Go
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Functions and Methods
-
Section 4: Concurrency
-
Section 5: Packages and Tooling
-
Section 6: More Standard Library
-
Section 7: Building Services
-
Section 8: Advanced Go
-
Section 9: Go in the Cloud-Native Ecosystem
-
Section 10: Data Structures and Algorithms in Go
-
Section 11: Testing and Deployment
-
Section 12: Practical Projects
-
Section 13: More Standard Library Practice
-
Section 14: More Practice Projects
-
Section 15: Design Patterns in Go
-
Section 16: Interview Practice
-
Section 17: Package fmt In Depth
-
Section 18: Package strings and strconv
-
Section 19: Package os and io
-
Section 20: Package time
-
Section 21: Package sort and container
-
Section 22: Package encoding
-
Section 23: Package net/http In Depth
-
Section 24: Package context
-
Section 25: Package regexp and bytes
-
Section 26: Package errors In Depth
-
Section 27: Package crypto and hash
-
Section 28: Package flag and log
-
Section 29: Package sync In Depth
-
Section 30: More Practice Exercises
-
Section 31: Go Modules and Workspaces In Depth
-
Section 32: Generics Deep Dive (Go 1.18+)
-
Section 33: Testing Package In Depth
-
Section 34: More Interview and Whiteboard Practice
-
Section 35: Package math and unicode
-
Section 36: Package path and filepath
-
Section 37: Package database/sql
-
Section 38: Package text/template and html/template
-
Section 39: Package archive and compress
-
Section 40: Lower-Level net Package
-
Section 41: Package runtime and reflect
-
Section 42: Package embed and io/fs
-
Section 43: Go Assembly and CGO Basics
-
Section 44: Building CLIs and TUIs
-
Section 45: Go Performance Tuning
-
Section 46: More Real-World Projects
-
Section 47: Go in Production
-
Section 48: Go Design Patterns
-
Section 49: Go Interfaces Deep Dive
-
Section 50: Final Practice and Review
64: Understanding Goroutine Leaks
I want to show you a piece of code that looks perfectly reasonable at first glance. It's a pattern I see all the time: a function that kicks off a background task and uses a select block to implement a timeout. It's the "standard" way to make sure your application doesn't hang forever waiting on a slow dependency.
package main
import (
"fmt"
"time"
)
func fetchData() string {
ch := make(chan string)
go func() {
// Simulate a slow network call
time.Sleep(2 * time.Second)
ch <- "Success: Data received!"
}()
select {
case res := <-ch:
return res
case <-time.After(1 * time.Second):
return "Error: Timeout reached"
}
}
func main() {
for i := 0; i < 10; i++ {
fmt.Println(fetchData())
}
fmt.Println("Finished processing requests.")
}
If you run this, it works exactly as expected. You'll see "Error: Timeout reached" ten times, and the program will exit. But there's a hidden disaster happening here. Every time fetchData times out, it leaves behind a "ghost" goroutine.
The Silent Resource Drain
Here is what's happening under the hood: when the time.After case wins the race, fetchData returns immediately. However, that background goroutine is still running. After two seconds, it tries to send the string "Success: Data received!" into the channel ch.
Since ch is an unbuffered channel, the sender must block until someone is there to receive the value. But the select block is gone. The function has returned. There is no longer any code in the entire program listening to that channel. That goroutine is now blocked forever. It will never finish, and the garbage collector cannot reclaim it because it's still technically "active," waiting on a channel operation.
In a small script, this doesn't matter. In a production server running for three months, this is how you end up with 50,000 idle goroutines eating up your RAM until the OS kills your process with an Out-Of-Memory (OOM) error. I've spent many late nights debugging these "slow leaks" because they don't crash the app immediately—they just degrade performance over time.
Preventing the Hang with Buffered Channels
The simplest fix for this specific pattern is to use a buffered channel. If we give the channel a capacity of 1, the background goroutine can drop its result into the "mailbox" and exit immediately, regardless of whether the main function is still listening.
func fetchData() string {
// Adding a buffer of 1 allows the sender to finish even if the receiver is gone
ch := make(chan string, 1)
go func() {
time.Sleep(2 * time.Second)
ch <- "Success: Data received!"
}()
select {
case res := <-ch:
return res
case <-time.After(1 * time.Second):
return "Error: Timeout reached"
}
}
Now, when the timeout occurs, the background goroutine wakes up, puts its value into the buffer, and terminates normally. The channel and the result will eventually be cleaned up by the garbage collector. I generally recommend buffered channels for these "single-result" patterns because they are lightweight and easy to reason about.
Using Context for Complex Lifecycles
Buffers are great for single values, but what if your background task is doing a lot of work—maybe looping through a database cursor or downloading a large file? A buffer won't stop the work from happening; it only stops the send from blocking.
In those cases, you need to tell the goroutine to stop working entirely. This is where context.Context becomes your best friend. By passing a context into the goroutine, the worker can periodically check ctx.Done() and exit early if the caller has given up.
func fetchData(ctx context.Context) string {
ch := make(chan string, 1)
go func() {
// Simulate a process that can be cancelled
select {
case <-time.After(2 * time.Second):
ch <- "Success!"
case <-ctx.Done():
// The caller stopped caring; we exit now to save resources
return
}
}()
select {
case res := <-ch:
return res
case <-ctx.Done():
return "Cancelled or Timed Out"
}
}
By using a context, you aren't just fixing the leak at the channel level; you're stopping the actual execution of the logic. It's a more robust approach and the standard for any professional Go codebase.
📋 Practical Task
Fixing the Leaky API Poller
You've been handed a piece of code that polls an external API. The developer tried to implement a "fast-fail" mechanism, but the application's memory usage is climbing steadily every time the API is slow.
Your Task: Modify the pollAPI function to prevent goroutine leaks. You can use either a buffered channel or a context, but ensure that no matter how many times the timeout is hit, no orphaned goroutines remain in memory.
package main
import (
"fmt"
"time"
)
func pollAPI(id int) string {
ch := make(chan string)
go func() {
// Simulate an API call that takes varying amounts of time
time.Sleep(time.Duration(id%3) * time.Second)
ch <- fmt.Sprintf("Result from API %d", id)
}()
select {
case res := <-ch:
return res
case <-time.After(500 * time.Millisecond):
return "API Timeout"
}
}
func main() {
for i := 1; i <= 100; i++ {
pollAPI(i)
}
fmt.Println("Polled 100 times.")
}
There are no comments for now.