Lesson 09 / 14
The Context Package
A single cancellation reaches all four goroutines waiting on ctx.Done() at the same time; all of them stop in the 'cancelled' class and ctx.Err() carries the same fixed reason. What a request-scoped value (context.WithValue) is for, and what it is not for, are measured separately. Limiting measurement: a context does not force anyone to stop, it only notifies — a goroutine that never checks ctx.Done() completes its own work to the end, even though the context has already been cancelled.
Published Updated
In this lesson
- Measures that a single cancellation reaches every goroutine waiting on the context at the same time, and that all of them stop in the same class
- Shows how a request-scoped value is carried through the context, and that this does not stand in for explicit function parameters
- Proves by running it that a goroutine that never checks cancellation completes its work to the end, even if the context has already been cancelled
Contents
The previous lesson measured a leak and left it unsolved: when a consumer exited a pipeline
early, the stages ahead of it had no way at all to know about it. It was not enough for the
stages to listen to their own input channels — a signal was needed that came from outside and
reached all stages at the same time. This lesson measures the tool that carries that signal:
the context package (the context package). A context being cancelled reaches every goroutine
waiting on it, however many there are, through a single event — unlike a channel, there is no
“how many receivers” question here at all, because a context’s cancellation signal works like a
broadcast, not to a single receiver but to all of them at once.
The context.Context type the context package introduces is not, on its own, a new mechanism —
underneath it is only a channel (the one Done() returns) and an error value (the one Err()
returns). What is new is that this pair can be derived in a tree shape: when a new context is
derived from a context (with calls like context.WithCancel, context.WithTimeout), cancelling
the parent context automatically cancels every child context too. This lesson starts with a
single-layer tree (a single context derived from context.Background()); the tree itself is not
this lesson’s subject, but how many waiters a single node’s cancellation reaches is measured here
in clear terms.
One Cancellation, Every Waiter in the Chain
The measurement below starts four goroutines; each one waits on ctx.Done(). After a single
cancel() call, all four send their report — all in the same class: cancelled.
// cancelspreads.go — a single cancellation reaches every goroutine waiting on the context at the same time
package main
import (
"context"
"fmt"
"sort"
"sync"
)
type report struct {
id int
reason string
}
func waiter(id int, ctx context.Context, reports chan<- report, group *sync.WaitGroup) {
defer group.Done()
<-ctx.Done()
reports <- report{id: id, reason: "cancelled"}
}
func main() {
const waiterCount = 4
ctx, cancel := context.WithCancel(context.Background())
reports := make(chan report, waiterCount)
var group sync.WaitGroup
for id := 1; id <= waiterCount; id++ {
group.Add(1)
go waiter(id, ctx, reports, &group)
}
cancel()
group.Wait()
close(reports)
records := make([]report, 0, waiterCount)
for r := range reports {
records = append(records, r)
}
sort.Slice(records, func(i, j int) bool { return records[i].id < records[j].id })
fmt.Println("-- one cancellation, four waiters --")
for _, r := range records {
fmt.Printf("waiter %d reason=%s\n", r.id, r.reason)
}
fmt.Println("ctx.Err():", ctx.Err())
}
-- one cancellation, four waiters -- waiter 1 reason=cancelled waiter 2 reason=cancelled waiter 3 reason=cancelled waiter 4 reason=cancelled ctx.Err(): context canceled
CP15. What context.WithCancel returns as ctx.Done() is a channel, but not an
ordinary one: when cancel() is called, this channel closes, and the “a receive from a
closed channel does not block” behavior seen repeatedly in the previous lessons applies here too.
All four goroutines were waiting on the <-ctx.Done() line; the moment the channel closes, all
four are freed at the same time. Closing belonged to a single side in every previous
lesson too — a producer, a stage, or a third party (like the one in the Fan-Out and Fan-In lesson
who knew when all producers had finished); it is the same here too. cancel itself wraps a
single close call; even more than one cancel call closes the channel only once. This is the
context package’s counterpart to the Fan-Out and Fan-In lesson’s “closing a channel is an atomic
operation” observation. This, too, is a deliberate design decision: it is safe to call cancel
more than once, even from more than one goroutine — the caller does not need to track its own
state wondering “did I already cancel this,” because the second and later calls silently do
nothing at all.
Sorting the four reports by id (sort.Slice) comes from the same need seen in the previous
lessons’ fan-in channels: four goroutines can write to the reports channel at the same time,
and which one arrives first depends on timing. The difference is that this time sorting does not
affect the result — all four are already in the same class (cancelled), sorting only makes
the output readable and repeatable.
CP16. All four stopping in the same class (cancelled) is the plainest form, in this
context, of the four-class measure established in the course’s first lesson (on its own, what it
waited for arrived, cancelled, never stopped): none of them finished its own body, none was
waiting for another goroutine’s step, all four stopped as the direct result of a single
cancellation event. The text ctx.Err() returns is fixed too — “context canceled” — a
version-independent, fixed string defined by the language’s own standard library; a caller
wanting to tell which context was cancelled for which reason can read this text.
These four were, in the previous lessons, the “cancelled” class’s only example (two waiters
in the shared definition) — here that same class is raised fourfold and the result does not
change, because a channel closing shows the same behavior no matter how many receivers there are.
This scale-independence is the context package’s real strength: on a web server, the
cancellation of a single request can reach dozens of goroutines handling that request (a database
query, a few outside calls, a cache read) through the same ctx.Done() channel, without needing
to warn any of them one by one.
Carrying a Request-Scoped Value
The context package’s second job is not spreading cancellation, it is carrying a request-scoped
value through the call chain — for example, carrying a request ID down to the deepest call,
without changing the signature of every function in the chain. This is a separate need that none
of the previous lessons’ channel arrangements meets: channels stream data, while context
values carry it alongside the call chain. In all three of the worker pool, fan-out/fan-in, and
pipeline measurements, data always flowed through an explicit parameter or an explicit channel;
what context.WithValue solves is different — carrying a request ID down to the deepest point in
the chain, without adding it separately to the signature of every function handling that request.
Carrying a value works independently of spreading cancellation: the measurement below first sets up a value-carrying arrangement, then (in the same program, with a separate context) sets up the limiting measurement showing that cancellation does not stop anyone.
// valueignore.go — carrying a request-scoped value, and a goroutine that never checks cancellation
package main
import (
"context"
"fmt"
)
type keyType string
const requestKey keyType = "request-id"
func readDeep(ctx context.Context) string {
value, ok := ctx.Value(requestKey).(string)
if !ok {
return "none"
}
return value
}
func main() {
ctx := context.WithValue(context.Background(), requestKey, "request-42")
fmt.Println("request id read by the inner function:", readDeep(ctx))
ctx2, cancel := context.WithCancel(context.Background())
cancel() // this goroutine has not started yet, but cancellation already happened
steps := make(chan int, 3)
go func() {
// ctx2.Done() is never checked; this goroutine has no idea it was cancelled.
for step := 1; step <= 3; step++ {
steps <- step
}
close(steps)
}()
completedSteps := 0
for range steps {
completedSteps++
}
fmt.Println("was the context already cancelled while the goroutine ran:", ctx2.Err() != nil)
fmt.Printf("completed step count: %d / 3\n", completedSteps)
}
request id read by the inner function: request-42 was the context already cancelled while the goroutine ran: true completed step count: 3 / 3
CP17. requestKey is not an ordinary string, it is a constant of its own defined
keyType type — this prevents another package, at compile time, from using the same string
value as its own key and accidentally writing into the same context slot. readDeep reads what
ctx.Value returns with a type assertion (.(string)) and separates out, with the second
return value (ok), the possibility that the value was never found at all — this is the context
value’s counterpart to the two-value-return access pattern in the dictionary (from M09/K01).
What this way of carrying a value is not is also part of the measurement: context.WithValue
exists not to carry a required dependency or parameter a function needs, but for
request-specific, header-like data (a request ID, a trace token) that has to pass through
every layer of a call chain, most of which do not use it. The readDeep function does not
crash if it cannot find the request ID (ok is false) — it just returns “none,” because a
context value is treated as optional. A value that is mandatory for a function to work (a
database connection, a piece of configuration) should be passed directly as a parameter, not
through the context; otherwise the function’s signature gives up saying what it needs at all.
This distinction’s practical consequence cannot be seen by looking at a function’s signature like
readDeep’s: the signature func readDeep(ctx context.Context) string never says the function
needs a request ID at all — it only takes a context, and what is inside that context stays
unknown until call time. This is an openness similar to the nil map and nil pointer behaviors
in the Go Fundamentals course: the type system does not check which keys a context carries, it
only reports the absence with nil/ok=false when a key is asked for. The only way to know
which context keys a function reads is to read the source — the signature makes no promise on
this at all.
A Context Does Not Stop Anyone, It Only Notifies
The second measurement proves this directly. ctx2 was already cancelled before the goroutine
even started — the cancel() call runs before the go func() {...}() line. This ordering is
deliberate: the measurement’s purpose is to eliminate entirely a timing debate like “the
goroutine started while cancellation had not happened yet, then cancellation could not catch up.”
The context is already cancelled before the goroutine even exists; despite this, the goroutine
that gets started never checks ctx2.Done() at all, it just produces its three steps in order
and closes its channel.
CP18. The result is clear: ctx2.Err() != nil is true — the context was already
cancelled by the time the goroutine started running — yet the completed step count is still
3 / 3. Since the goroutine never looks at whether the context was cancelled at all, it kept
running completely unaware of the cancellation and finished its work in full. This is the
exact answer to the question left open at the end of the previous lesson: the context package
does not force anyone to stop, it is not a mechanism that cuts off a goroutine. All it does is
notify everyone who asks, once cancellation happens — those listening to ctx.Done() take this
notice and make their own decision, those not listening notice nothing at all.
This behavior might look like a defect at first glance — why does the context not stop a goroutine
by itself? The answer lies in Go’s overall design: the language’s runtime offers no tool at all
for forcibly stopping a goroutine from the outside (there is no call in Go resembling cutting
off a thread from outside). A goroutine can only stop by its own choice, on its own code path, by
reaching a return or the end of the function. The context package works within this
constraint: since it offers no forced stopping, the only tool it has is notification —
telling a goroutine that thinks it should stop, and leaving the decision to it. This is the
clearest example of the principle seen repeatedly in the previous lessons — “the decision to stop
always depends on a check point in the goroutine’s own body”: the check point here is a
case <-ctx.Done() branch inside a select, and if that branch was never written in the source,
the cancellation itself carries no meaning at all.
This also draws the boundary of the fix for the pipeline leak measured in the previous lesson:
adding a select branch that checks ctx.Done() to a stage makes that stage aware of
cancellation — but this addition has to be made separately in every stage. If the square
stage, say, had written a two-branch select instead of a plain send to its output — one
sending the value, the other waiting on ctx.Done() — cancelling the context could have freed
this stage too once the consumer exited early. But this change has to be made separately in
every stage of the chain; the context itself never automatically stops any goroutine, what
spreads is only information, and turning that information into action stays each goroutine’s
own responsibility.
This nature of a cancellation context explains why a context.Context value does not work like a
lock or an interrupt: it is only a closing channel, and whoever is listening to that closing
channel is the only one who reacts. This also sets the context package on the same footing as
every mechanism in the previous lessons — a channel, a wait group, a select, all rest on the
same principle: concurrency in Go is not built by managing a goroutine from the outside, it is
built by a network of channels through which goroutines notify each other.
Summary
- A context being cancelled reaches every goroutine waiting on
ctx.Done(), however many there are, at the same time; all of them stop in the same class (cancelled). ctx.Err()returns a fixed text defined by the language’s standard library; it can be read to tell which context was cancelled.context.WithValueis for optional, header-like data carried between layers; it does not stand in for a function’s required dependencies.- A context does not force anyone to stop, it only notifies — a goroutine that never checks
ctx.Done()completes its work to the end even if the context has been cancelled. - Making a goroutine responsive to cancellation requires it to add a
ctx.Done()check to its own loop; this does not happen automatically. - Go’s runtime offers no tool for forcibly stopping a goroutine from the outside; the context works within this constraint, and the only tool it has is notification, not force.
Next Step
In this lesson, exactly when the context itself was cancelled was already known in advance —
cancel() was called explicitly, and the waiters were waiting for that call. But the most common
reason a context gets cancelled in real systems is not someone making a deliberate decision, it is
a duration running out: waiting for a network call or a database query to respond within a
reasonable time, and giving up if it does not. The next lesson measures this special case: when a
select statement picks a branch based on whether it received a result or a cancellation,
a timeout becomes a form of this same choice — what is measured is not the elapsed duration, but
which branch was chosen; this lesson measured an already-known cancellation, the next lesson
measures a cancellation that has not yet happened (a timeout) with the same select
mechanism.
To keep your progress and take notes, Log in
My notes
Log in to take notes.