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:
- A bounded Worker Pool (or an
errgroupwith a concurrency limit) to cap the maximum active goroutines. - 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 intin the anonymous function signature and directly referencingidinsidego 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_STREAMframe over HTTP/2, propagating the cancellation downstream to conserve global infrastructure resources.
Discussion & Comments