Common Goroutine Leaks in Go and How to Avoid Them
Goroutine leaks are one of the most common issues in Go applications. A goroutine leak occurs when a goroutine is started but never terminates, consuming memory and potentially other resources indefinitely. In this post, we’ll explore the most common causes of goroutine leaks and how to prevent them.
What is a Goroutine Leak?
A goroutine leak happens when a goroutine is blocked forever, waiting for something that will never happen. Unlike memory leaks in other languages, goroutine leaks are particularly insidious because each leaked goroutine consumes at least 2KB of stack space and may hold references to other resources.
// This goroutine will leak - nothing ever sends to the channel
func leak() {
ch := make(chan int)
go func() {
val := <-ch // Blocked forever
fmt.Println(val)
}()
// Function returns, but goroutine is still waiting
}
Common Causes of Goroutine Leaks
1. Forgotten Channel Receivers
The most common leak occurs when a goroutine waits on a channel that never receives data.
// Leaky version
func processAsync(data []int) {
results := make(chan int)
for _, d := range data {
go func(val int) {
results <- process(val)
}(d)
}
// Oops! We never read from results
}
// Fixed version
func processAsync(data []int) []int {
results := make(chan int, len(data)) // Buffered channel
for _, d := range data {
go func(val int) {
results <- process(val)
}(d)
}
output := make([]int, len(data))
for i := range data {
output[i] = <-results
}
return output
}
2. Missing Context Cancellation
When using context for cancellation, forgetting to check for cancellation causes leaks.
// Leaky version - ignores context
func worker(ctx context.Context) {
for {
doWork()
time.Sleep(time.Second)
}
}
// Fixed version - respects context
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
doWork()
}
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
}
}
3. Blocked Sends on Unbuffered Channels
When a goroutine tries to send on an unbuffered channel with no receiver, it blocks forever.
// Leaky version
func fetch(urls []string) map[string]string {
results := make(map[string]string)
ch := make(chan result) // Unbuffered!
for _, url := range urls {
go func(u string) {
ch <- result{url: u, body: httpGet(u)}
}(url)
}
// If we return early due to error, goroutines leak
for i := 0; i < len(urls); i++ {
r := <-ch
if r.body == "" {
return results // Remaining goroutines are stuck!
}
results[r.url] = r.body
}
return results
}
// Fixed version - use buffered channel
func fetch(urls []string) map[string]string {
results := make(map[string]string)
ch := make(chan result, len(urls)) // Buffered
for _, url := range urls {
go func(u string) {
ch <- result{url: u, body: httpGet(u)}
}(url)
}
for i := 0; i < len(urls); i++ {
r := <-ch
if r.body != "" {
results[r.url] = r.body
}
}
return results
}
4. Infinite Loops Without Exit Conditions
Goroutines running infinite loops without proper termination signals will run forever.
// Leaky version
func startWorker() {
go func() {
for {
processQueue()
time.Sleep(100 * time.Millisecond)
}
}()
}
// Fixed version
func startWorker(done chan struct{}) {
go func() {
for {
select {
case <-done:
return
default:
processQueue()
}
select {
case <-done:
return
case <-time.After(100 * time.Millisecond):
}
}
}()
}
5. Leaked Ticker or Timer
Tickers and timers that aren’t stopped will continue running.
// Leaky version
func poll(ctx context.Context) {
ticker := time.NewTicker(time.Second)
// ticker.Stop() never called!
for {
select {
case <-ctx.Done():
return // Ticker continues in background!
case <-ticker.C:
doWork()
}
}
}
// Fixed version
func poll(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop() // Always stop the ticker
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
doWork()
}
}
}
Best Practices to Prevent Goroutine Leaks
1. Always Use Context for Cancellation
Pass context to all goroutines and check for cancellation:
func worker(ctx context.Context, jobs <-chan Job) {
for {
select {
case <-ctx.Done():
return
case job := <-jobs:
processJob(job)
}
}
}
2. Use errgroup for Coordinated Goroutines
The golang.org/x/sync/errgroup package helps manage goroutine lifecycles:
func processAll(ctx context.Context, items []Item) error {
g, ctx := errgroup.WithContext(ctx)
for _, item := range items {
item := item // Capture loop variable
g.Go(func() error {
return process(ctx, item)
})
}
return g.Wait()
}
3. Use Buffered Channels When Appropriate
Buffered channels prevent goroutines from blocking on sends:
func fanOut(input <-chan int, workers int) <-chan int {
output := make(chan int, workers) // Buffer prevents blocking
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for val := range input {
output <- process(val)
}
}()
}
go func() {
wg.Wait()
close(output)
}()
return output
}
4. Detect Leaks with Testing
Use goleak in tests to detect goroutine leaks:
import "go.uber.org/goleak"
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
func TestWorker(t *testing.T) {
defer goleak.VerifyNone(t)
ctx, cancel := context.WithCancel(context.Background())
startWorker(ctx)
// Do test work...
cancel() // Clean up
time.Sleep(100 * time.Millisecond) // Allow goroutine to exit
}
5. Monitor Goroutine Count in Production
Track goroutine counts to detect leaks early:
import "runtime"
func reportGoroutineCount() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
count := runtime.NumGoroutine()
metrics.Gauge("goroutine_count", float64(count))
if count > threshold {
log.Warnf("High goroutine count: %d", count)
}
}
}
Debugging Goroutine Leaks
When you suspect a leak, use pprof to investigate:
import _ "net/http/pprof"
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// ...
}
Then inspect with: go tool pprof http://localhost:6060/debug/pprof/goroutine
Wrapping Up
Goroutine leaks can silently degrade your application’s performance. The key principles to prevent them are:
- Always provide a way for goroutines to terminate
- Use context for cancellation propagation
- Prefer buffered channels when the receiver might not consume all messages
- Stop timers and tickers with defer
- Test for leaks using tools like goleak
- Monitor goroutine counts in production
By following these practices, you’ll write concurrent Go code that’s both efficient and leak-free. For more on managing goroutines, check out our post on error groups and context in Go.