Code review: why does this timeout wrapper leak goroutines?
Question 501HardGo 1.22 to 1.25
func fetchWithTimeout(ctx context.Context) (string, error) {
ch := make(chan string) // BUG: unbuffered
go func() {
ch <- slowFetch() // blocks forever if nobody receives
}()
select {
case v := <-ch:
return v, nil
case <-time.After(time.Second):
return "", errors.New("timeout")
}
}
When the timeout fires, the function returns and no one ever receives from ch. The goroutine stays blocked on the send forever, along with everything it references. Under load, that means thousands of leaked goroutines and growing memory.
Fixes:
func fetchWithTimeout(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
ch := make(chan string, 1) // buffered: the send never blocks
go func() { ch <- slowFetchCtx(ctx) }()
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
The buffer of 1 lets the worker finish and exit. Passing ctx lets slowFetch actually stop its work instead of running to completion unused. Also point out that time.After in a hot loop allocates a timer each time. Since Go 1.23, unreferenced timers are garbage-collected, but context or a reused time.Timer is still cleaner. Catch leaks in tests with go.uber.org/goleak.
More on Tricky Output & Code-Review Puzzles
- Q499Which of these deadlock, and why does Go detect some deadlocks but not others?
- Q500Is msg guaranteed to print "hello"? Compare unbuffered and buffered channels.
- Q502Which of these recover calls actually stop the panic?
- Q503Does the recover in main protect this program?
- Q504A deferred function panics while another panic is in progress. What prints?
- Q505What does this integer puzzle print?