Go / Editorial solution guide
Resolve Goroutine Leaks, Deadlocks, and Cancellation Gaps
Find the blocking operation without a reachable completion event and repair both cancellation and joining.
Read-only knowledge base ยท No public comments
Recognize the symptoms
A request returns but owned workers remain blocked, shutdown never completes, or a local program reports "fatal error: all goroutines are asleep - deadlock!". That runtime message is not guaranteed for every deadlock; timers, network activity, or unrelated goroutines can keep a process alive while application work is stuck.
Understand the likely causes
- A channel send may have no receiver, a receive may wait for a channel never closed, or nil channels may disable progress. Cancellation can be ignored around a blocking operation, and cancel does not join goroutines. Buffers postpone a missing consumer problem without defining shutdown, while closing from arbitrary receivers can introduce send-on-closed failures.
1. Inventory owned goroutines and blocking points
For each goroutine, write its starter, stopping event, blocking operations, resources, and completion observer. Inspect local goroutine profiles or stack dumps in a controlled reproduction to find repeated blocked frames. Do not rely on raw goroutine counts alone because the runtime and test infrastructure may create unrelated goroutines. Locate the missing event rather than adding sleeps.
2. Classify channel state and closing ownership
A nil send or receive blocks indefinitely; a nil select case is disabled. An open empty channel is different from a closed drained one, whose receive returns a zero value and false. Assign closing to the sender or coordinator that knows every sender has finished. Do not close a channel merely because one consumer is no longer interested.
3. Add cancellation and explicit completion
The complete example waits on cancellation alongside an input receive. Its input is intentionally nil, so that case cannot progress; cancellation provides the exit path. main calls cancel and then waits for done before reporting. This safely demonstrates the missing-input case without executing an intentional deadlock. The worker owns closing done and never closes input.
package main
import (
"context"
"fmt"
)
func start(ctx context.Context, input <-chan int) <-chan struct{} {
done := make(chan struct{})
go func() {
defer close(done)
for {
select {
case <-ctx.Done():
return
case _, ok := <-input:
if !ok {
return
}
}
}
}()
return done
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var input <-chan int
done := start(ctx, input)
cancel()
<-done
fmt.Println("worker joined")
}
worker joined
The nil case is disabled; cancellation ends the worker, and done acknowledges its return.
4. Cover every blocking stage
A cancellation-aware receive does not repair a later plain blocking send or an uncancellable I/O call. Use context-aware APIs or explicit deadlines/closure under a documented resource protocol. A select does not interrupt an operation already started outside it. Wrapping the call in another goroutine can simply create a new leak if no one can stop that call.
5. Define early-return and partial-result policy
When a consumer needs only one result, cancel unused work and join every owned stage before releasing shared state. Define whether a final result racing with cancellation is acceptable, since ready select cases have no source-order priority. Bound queues and admission as well as workers. Preserve any already committed effects honestly rather than implying cancellation rolled them back.
Verify the fix
The complete example must print worker joined and terminate without a sleep. Add bounded tests for cancellation before start, blocked send, nil input, closed input, and downstream rejection. Require explicit completion signals and correct resource state. Use supported race checks to inspect shared-state safety, but remember that absence of a data race does not prove freedom from deadlock or leaks.
Keep it from recurring
- Make starters responsible for a stop and join protocol, call derived-context cancel functions on all paths, and keep one closing owner per channel. Review all early returns for stranded senders and retained references. Diagnose lifecycle defects instead of enlarging buffers or recovering send-on-closed panics.