The Phantom Deadlock: Broken Concurrency in Go

📅 Oct 05, 2026 ★★★★☆ 📚 Concurrency, Go Language, Code Review
#Goroutines #Channels #Memory Leaks #Deadlock #Go

Scenario: The Async Aggregator Bug

An applicant is presented with a production Go microservice that aggregates user profiles from multiple microservices concurrently. An AI assistant generated the worker pool to enforce a strict 200ms timeout per batch:

package main

import (
	"context"
	"errors"
	"time"
)

type Result struct {
	Data string
	Err  error
}

func FetchData(ctx context.Context, id int) (string, error) {
	// Simulates variable upstream network latency
	time.Sleep(300 * time.Millisecond)
	return "payload", nil
}

func AggregateUserData(ids []int) ([]string, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer cancel()

	ch := make(chan Result)

	for _, id := range ids {
		go func(targetID int) {
			data, err := FetchData(ctx, targetID)
			ch <- Result{Data: data, Err: err}
		}(id)
	}

	var results []string
	for range ids {
		select {
		case res := <-ch:
			if res.Err != nil {
				return nil, res.Err
			}
			results = append(results, res.Data)
		case <-ctx.Done():
			return nil, errors.New("timeout reached")
		}
	}

	return results, nil
}
Q:The candidate runs this code in a test environment with 10 IDs. The function exits after 200ms with timeout reached, which looks correct. However, after running this under load for two hours, the container crashes with an Out of Memory (OOM) error. Where is the silent memory leak? Reveal â–¾

The leak is caused by unbuffered channel blocking leading to goroutine leaks.

The channel is unbuffered (make(chan Result)). When the 200ms timeout occurs, AggregateUserData aborts and executes return nil, errors.New(...). The function exits, and nothing ever reads from ch again.

When the 10 spawned goroutines finish their 300ms FetchData calls, they all attempt to send to ch via ch <- Result{...}. Because the channel is unbuffered and has no active receiver, all 10 goroutines block forever. They cannot be garbage-collected, retaining their call stacks and allocated memory until the process exhausts its OS limits.

Q:The candidate suggests fixing it by changing the channel to make(chan Result, len(ids)). Does this resolve the goroutine leak? What new bug does this introduce if ids contains 50,000 entries? Reveal â–¾

Using a buffered channel of size len(ids) does fix the goroutine leak for timeouts, because the goroutines can write their result and exit immediately without waiting for an active receiver.

However, if ids contains 50,000 entries, this introduces an Unbounded Goroutine Explosion. Launching 50,000 goroutines concurrently creates massive CPU scheduler thrashing, consumes hundreds of megabytes of memory for goroutine stacks, and exhausts upstream socket limits (opening 50,000 simultaneous network connections).

The production fix requires two components:

  1. A bounded Worker Pool (or an errgroup with a concurrency limit) to cap the maximum active goroutines.
  2. Checking ctx.Done() inside the goroutine before sending:
select {
case ch <- Result{Data: data, Err: err}:
case <-ctx.Done():
}
Q:Look closely at the FetchData call inside the goroutine: FetchData(ctx, targetID). FetchData accepts ctx, but sleeps using time.Sleep. If cancel() is executed by defer, does FetchData actually stop processing? Reveal â–¾

No. Passing ctx into a function is merely an API convention; it does not automatically stop execution unless the code inside the function actively listens to ctx.Done().

Because FetchData simply uses time.Sleep(300 * time.Millisecond), it completely ignores the cancellation signal. The worker thread continues consuming CPU and socket resources all the way to completion even though the caller has already abandoned the result. To respect cancellation, asynchronous functions must select on ctx.Done():

select {
case <-time.After(300 * time.Millisecond):
    return "payload", nil
case <-ctx.Done():
    return "", ctx.Err()
}

Variations & Real-World Impact

  • Closure Variable Capture (Go Pre-1.22): In older versions of Go, omitting targetID int in the anonymous function signature and directly referencing id inside go func() { FetchData(ctx, id) }() caused all goroutines to share the exact same loop variable pointer, resulting in every goroutine processing the last element of the slice.
  • Context Propagation: In microservice frameworks like gRPC, canceled contexts automatically send an RST_STREAM frame over HTTP/2, propagating the cancellation downstream to conserve global infrastructure resources.

Further Exploration

Discussion & Comments

SDB Watermark