Context
You can start a thousand goroutines with one keyword. Stopping them is the
hard part — a goroutine can't be killed from outside; it has to notice it
should stop and return. Go's standard mechanism for delivering that notice is
context.Context.
Cancellation is a tree
Picture a web server handling a request: the handler starts a goroutine to query a database, another to call an API, and those may fan out further. If the client disconnects, all of that work is wasted — and every bit of it should stop, promptly, all the way down.
A Context models exactly this. Contexts form a tree: each derived context
is a child of another, and cancelling a parent cancels every descendant.
Cancel the request's context and the database query, the API call, and their
sub-tasks all get the signal at once.
Creating contexts
The root of every tree is a blank, never-cancelled context:
ctx := context.Background()
You derive cancellable children from it:
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // always call cancel, even on success — it frees resources
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // cancelled automatically after 2s, or when you call cancel
WithCancel gives you a cancel function to call whenever you decide the
work is no longer needed. WithTimeout (and its sibling WithDeadline)
cancels itself when time runs out. Either way, defer cancel() is
non-negotiable — it releases the context's resources and is safe to call
twice.
The fourth one: context.WithValue
There is one more derivation, and it carries data rather than cancellation:
ctx := context.WithValue(parent, requestIDKey, "abc123")
id, ok := ctx.Value(requestIDKey).(string)
Use it sparingly, and not for ordinary parameters. Everything a function
needs to do its job belongs in its signature, where the compiler checks it
and a reader can see it. A value smuggled through the context is invisible
at the call site, untyped on the way out — note the type assertion, and the
ok you have to handle when it fails — and turns a missing argument from a
compile error into a runtime surprise.
It earns its place for request-scoped metadata that cuts across every layer and belongs to none of them: a request ID, a trace span, an authenticated user identity. If you find yourself putting a database handle or a config struct in there, pass it as an argument instead.
Noticing cancellation: ctx.Done()
ctx.Done() returns a channel that is closed when the context is
cancelled. A closed channel receives immediately — which makes Done() a
perfect select case:
select {
case <-ctx.Done():
return ctx.Err() // context.Canceled or context.DeadlineExceeded
case result := <-work:
return handle(result)
}
And for a loop doing work in steps, the select-with-default idiom from
the Select lesson becomes a cancellation checkpoint — poll "am I cancelled?"
each iteration, without blocking:
for i := 1; i <= limit; i++ {
select {
case <-ctx.Done():
return count // stop early, return what we have
default:
}
count = doStep(i)
}
This is cooperative: the context never forces anything to stop. Code that
ignores Done() runs to completion. Well-behaved Go checks.
The first-argument convention
Context is passed explicitly, always as the first parameter, always
named ctx:
func fetchUser(ctx context.Context, id int) (*User, error)
Never store a context in a struct; pass it down the call chain. The whole
standard library follows this shape — http.Request carries one
(r.Context(), cancelled when the client disconnects), and
database/sql, net, and os/exec all take ctx first. That convention
is what lets cancellation flow from an HTTP client hanging up, through your
handler, into every query it spawned. For servers, context isn't garnish —
it's how the whole system agrees to stop wasting effort.
Your exercise: a worker loop that counts toward a limit but checks
ctx.Done() every step, so an already-cancelled context stops it before
step one.
Practice
1. Cancellable Worker
AdvancedImplement
countUntil, a worker loop that respects cancellation:func countUntil(ctx context.Context, limit int) int- Count from
1up tolimit, one step per loop iteration, and return the final count — an uncancelled context andlimitof 10 returns10. - Before taking each step, check whether
ctxhas been cancelled — the non-blockingselect { case <-ctx.Done(): ...; default: }idiom. If it has, stop immediately and return the count reached so far. - Because the check comes before the first step, a context that is already
cancelled when
countUntilis called returns0. - If
limit <= 0there are no steps to take: return0.
Do not add
time.Sleepor other delays — the loop is plain fast code, and the cancellation check each iteration is what makes it responsive. The tests call it with an already-cancelled context (expecting0) and withcontext.Background()(expectinglimit).package main import ( "context" "fmt" ) // countUntil counts 1..limit, checking ctx before each step. If ctx is // cancelled it returns the count reached so far; otherwise it returns limit. func countUntil(ctx context.Context, limit int) int { // TODO: loop to limit, but check ctx.Done() (non-blocking select) // before counting each step. return 0 } func main() { fmt.Println(countUntil(context.Background(), 10)) ctx, cancel := context.WithCancel(context.Background()) cancel() // already cancelled fmt.Println(countUntil(ctx, 10)) }Sign in to write and run your solution in the browser.
Sign in for hints and the solution.
- Count from