13 · Context cancellation done right¶
Objectives: Understand what
context.Contextis and why Go's concurrent workers need it. Learn how to write a worker loop that stops reliably when the caller cancels — and understand the subtle pitfall that makes a bareselectmiss cancellations intermittently. This lesson sets up Lesson 14, where we look at what happens when the channel buffers are sized wrong.Estimated time: 20 minutes.
What this actually means (plain English)¶
No jargon — here's what the ideas in this lesson actually mean, and why they matter.
- Context = "a baton handed to every runner that the starter can yank back mid-race." The caller creates a
context.Context, passes it intoLoadBatchConcurrentorParseBatch, and can cancel it at any moment — every goroutine that received it is responsible for noticing and stopping promptly. ctx.Done()= "a door that swings open the instant the race is called off." It is a channel that closes when the context is cancelled or its deadline fires; workersselecton it alongside the jobs channel to hear the signal while blocked.ctx.Err()= "checking the scoreboard after the whistle — it tells you why the race stopped." It returnsnilwhile the context is live, then returnscontext.Canceled(someone calledcancel()) orcontext.DeadlineExceeded(the wall-clock budget ran out).- Bare
selectrace = "a coin flip between 'do one more item' and 'quit' — and the coin is biased toward work." When both the jobs channel andctx.Done()are ready at the same time, Go picks a branch uniformly at random, so a cancelled context can be silently ignored for hundreds of iterations. ctx.Err()guard at the top of the loop = "checking the scoreboard before picking up the next baton, not after." Theif err := ctx.Err(); err != nil { return }line inLoadBatchConcurrentruns before everyselect, so a worker that wakes up to an already-cancelled context exits on that very iteration rather than racing.- Why it matters: without this guard, a batch job on a deadline might happily process thousands of items after its deadline has passed, wasting CPU and memory and returning results the caller will discard anyway.
See it — worker loop: guard + select, two cancellation checkpoints.
The two functions we are reading¶
This lesson grounds every example in two real files from this repository (a "function" here is a named, reusable chunk of code that you can run — "call" or "invoke" — from elsewhere; it can hand a result back to whoever called it, which programmers call "returning"):
| File | Function |
|---|---|
stb-image-go/stb_image.go |
LoadBatchConcurrent — decodes a slice of images in parallel |
dr-wav-go/dr_wav.go |
ParseBatch — parses a slice of WAV files in parallel |
Both functions spin up a worker pool (in plain terms: they start several
independent "workers" — lightweight, independently-running tasks Go calls
goroutines — that each pick up jobs and run at the same time as one another;
this is what "concurrency" means), hand out jobs through a channel (a
channel is a safe pipe that one goroutine can put values into and another
can take values out of, one at a time, so they can pass work and results
back and forth without stepping on each other), and accept a
context.Context from the caller (the "caller" is whatever code called
this function — the one waiting for it to finish and hand back a result).
They solve the cancellation problem in slightly different ways, which makes
comparing them instructive.
What a context looks like at the call site¶
// Caller creates a context with a 5-second deadline.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // always release resources
images, err := stbimagego.LoadBatchConcurrent(ctx, rawImages)
In plain terms: the caller sets up a shared "stop signal" object (ctx)
that will automatically flip to "stopped" after 5 seconds, then hands that
object into LoadBatchConcurrent so every worker it spins up can watch for
the same signal.
cancel is a function. Calling it immediately signals every goroutine (a
goroutine is one of Go's lightweight, independently-running tasks — many can
be alive at once, each making progress on its own piece of work) that
received ctx to stop. defer cancel() (defer schedules a piece of code
to run automatically right before the current function returns, no matter
how it exits — here it guarantees cancel() gets called eventually) ensures
the signal fires even if the caller returns early due to an error.
WithTimeout vs WithCancel
context.WithTimeout(parent, d) is shorthand for context.WithDeadline(parent, time.Now().Add(d)).
Use WithCancel when you want manual control; use WithTimeout /
WithDeadline when you have a wall-clock budget.
The worker loop in stb-image-go¶
From stb-image-go/stb_image.go, the goroutine launched inside
LoadBatchConcurrent:
go func() {
defer wg.Done()
for {
// Check cancellation first: a bare select races between a ready
// job and ctx.Done() (Go picks randomly), so an already-canceled
// context would only be honored intermittently.
if err := ctx.Err(); err != nil {
errs <- err
return
}
select {
case idx, ok := <-jobs:
if !ok {
return // jobs channel closed and empty — normal exit
}
img, err := Load(datas[idx])
if err != nil {
errs <- fmt.Errorf("failed to decode image at index %d: %w", idx, err)
} else {
results[idx] = img
}
case <-ctx.Done():
errs <- ctx.Err()
return
}
}
}()
In plain terms: this code launches one worker (go func() { ... }()
starts the enclosed code as its own independent goroutine, running
alongside everything else). Each worker repeats a loop: first it checks
whether the stop signal has already fired; if not, it waits for either a
new job to arrive or the stop signal, whichever comes first; if a job
arrives, it decodes that one image and records the result or the error;
if the stop signal fires instead, it reports that and stops for good.
Two lines do the critical work:
if err := ctx.Err(); err != nil— this runs before theselect(aselectstatement waits — "blocks" — at that line, doing nothing else, until at least one of its listed channels has something ready; "blocking" just means the program pauses right there instead of moving on). If the context was already cancelled when we reach the top of the loop, we exit immediately without entering theselectat all.case <-ctx.Done():inside theselect— this catches a cancellation that arrives while the worker is blocked waiting for a job. Both branches together give reliable, prompt cancellation.
Why the top-of-loop check is not redundant¶
Imagine the jobs channel has 1000 items buffered and the context is cancelled.
Without the ctx.Err() guard, each iteration does a select with two ready
cases — jobs and ctx.Done(). Go picks randomly, so on average half the
iterations will drain a job instead of stopping. Statistically the worker would
process ~500 more images before it finally picks ctx.Done(). With the guard,
it exits on the next loop iteration.
The bare-select pitfall
This pattern is subtly wrong:
// BAD — do not do this
for {
select {
case work := <-jobs:
process(work)
case <-ctx.Done():
return
}
}
When both jobs and ctx.Done() are ready, Go's runtime picks one branch
uniformly at random. The worker may consume many more jobs before it
happens to land on ctx.Done(). Always guard with ctx.Err() first.
Contrast: the worker loop in dr-wav-go¶
From dr-wav-go/dr_wav.go, ParseBatch uses a structurally different
approach:
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case work, ok := <-dataChan:
if !ok {
return
}
wav, err := Parse(work.data)
resultChan <- result{wav: wav, err: err, index: work.index}
}
}
}()
In plain terms: this worker's loop only ever checks for the stop signal at the moment it is choosing between "wait for a job" and "wait to be cancelled" — it never checks before that, at the top of the loop, the way the image-decoding worker above does.
Notice what is missing: there is no ctx.Err() guard before the select.
The ctx.Done() case is listed first in the select block, but that does
not help — Go does not give priority to any case. Both cases get equal weight
when both are ready.
The WAV batch function compensates in a different place — the result collector
checks ctx.Err() after each result arrives:
This stops collecting results, but the workers themselves may still process extra items in the background. For a batch of large WAV files that is wasteful. The stb-image pattern (guard at the top of the worker loop) is tighter.
Neither is wrong for the repo's current use cases
The dr-wav approach is correct for correctness — it does eventually stop and never returns results after cancellation. The stb-image approach is more efficient under high cancellation pressure. The lesson is about understanding the trade-off so you can choose deliberately.
How the caller receives the cancellation signal¶
Back in stb-image-go/stb_image.go, after all workers finish:
wg.Wait()
close(errs)
multiErr := make([]error, 0, len(errs))
for err := range errs {
multiErr = append(multiErr, err)
}
if len(multiErr) > 0 {
if errors.Is(multiErr[0], context.Canceled) ||
errors.Is(multiErr[0], context.DeadlineExceeded) {
return nil, multiErr[0]
}
return nil, fmt.Errorf("multiple errors occurred: %v", multiErr)
}
In plain terms: once every worker has finished (wg.Wait() blocks —
waits — right there until all of them are done), the code gathers up every
error any worker reported, and if the very first one was a cancellation, it
reports that specifically instead of lumping it in with ordinary decode
errors.
Key points:
errors.Isunwraps chains. If a worker wrappedctx.Err()withfmt.Errorf("…: %w", err),errors.Isstill findscontext.Canceledinside the chain.- Context errors are surfaced directly, not merged into the multi-error string.
This lets callers do
errors.Is(err, context.Canceled)cleanly.
Channel buffer sizing and why it matters here¶
Both functions pre-allocate the error channel with extra capacity:
In plain terms: make(chan error, N) creates a channel with room to
hold N error values inside it at once before anyone has to read them out
(this reserved capacity is the channel's "buffer" — a fixed-size holding
area). Sizing that room generously up front means no worker ever has to
pause and wait for space to open up before it can report its error.
The comment in the file explains why:
Buffer the worst case so no worker blocks on send: up to len(datas) decode failures plus up to numWorkers cancellation sends. An under-sized buffer deadlocks wg.Wait when cancellation coincides with decode failures.
This is the exact bug explored in Lesson 14. If the
buffer is len(datas) and all workers also send a cancellation error, those
sends block (in plain terms: each worker tries to put its error into the
channel, finds no room left, and simply sits there frozen, waiting forever
for space that never opens up). wg.Wait() waits for the workers to finish.
Deadlock (a deadlock is when two or more parts of a program are each stuck
waiting on the other, so nothing ever moves forward again — the whole
program just hangs).
Try it
Run the stb-image tests (a test is a small piece of code written purely to run your program under controlled conditions and check that it behaves as expected — running the tests below is just running that checking code) with the race detector enabled:
Expected outcome: all tests pass with no data-race report. The -race flag
instruments every goroutine memory access; if cancellation were racy, the
detector would report it here. Try predicting before running: will you see
output lines mentioning "PASS" or "DATA RACE"?
Try it — cancel mid-batch
You can exercise the cancellation path directly:
cd /path/to/safeheaders-go/stb-image-go
go test -race -v -run TestLoadBatchConcurrent_Cancellation -timeout 10s ./...
Expected outcome: the test passes and completes well within the 10-second
timeout. If the channel buffer were too small, wg.Wait() would hang and
the test would be killed by -timeout.
Putting it all together — a minimal template¶
Here is the distilled pattern from the two real functions, stripped to its essential shape:
func ProcessBatch(ctx context.Context, items []Item) ([]Result, error) {
numWorkers := runtime.NumCPU()
if len(items) < numWorkers {
numWorkers = len(items)
}
jobs := make(chan int, len(items))
for i := range items {
jobs <- i
}
close(jobs)
results := make([]Result, len(items))
// Size: decode errors (len(items)) + cancellation sends (numWorkers).
errs := make(chan error, len(items)+numWorkers)
var wg sync.WaitGroup
for range numWorkers {
wg.Add(1)
go func() {
defer wg.Done()
for {
// ① Guard: exit immediately if already cancelled.
if err := ctx.Err(); err != nil {
errs <- err
return
}
select {
case idx, ok := <-jobs:
if !ok {
return
}
r, err := process(items[idx])
if err != nil {
errs <- err
} else {
results[idx] = r
}
case <-ctx.Done(): // ② catch cancellation while blocked
errs <- ctx.Err()
return
}
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
return nil, err
}
return results, nil
}
In plain terms: this function figures out how many workers to run at once (one per available processor core, or fewer if there isn't enough work to go around), loads every item's position into the jobs channel, then starts all the workers and lets each one pull items, process them, and watch for cancellation exactly as shown above. Once every worker has finished, it collects whatever errors came in and hands back either the results or the first error it finds.
The two numbered comments mark the two places cancellation is checked. Neither alone is sufficient. Together they are.
Key takeaways¶
context.Contextis Go's standard way to propagate cancellation and deadlines across API boundaries; always accept it as the first argument of long-running functions.- A bare
select{case <-jobs: … case <-ctx.Done(): …}is non-deterministic when both are ready — usectx.Err()at the top of the loop as a hard check. ctx.Err()returnsniluntil cancelled, then returnscontext.Canceledorcontext.DeadlineExceeded; useerrors.Isto check wrapped errors.- Size your error channels for the worst case: job errors plus per-worker cancellation sends — an undersized buffer causes the deadlock described in Lesson 14.
- Always
defer cancel()at the call site to release context resources, even when you expect the deadline to fire naturally.