Ramblings of an aging IT geek
← Ramblings of an aging IT geek
golang

context.Context, and learning to thread it all the way through

What finally made Go's context package click for me: cancellation propagation, the rules I follow now, and the mistake I kept making with values.

Go code on a screen

For a long time context.Context was the parameter I copied around because the linter and everyone else's code told me to. I passed it down, I never looked at it, and I had no real model of what it was doing. The thing that finally made it click was a leaked goroutine I could not explain, and the realisation that context is, at heart, one idea: a way to say "stop" that travels.

It's a cancellation tree, not a bag of stuff

The mental model that fixed it for me: context is a tree of cancellation signals. You start with a root, usually context.Background(), and every time you derive a child with WithCancel, WithTimeout, or WithDeadline, you hang a new node off the tree. Cancel a parent and every descendant is cancelled too. That's the whole point. The signal flows down.

ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()

rows, err := db.QueryContext(ctx, query, args...)

If that query takes longer than two seconds, or the incoming request is cancelled because the client hung up, ctx.Done() fires and a context-aware database driver abandons the query. The leaked goroutine I'd been chasing was a goroutine doing work nobody was waiting for any more, because I'd never given it a context to watch.

The leak, and the fix

My bug was the classic one. I spun off a goroutine to do background work, passed it nothing, and when the request that started it went away, the goroutine carried on, holding a connection, forever. The fix was to pass the request's context in and actually check it:

func worker(ctx context.Context, jobs <-chan Job) {
    for {
        select {
        case <-ctx.Done():
            return
        case job := <-jobs:
            process(ctx, job)
        }
    }
}

select on ctx.Done() is how a goroutine notices it's no longer wanted. Without it, the context might as well not exist. Threading the parameter through is necessary, but it does nothing on its own; something at the bottom has to actually listen.

The rules I follow now

  • Context is the first parameter, named ctx, and it never goes in a struct field. If a function does work that can block or take time, it takes a context.
  • Always call the cancel function WithCancel and friends return, usually with defer cancel(). Not calling it leaks resources even when the work finishes early.
  • Don't pass nil. If you genuinely have no parent, use context.Background() or, for code you haven't wired up yet, context.TODO() so it's at least greppable later.

The mistake I kept making with values

context.WithValue looks like a tidy way to pass things around, and that is exactly the trap. I used it to smuggle a database handle down a call stack once, and the result was a function whose dependencies were invisible: you could not tell from its signature what it needed. Context values are for request-scoped data that genuinely crosses API boundaries, a trace ID, an authenticated user, a request deadline's worth of metadata. They are not a substitute for passing your dependencies as arguments. If a function needs a thing to do its job, give it the thing. Keep the context for cancellation and the small amount of metadata that really does ride along with the request, and it stays comprehensible.

Once I stopped treating context as a magic parameter and started treating it as a "stop" signal with a tree behind it, the rest fell into place. The goroutine leak went away. So, mostly, did my confusion.