Concurrency
Context, Cancellation and the Race Detector in Practice
context.Context as a cancellation tree, WithCancel, WithTimeout and WithValue, the cancel leak, and why every concurrency test belongs under go test -race.
Three goroutines are waiting on a downstream call that already failed, and nobody told them to stop. The request that started them timed out forty seconds ago. They will keep waiting until the process restarts, each holding a connection, a buffer and a stack. Context exists to fix exactly this, and the race detector catches the mistakes you make while fixing it.
A context.Context is a node in a cancellation tree: it carries a deadline,
a done channel and an error, and it passes those down to the contexts derived from it. The
values are a small side channel for request-scoped data that rides along with the request.
Treating the value part as a dependency injection container is what makes a codebase
unreadable.
Cancellation is a tree, and the parent always wins
Every context has a parent except context.Background(), which is the root.
Cancelling a parent cancels every context derived from it, however deep the chain goes. A
child can also be cancelled on its own without touching its siblings or its parent.
parent, pcancel := context.WithCancel(context.Background())
child, ccancel := context.WithTimeout(parent, time.Minute)
defer ccancel()
pcancel()
time.Sleep(10 * time.Millisecond)
fmt.Println("child after parent cancel:", child.Err(), "| parent:", parent.Err())
child after parent cancel: context canceled | parent: context canceled
The child had a one minute deadline of its own and still stopped immediately, because the parent was cancelled. That is what makes contexts useful in a service: the request arrives, a root context is created for it, and every function the handler calls receives a context derived from it. When the client disconnects or the deadline passes, one cancellation unwinds the whole tree.
ctx.Err() reports why a context is finished and returns nil
while it is still live. The two errors it returns are context.Canceled and
context.DeadlineExceeded. Both are sentinel values, so
errors.Is works through any wrapping you added on the way up.
WithCancel is the primitive, and the other two are built on it
context.WithCancel(parent) returns a derived context and a
CancelFunc. Calling the function cancels that context and everything derived
from it. This is the only cancellation mechanism you need to understand; the deadline
variants are the same thing with a timer attached.
// work respects cancellation: every loop iteration is interruptible.
func work(ctx context.Context, id int, out chan<- string) {
defer close(out)
for i := 0; ; i++ {
select {
case <-ctx.Done():
out <- fmt.Sprintf("worker %d stopped after %d items: %v", id, i, ctx.Err())
return
case <-time.After(20 * time.Millisecond):
// pretend this is one unit of real work
}
}
}
cancelling
worker 0 stopped after 3 items: context canceled
worker 1 stopped after 3 items: context canceled
worker 2 stopped after 3 items: context canceled
ctx.Err() after cancel: context canceled
The select is the whole pattern. The loop has no exit condition of its own;
it exits because ctx.Done() became ready. A worker that checks the context once
at the top and then runs for ten minutes is not cancellable, however carefully the context
was threaded to it.
context.WithTimeout(parent, d) cancels after a duration, and
context.WithDeadline(parent, t) cancels at an instant. The timeout version is
a deadline computed from the current time, and ctx.Deadline() reports the
instant either way:
deadline := time.Now().Add(50 * time.Millisecond)
dctx, dcancel := context.WithDeadline(context.Background(), deadline)
defer dcancel()
if d, ok := dctx.Deadline(); ok {
fmt.Println("deadline is", d.Sub(deadline).Round(time.Millisecond), "after now+50ms,",
"with", time.Until(d).Round(time.Millisecond), "left")
}
fast call: ok
slow call: context deadline exceeded | errors.Is(err, context.DeadlineExceeded): true
deadline is 0s after now+50ms, with 50ms left
deadline call: context deadline exceeded
The error a function returns when it gives up should be the context's own error.
ctx.Err() already says which of the two things happened, and every caller above
you can test it with errors.Is(err, context.DeadlineExceeded) without knowing
anything about your package. Use one context per request rather than one per call: a single
deadline at the entry point, with everything else inheriting it. Nested timeouts are
occasionally right, when a sub-operation deserves a tighter budget than the whole request,
and are usually a way of hiding that the outer budget was never chosen.
Context values are for request metadata, not for dependencies
context.WithValue attaches an immutable key-value pair to a derived context.
The key can be any comparable value, and the documented convention is to use an unexported
type of your own so no other package can collide with it.
// The key type is unexported, so no other package can collide with it even
// if it stores a string named "requestID".
type ctxKey int
const (
requestIDKey ctxKey = iota
userIDKey
)
func withRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func requestID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey).(string)
return id, ok
}
handler: request req-7f3a found: true
repository: logging request req-7f3a for the slow query
repository: no user in context (absent values are not errors)
context.Background has no request id: false
Two habits keep this from turning into a mess. The unexported key type means the value can
only be reached through your accessor functions, so the key and the type assertion live in
one place. And a missing value is not an error: ctx.Value returns
nil, the comma-ok form tells you so, and your accessor should report that
honestly rather than returning a zero value that looks like data.
What does not belong in a context: database handles, loggers, configuration, feature flags, and anything else a function could take as a parameter. Context values are untyped at the point of use, so a function that takes a context and pulls a database out of it tells you nothing and cannot be called in a test without building a context first.
The rule, not the taste
Context is the first parameter of a function, named ctx, and it is
passed explicitly down the call chain. It is never stored in a struct field: a struct that
holds a context has an implicit lifetime that no caller can see from the type
signature.
A forgotten cancel leaks until the timer fires
Both WithCancel and the deadline variants return a CancelFunc,
and the documentation is blunt that you must call it. The usual shape is a
defer on the line below, so every return path releases the resource:
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
The leak when you forget it is not a goroutine in the deadline case, which surprises people who go looking for one. It is a timer and the context's own state, held until the deadline arrives. Ten thousand requests with a one hour timeout that never call cancel retain about 2.7 MB and a matching number of runtime timers, and the number keeps climbing as long as traffic does:
cancel=false 10000 contexts: 2686 KiB, 2673 KiB (~273 bytes each)
with cancel: 1 goroutines (was 1), heap 0 KiB growth
The fix is one line, and the reason to write it even when you expect the deadline to
arrive and free everything anyway is that "the deadline will arrive" is a guess about
traffic, and this is not. WithCancel punishes you harder: a context that is
never cancelled and has no deadline is never released at all. A cancelled context also
releases the resources waiting on it, so calling cancel when the work is done
lets a downstream goroutine return instead of waiting for a deadline it no longer needs. If
you create a cancellable context, you own the cancel call.
The race detector is the point of the whole module
Everything in these three lessons is easier to get wrong than to get right, and the compiler will not catch any of it. The race detector will: it instruments every memory access and reports the pairs that are unsynchronised, with both stacks and the goroutines that created them.
Here is a counter with one method and no lock, incremented by a hundred goroutines.
type Counter struct{ n int }
func (c *Counter) Add(delta int) { c.n += delta }
func main() {
c := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Add(1)
}()
}
wg.Wait()
fmt.Println("counter:", c.n, "(expected 100)")
}
$ GORACE=halt_on_error=1 go run -race ./m2race
==================
WARNING: DATA RACE
Read at 0x00c000012138 by goroutine 9:
main.(*Counter).Add()
/tmp/goconc/m2race/main.go:10 +0x70
main.main.func1()
/tmp/goconc/m2race/main.go:19 +0x6c
Previous write at 0x00c000012138 by goroutine 7:
main.(*Counter).Add()
/tmp/goconc/m2race/main.go:10 +0x80
main.main.func1()
/tmp/goconc/m2race/main.go:19 +0x6c
Goroutine 9 (running) created at:
main.main()
/tmp/goconc/m2race/main.go:17 +0x70
Goroutine 7 (finished) created at:
main.main()
/tmp/goconc/m2race/main.go:17 +0x70
==================
exit status 66
Read it as a story about one address. Two goroutines touched the same memory, one of them
wrote it, and nothing ordered those accesses. The read and the write name the same file and
line, because c.n += delta is both at once. The last two blocks say which line
started each goroutine, which is what matters when the racing access is inside a library.
Without -race, the same program prints a wrong number and exits zero. Run it
and the count is 77 where 100 was expected. That is a data race: undefined behaviour that
happens to produce a plausible answer most of the time. In a service it produces a total
that is slightly off for weeks and a crash the day the scheduler orders things
differently.
The fix for a counter is a mutex, and the detector then has nothing to say:
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Add(delta int) {
c.mu.Lock()
defer c.mu.Unlock()
c.n += delta
}
$ go run -race ./m2race
counter: 100 (expected 100)
$ echo $?
0
The lock is the fix, and the fact that the number is now right is not the evidence. The
detector is: an empty report from a run that also exercised the code. A test that passes
without -race tells you the code happens to work on this run, which is a
different claim.
Run it in tests, and run it in CI
go test -race instruments the test binary and the code under test, which is
where the detector earns its keep: tests are concurrent by nature, they run on every
commit, and they exercise paths the demo programs above never reach.
func TestSlowCallRespectsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := slowCall(ctx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("got %v, want context.Canceled", err)
}
}
func TestSlowCallTimesOut(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
<-ctx.Done()
_, err := slowCall(ctx)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("got %v, want context.DeadlineExceeded", err)
}
}
$ go test -race ./m3f -v
=== RUN TestSlowCallRespectsCancellation
--- PASS: TestSlowCallRespectsCancellation (0.00s)
=== RUN TestSlowCallTimesOut
--- PASS: TestSlowCallTimesOut (0.02s)
PASS
ok example.com/concurrency/m3f 1.294s
Notice what those two tests assert. Not that the function returned an error, but that it
returned the specific error the caller above will test for. Neither test sleeps longer than
it must: the cancellation test cancels before the call, and the timeout test waits for
ctx.Done(). A test whose speed depends on the machine eventually fails on a
busy CI box.
The detector costs a few times the runtime and memory of an uninstrumented binary, which
is why it does not belong in every local run. It belongs in CI as a separate job so a race
is a red build. Two details matter: GORACE is read by any instrumented binary
and takes options like halt_on_error=1 to stop at the first report, and
-race makes the test binary exit non-zero, so a pipeline that already fails on
a non-zero exit needs no special handling.
What the detector cannot see is what makes this module worth three lessons. It reports
only races that happen during the run, so a race on an untested path stays hidden, and it
says nothing about a goroutine leak, a channel nobody closes, or a context whose
cancel is never called.
Write a function FetchAll(ctx context.Context, ids []string) ([]string,
error) that starts one goroutine per id, calls a fetch(ctx, id)
that sleeps for a random 30 to 300 milliseconds, and returns the results in the order
of the ids.
Give it a deadline of 100 milliseconds and define what happens to the goroutines whose work has not finished when it expires. Answer in a comment before you write the body: does the caller wait for them, and if not, what keeps them from leaking?
Write two tests: one where every fetch finishes inside the budget, and one where the
slowest does not. Assert the error with errors.Is against
context.DeadlineExceeded.
Then introduce a deliberate data race into the results slice, confirm
go test -race reports it, and remove it again.
Show the solution and the reasoning
func FetchAll(ctx context.Context, ids []string) ([]string, error) {
results := make([]string, len(ids))
errs := make([]error, len(ids))
var wg sync.WaitGroup
for i, id := range ids {
wg.Add(1)
go func() {
defer wg.Done()
v, err := fetch(ctx, id)
if err != nil {
errs[i] = fmt.Errorf("fetch %s: %w", id, err)
return
}
results[i] = v
}()
}
wg.Wait()
if err := errors.Join(errs...); err != nil {
return nil, err
}
return results, nil
}
Each goroutine owns one index of two preallocated slices, so there is no shared write and no lock is needed. That is the whole reason the slice is allocated up front: appending from several goroutines is a race, and indexing an element each goroutine owns is not.
The caller waits for every goroutine, including the ones whose work was cancelled. On
cancellation each fetch returns early with ctx.Err() wrapped, so
wg.Wait() returns promptly rather than after the slowest sleep. If you do not
wait, you must be certain the goroutines stop anyway, and the only thing that makes them
stop is a cancelled context that fetch actually selects on.
errors.Join is the right shape for the error here, because several
goroutines can fail for the same deadline and all of them are true. It produces one error
whose errors.Is returns true for every joined cause, so the test can assert
context.DeadlineExceeded without knowing how many goroutines reported it. The
cost is a message of several lines, one per failed fetch.
The deliberate race is the instructive part. Replace the indexed write with
results = append(results, v) and the detector fires on the first run, with
two goroutines and one address. Notice that the plain go test run usually
passes: the slice rarely needs to grow twice at the same moment, so the corruption is
rare. Put the indexed version back, run go test -race again, and the empty
report is the proof that the fix is real.
Two things to check in your own run. The order of the returned results must match the
ids even though the goroutines finish in a different order, which the indexing gives you
for free. And the timeout test must finish in about the budget, not in the sum of the
random sleeps: if it takes 300 milliseconds, the cancellation is not reaching
fetch.
Progress
Mark this lesson done and it stays marked in this browser.
Course complete
That is the last lesson of Go for Backend Services. If the material was useful, the practice is what makes it stick: rebuild the final project from an empty directory without looking back at the lesson.