Cancelling a Context Doesn't Stop a Single Goroutine
Your context was cancelled on time, and the goroutine kept running anyway. A request handler times out, the client disconnects, and three minutes later the goroutine that handler spawned is still chewing CPU and holding a database connection open. Nothing crashed. No error was logged. The goroutine just never noticed.
This trips people up because context.Context reads like a control mechanism. It has a Done() channel, a cancel() function, and an Err() method that tells you why it stopped. It looks like it has authority over the code running underneath it.
It doesn't. All cancel() does is close a channel. Whether anything downstream cares is entirely up to that code.
cancel() closes a channel, and that's the whole mechanism
Strip away the parent chains and value lookups, and the cancellation mechanism in context is a small, honest thing. Roughly:
type cancelCtx struct {
context.Context
done chan struct{}
err error
}
func (c *cancelCtx) cancel(err error) {
c.err = err
close(c.done) // this is the entire mechanism
}
func (c *cancelCtx) Done() <-chan struct{} { return c.done }
func (c *cancelCtx) Err() error { return c.err }
That's it. cancel() closes a channel and records an error. It does not:
- touch the stack of any goroutine
- raise a signal
- unwind anything
Every reader of that context has to be actively selecting on Done(), at a point in its own execution where it's willing to stop, for cancellation to have any effect at all.
Go has no primitive for forcibly interrupting a running goroutine from outside it. There is no equivalent of a thread kill. If a goroutine never checks, the context can be cancelled a thousand times over and it will run to completion regardless.
This is why the pattern is called cooperative cancellation. "Cooperative" is doing a lot of work in that phrase: the goroutine has to actually cooperate, and plenty of code, especially code that predates a context-aware rewrite, does not.
Three places where the context gets passed and then ignored
The failure mode is rarely "I forgot to pass the context". It's usually "I passed it, but nothing downstream ever looks at it". Here are a few common places that happens.
CPU-bound loops never consult Done()
func processAll(ctx context.Context, jobs []Job) {
for _, job := range jobs {
result := transform(job) // pure computation, no I/O, no channel ops
save(result)
}
}
There is no blocking operation anywhere in this loop for ctx.Done() to interrupt, because it was never consulted in the first place. A one-second timeout will not stop a ten-minute loop. The fix is to check periodically:
func processAll(ctx context.Context, jobs []Job) error {
for _, job := range jobs {
if err := ctx.Err(); err != nil {
return err
}
result := transform(job)
save(result)
}
return nil
}
Cheap, and it turns an unkillable loop into one that stops within a single iteration of the cancellation firing.
A send nobody receives blocks forever
func worker(ctx context.Context, out chan<- Result) {
r := doWork()
out <- r // blocks forever if nobody is receiving any more
}
If the receiver gave up after a timeout and stopped reading from out, this send blocks permanently. The goroutine leaks for the lifetime of the process.
This one needs a select, not just an error check, because the blocking point is the send itself:
func worker(ctx context.Context, out chan<- Result) {
r := doWork()
select {
case out <- r:
case <-ctx.Done():
}
}
APIs that were never wired up to a context
Sometimes the context you're carrying around has nowhere to attach. Typical culprits:
- anything built on
http.Getinstead of a request built withhttp.NewRequestWithContext - any database call using a method without a
Contextsuffix - any third-party client with an older non-context API
When a "context-aware" service still leaks goroutines, check whether the actual blocking call several layers down ever received the context at all.
Quick detour, since we're here: go vet has a lostcancel check that flags a common relative of this bug. It catches discarding the cancel function returned by context.WithCancel or context.WithTimeout without ever calling it.
That's a different leak: the context object and its timer goroutine outlive their usefulness, rather than downstream work ignoring cancellation. But it's caught automatically and worth having in CI if it isn't already.
When cancellation works, it's because something breaks the operation
It's worth looking at a case where context cancellation demonstrably works, because the mechanism is instructive.
When you cancel the context behind an in-flight HTTP request, net/http's transport is watching ctx.Done() in a separate goroutine. When it fires, the transport closes the underlying TCP connection.
The blocked Read on that socket doesn't get "cancelled" in any abstract sense. The file descriptor it's reading from gets forcibly closed out from under it, and the syscall returns an error because the connection no longer exists. The context doesn't stop the read. It arranges for the read to fail.
That distinction tells you what's required for cancellation to work anywhere:
- something has to be watching
Done() - that something has to be able to take an action which actually unblocks the operation in progress
A channel close on its own does nothing. It's only useful in combination with code positioned to react to it.
Some blocking calls can't be reached at all
Some blocking operations can't be interrupted this way, because they're not happening inside anything the Go runtime scheduler controls.
Regular file reads
A blocking read from a regular file via os.File is one. There's no context parameter on that API, and no non-blocking I/O path underneath it for the runtime to poll and interrupt the way it does with network sockets. A goroutine blocked in a filesystem read stays blocked until the read completes, however aggressively you cancel its context.
Cgo calls
If a goroutine is inside a blocking C function call, the OS thread it's running on is genuinely stuck in that call. Go's scheduler can spin up another OS thread so other goroutines keep making progress (the same mechanism that keeps the program responsive when a goroutine blocks on a syscall).
But the original thread, and the goroutine pinned to it, doesn't come back until the C call returns on its own.
DNS, where both problems meet
DNS resolution is where these two points intersect in a way that catches people out. Go has two resolvers:
- the pure Go resolver, which does its own non-blocking network I/O and honours context deadlines properly
- the cgo resolver, which shells out to the system's
getaddrinfo
On the cgo path, a context timeout doesn't stop the lookup. It just gives up waiting for it from the calling goroutine's side, and the actual resolution continues on a leaked thread until the C library call returns.
Which resolver is active depends on build tags and the target OS. It's exactly the kind of thing worth checking if you've seen DNS lookups apparently ignore a deadline in production.
Hang on, why can exec.CommandContext kill things?
Because exec.CommandContext manages a child process rather than a goroutine. When the context is cancelled, Go can and does send a kill signal to that process, because a process is a real OS-level entity the kernel is willing to terminate on request.
A goroutine has no such entity behind it. It's just a stack and a program counter the scheduler is running; there's nothing external to signal.
Spotting the leaks after the fact
None of this fails loudly, so the practical question is how you notice it's happening. Two tools do most of the work:
net/http/pprof's goroutine profile is the first stop. A growing, never-shrinking goroutine count under sustained load, with stack traces that all sit at the same blocking point, is close to a diagnostic signature for exactly this bug.- In tests, uber-go's
goleakpackage is the standard way to catch it before it reaches production, by asserting that no unexpected goroutines are still alive when a test finishes.
The rule that falls out of all this is simple to state, even if applying it consistently across a codebase takes discipline. Every blocking point that could outlive the caller's interest in its result needs to either accept a context and use it, or sit inside a select alongside ctx.Done().
Passing the context down the call chain is necessary but not sufficient. Something at the bottom of that chain has to actually look at it.