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 := 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.)