Skip to content
academia.sh

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.WithValue is 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close