Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Go

Published:

Tags:
  • go
  • concurrency
  • context
  • goroutines
  • debugging

context.Context Cancellation Doesn't Stop Anything: Why Your Goroutines Keep Running Anyway

A request handler times out, the client disconnects, and yet 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 context was cancelled exactly when you expected. 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, 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.

What cancel() actually does

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, does not raise a signal, does not 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: it means the goroutine has to actually cooperate, and plenty of code, especially code that predates a context-aware rewrite, does not.

Where it quietly stops working

The failure mode is rarely "I forgot to pass the context". It's usually "I passed it, but nothing downstream ever looks at it". A few common places that happens:

CPU-bound loops

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.

Unbuffered channel operations

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

Anything built on http.Get instead of a request built with http.NewRequestWithContext, any database call using a method without a Context suffix, any third-party client with an older non-context API: the context you're carrying around has nowhere to attach. It's worth checking, when a "context-aware" service still leaks goroutines, whether the actual blocking call several layers down ever received the context at all.

Since we're here: go vet has a lostcancel check that flags a common relative of this bug, 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 outliving their usefulness rather than downstream work ignoring cancellation, but it's caught automatically and worth having in CI if it isn't already.

How cancellation ever actually stops anything

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, and when it fires, it 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 matters because it tells you what's required for cancellation to work anywhere: something has to be watching Done(), and 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.

The genuinely uninterruptible cases

Some blocking operations can't be reached this way at all, because they're not happening inside anything the Go runtime scheduler controls.

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 will stay blocked until the read completes, however aggressively you cancel its context.

Cgo calls are another. 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 (this is 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 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, and 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. 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, and it's exactly the kind of thing worth checking if you've seen DNS lookups apparently ignore a deadline in production.

Contrast this with exec.CommandContext, which 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.

Finding leaks after the fact

Since none of this fails loudly, the practical question is how you notice it's happening. 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 goleak package 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 enough 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.