Understanding Go's context Package
Go request handlers typically include a “context” value as their first argument:
func handler( ctx context.Context, ... ) {
...
}
In my experience, this convention is typically fastidiously followed, but
then nothing is ever done with that ctx
argument. What is it really
for? Unfortunately, the description in the official
Go package documentation
is a bit cryptic, and the type implementation itself does not reveal
anything either (the default context is just an empty struct).
Properly understood, it’s actually a really convenient idiom; however,
its value is not so much in the context
package itself, but in some
idioms in the code that use the package.
A Convention for Metadata
I think the context “pattern” is best understood as a convention for passing metadata along with a request. If every function handling a request takes a context as its first argument, there is never any question where and how to pass such metadata. Without this convention, there would be less consistency and predictability in function signatures, and there would be an unfortunate tendency to include the metadata on the request itself (where it does not belong).
Examples of such metadata include internal authentication information, or tracing and diagnostic values that might be useful to track: in other words, data that is helpful or necessary in handling the request, but not part of the request itself.
The confusion arises because frequently there is no metadata associated with a request, and hence the context is just passed along as a piece of essentially useless boilerplate!
If such data exists, it can be attached to an existing (parent)
context, and later retrieved like so (actually, WithValue()
creates a new context that embeds the parent context):
valCtx := ctx.WithValue( parent, key, val )
v := valCtx( key )
The documentation warns not to use the string
type as key,
but instead to define a separate, project-specific key type for use in
these functions.
The Cancellation Idiom
More interesting than as a carrier for metadata is the context
package’s infrastructure for goroutine cancellations. The problem
is that it’s up to application’s goroutines to make proper use of
this infrastructure; this is where it is helpful to understand some
appropriate idioms.
The primary idiom looks like this:
func handle( ctx context.Context, ... ) {
ctx, cancel := context.WithCancel( ctx )
go work( ctx )
// handle request...
cancel() // stop the goroutine
}
func work( ctx context.Context ) {
for {
select {
case <-ctx.Done():
return // stop work and return
default:
process_unit_of_work() // do work
}
}
}
In the handle()
function, the incoming parent context is replaced
with a context that includes cancellation functionality. It is this
new, augmented context object that is passed to the work()
goroutine.
When the handler is finished processing the request, it stops the
goroutine by invoking the cancel()
function that was returned by
WithCancel()
together with the augmented context.
The important thing is that the goroutine must be explicitly instrumented to respond to the cancellation instruction! This piece of information is easily overlooked, and this is why the context package can seem so mysterious: there is no magic here, the users of the package have to implement it themselves!
When the cancel()
function is invoked, the context’s Done
channel
is closed. The goroutine must be prepared to listen for this event.
(The way that Go uses the closing of a channel to signal a semantic
event must be one of Go’s
murkier design decisions!) As long as the
channel is open, attempts to read from it will block; once the channel
is closed, reads from it will succeed automatically, returning the
null value of the appropriate type. All of this must be wrapped in
a select
statement, to allow the goroutine to skip over the blocking
read from the Done
channel while it is open (that is, while the
goroutine has not been canceled yet). And the select
statement
itself has to be wrapped in a loop that processes one “unit of work”
on each iteration.
To keep the example simple, the goroutine here does not take any input.
More commonly, it would take additional arguments that determine
what the goroutine would be working on. In fact, the arguments
might be channels from which the goroutine would retrieve work
units and/or write results to. In this case, reading or writing
the appropriate channel would constitute another case
of the
select
block.
The package also provides functionality to endow a context with either a deadline or a timeout. (The difference is that a deadline marks an absolute point in time, whereas a timeout is measured as a duration from the current time.) The required instrumentation for participating goroutines is similar.
It should also be pointed out that the mechanism to stop goroutines
by closing the Done
channel works for any number of goroutines.
In this example, there was only a single goroutine, but the handler
may in fact have set up several to work concurrently. As long as they
are instrumented properly, the cancel()
function will stop all of
those goroutines!
Once more, and as summary:
the context
package only provides some enabling infrastructure,
but the actual cancellation logic lives in the goroutine, and must be
implemented explicitly, as shown here. The for-select-Done
pattern used
in the goroutine is typical and should probably be committed to memory.
(Once absorbed as a standing idiom it is alright, but it does take
some getting used to, to immediately grasp what’s going on.)