Skip to Content
Course content

64: Understanding Goroutine Leaks

Click on the "Edit" button in the top corner of the screen to edit your slide content.

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.")
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.