14.5 Goroutine Leaks and How to Prevent Them
Right, let’s talk about goroutine leaks. This is where the magic of “just fire off a goroutine for everything!” starts to feel less like a superpower and more like you’ve accidentally hired an intern who never, ever goes home. They just keep stacking pizza boxes in the corner of the breakroom, muttering about channels. A goroutine leak happens when you start a goroutine that is supposed* to terminate at some point, but due to a logic error, it never does. It becomes the undead of your concurrency model: shambling around, consuming resources, and waiting for a signal to rest that never comes.
The real danger here is that these leaks are often silent. Your program doesn’t panic. It just gets slower and slower, consuming more and more memory, until eventually, the OOM killer on your machine takes pity on you and puts the whole thing out of its misery. Not a great user experience.
The Blocked Send (Or Receive) That Never Comes
This is the classic, the bread and butter of goroutine leaks. It happens when a goroutine is waiting to send a message to a channel that no one will ever read from, or waiting to read from a channel that no one will ever write to. It’s like waiting for a text back from someone who blocked you.
Here’s a prime example. Imagine a function that starts a goroutine to do some work and report the result back. But what if the caller, for whatever reason, decides it doesn’t care about the result and returns early?
func processData(data string) chan string {
resultCh := make(chan string)
go func() {
// Simulate some expensive work
processed := "Result: " + data
// This send will block forever if nothing reads from resultCh
resultCh <- processed
}()
return resultCh
}
func main() {
resultCh := processData("important info")
// Oops, we got distracted! Let's just return early...
return
// The goroutine inside processData is now permanently blocked,
// waiting for something to read from resultCh. Leak achieved.
}
The fix? Contexts. Always use contexts. They are your emergency brake cord for goroutines.
func processData(ctx context.Context, data string) chan string {
resultCh := make(chan string)
go func() {
defer close(resultCh) // Good practice when you're the sender
processed := "Result: " + data
select {
case resultCh <- processed:
// Happy path! Result was delivered.
case <-ctx.Done():
// Caller got bored and left. Time to clean up and go home.
log.Println("context cancelled, abandoning send")
}
}()
return resultCh
}
The Forgotten Goroutine in an Infinite Loop
This one is a special kind of facepalm. You fire off a goroutine that has a for loop, but you forget to include a way to break out of it. The most common culprit? A for loop that pulls from a channel, but the channel is never closed.
func monitorSomething() {
go func() {
for {
// Do some periodic task...
time.Sleep(1 * time.Second)
// But there's no way to tell this goroutine to stop!
// It will run until your main() function exits.
}
}()
}
The designers gave us a great tool for this: the done channel pattern, often orchestrated by context.Context.
func monitorSomething(ctx context.Context) {
go func() {
for {
select {
case <-time.After(1 * time.Second):
// Do the periodic task
doTask()
case <-ctx.Done():
// The context was cancelled, time to exit.
fmt.Println("Shutting down monitor")
return // This is crucial! Return exits the goroutine.
}
}
}()
}
// Later, to stop it: cancel() the context
The Subtle Leak: Abandoned time.After
This one is sneaky and catches even experienced gophers. time.After is incredibly convenient, but it creates a channel under the hood and starts a timer. If that timer fires and no one is listening on the channel anymore, the timer resource sits in the heap until it fires and the garbage collector eventually gets it. In a hot loop, this can lead to a buildup of thousands of abandoned timers.
func waitForThing(ch <-chan string) {
for {
select {
case s := <-ch:
fmt.Println(s)
case <-time.After(5 * time.Second):
fmt.Println("Timed out waiting for thing")
// The problem: every time we time out, time.After creates a new channel.
// The previous one is still hanging out in memory until its 5 seconds are up.
}
}
}
The solution is to use time.NewTimer instead and manage it properly. Reset it for each loop iteration.
func waitForThingFixed(ch <-chan string) {
timer := time.NewTimer(5 * time.Second)
defer timer.Stop() // Important! Clean up the timer when we're done.
for {
// Reset the timer for each iteration.
// We have to drain the channel if it fired already, which is a bit clunky but necessary.
if !timer.Stop() {
select {
case <-timer.C: // Try to drain the channel if it's full
default:
}
}
timer.Reset(5 * time.Second)
select {
case s := <-ch:
fmt.Println(s)
case <-timer.C:
fmt.Println("Timed out waiting for thing")
}
}
}
Yes, it’s more code. That’s the trade-off. The language designers chose convenience (time.After) as the easy default, and left the more performant, resource-conscious option (time.NewTimer) to you. For most one-off timeouts, time.After is fine. For loops that run frequently and for a long time, you need to reach for the timer.
The golden rule: Know how your goroutines will end. Before you write go, ask yourself: “What signal will make this stop?” If the answer is “uh… nothing,” you’ve found your leak. Use contexts religiously for cancellation, and always structure your code so that goroutines have a clear and guaranteed exit path. Your program’s memory profile will thank you.