With a focus on concurrency
When developing code for a concurrent or client/server environment, we can often benefit from having context — information about the system that isn’t necessarily required to perform business logic. Go’s standard library provides an excellent context package that includes a number of utilities that can pass this information to functions receiving them. These include the ability to:
- know that the operation has been cancelled
- cancel if an operation isn’t completed by a certain time
- timeout if the operation runs too long
- pass generic data in the form of key/value storage
We’ll dig into each one and their common use cases below.
Contexts allow applications to gather additional information about their environments, which they can pass to functions they call. Sometimes a context needs to be created from scratch, or the application developer knows it will be needed but does not yet know where that context will come from or how to generate it.
In the first of these cases, an empty context can be created with:
ctx := context.Background()
This will generate an empty context that can be passed around and extended with cancel functions, timeouts/deadlines, or values (see the next sections). This context is never cancelled and has no deadline or associated values. Typically this is used for the top-level context or tests.
Similarly, we can derive a TODO context with the following:
ctx := context.TODO()
This returns an empty context that is never cancelled and has no deadline or associated values. This context is used when it’s unclear which context to use, or the function expects a context to be passed in that is not yet available. It is essentially providing a means to compile and pass information around but marking that context as incomplete (similar to a
// TODO comment).
You can see how both can be generated and used in a (somewhat useless) example below:
Cancelation is the most straightforward way of ending a computation path that relies on a context — it allows the calling code to signal to anything downstream that the computation is no longer needed. It is time to clean up resources and exit. This can be useful when processing long-running requests (e.g., stop processing if a client disconnects from a server), and it is a common way of cleanly shutting down an application — when the app receives a stop signal (such as a
SIGTERM) it can cancel the top-level context, and anything derived from that context will know to clean up and cease activities.
We generate contexts with cancel as follows:
ctx, cancelFunc := WithCancel(parent Context)
This returns a copy of
parent with a new
Done channel, along with a cancellation function. When
cancelFunc is called, the
Done channel on
ctx is closed, signaling anything downstream to clean up and cease all activities.
Done is also closed if
parent is cancellable and has been cancelled.
In the following example, we generate a cancellable context and check if it’s been cancelled by checking the done channel via
When we run the above code, we can see the output as:
background long running task launched
background long running task still going
going to cancel background task
long running task bailed because context cancelled
some time has elapsed after cancellingProgram exited.
If you’d like to experiment with it yourself, you can copy/paste this example (or anything that follows it) into Go playground.
Contexts with deadlines are often used when making requests to an external resource that the caller may not know or control the state of, such as a database or API. They say, “I want something to happen at or by a certain time, or else bail.” The Context’s
Done channel will close when the deadline expires, signifying to any downstream code that they should stop doing what they are doing.
These take the form of
ctx, cancelFunc := WithDeadline(parent Context, deadline time.Time)
parent is the context this is based on, and
deadline is the time you want the thing to happen. Note that if
parent has a deadline that is earlier than
deadline. This context will have the same deadline as
The following example shows how it might be used to end a long-running task:
Here, we wrote a function that takes a context and a value for how long to wait. It will return in either case, but print whether it exited due to the context deadline passing or the wait time elapsed. We can see that we will return because the context expired in the first case, and in the second case, we will return well before the context expires. We get the following output:
bailed because context deadline passed
completed before context deadline passedProgram exited.
longRunningTask() also demonstrates a simple case of how code that accepts a context can handle a cancelled context using a
select on various channels.
Contexts with timeouts are a convenient feature similar to contexts with deadlines. They are called like this:
ctx, cancelFunc := WithTimeout(parent Context, dur time.Duration)
Under the hood, they are calling:
But hey, we’ll take any simplification we can get!
Essentially, when we pass a context with a timeout, we say we are willing to wait until the
timeout amount of time has passed, and if the call is not complete, it should wrap up and bail out. This reduces the chance that our code might get infinitely (for some value of “infinite”) hung up waiting for some call to complete rather than error out and keep doing its job.
The following example shows how this might be used to end a long-running task:
We get the following output:
bailed because context timed out
completed before context timed outProgram exited.
We can see how similar this is to the above deadline example. When picking between a
WithDeadline and a
WithTimeout, simply choose the one that fits whatever the calling code has better. If you want it to happen by a certain time, choose deadline! If you want it to happen within a certain duration, choose timeout!
Contexts also enable request-scoped values to be passed around. To attach a key/value pair to a context, call:
ctx := WithValue(parent Context, key, val any)
This returns a copy of
parent where the value associated with
key must be comparable and should not be a built-in type (such as a string or int) to avoid collisions between different packages using the context. If multiple packages use the same
key type with the same value, they can collide when trying to set/get
key should be defined as its own properly-scoped type for setting and getting
val. That way, even if multiple scopes define a key with the same underlying value, they will not collide within the context. The following example illustrates this:
Here we define a key type (
keyType1) that is scoped to
main(). It is constructed with the value
"foo". We now assign the key/value pair
"foo"="bar" within a context. If we try to retrieve a key of type
keyType1 (with value
"foo") we get back
"bar", as expected.
Now, we can define a new function with its own key type. If we assign the same key value to that new key type (
"foo"), we can try to get the value from the context:
found a value for key type 1: bar
no value for key type 2Program exited.
As we can see, since we don’t have access to the proper key type, we cannot access the value, even if the underlying value for the key is the same. Note that we could have also assigned a new value within
tryAnotherKeyType() , and it would not be accessible in
main() due to the same key type mismatch we’ve discussed thus far. This is how we avoid key/value collisions between packages (or any unit of scope).
This tool should be used for passing request-scoped data between processes and APIs, not merely for optional function parameters, as this can obfuscate the code. Also, note that the values in the context must be threadsafe — that is, they might be used concurrently by multiple goroutines.
We’ve alluded to it previously in this article, but derived contexts can be very useful. The summary is that child contexts are governed by their parents — they cannot have a longer timeout/deadline than their parents, and they cannot cancel their parents. They also inherit any values stored in their parents. Parents, however, can cancel their children.
We often use derived contexts as code splits into branches with different lifetimes and functions. For example, we might have one main context that governs the lifetime of our overall application, but a derived context to govern an API request, and perhaps yet another context derived from that to govern a storage call required to handle the request.
This way, we can choose to end the entire application (by cancelling the main context), the request processing (by cancelling the first derived context), or the storage call (by cancelling the second derived context).
Note that with all of the above, it is best practice to call a context’s
CancelFunc when it is no longer needed to free up resources (if it’s a context that includes a cancel function). In the examples above, I generally disregarded the cancel function for simplicity, but it’s important to be efficient with resources for long-running applications. Contexts are generally passed as the first function parameter, so keep that in mind when developing your own context-consuming code!
Hopefully, this has served as a springboard to learning more about contexts in Go and improving your application development skills. It should start to make more sense now when you see functions that accept contexts (such as calling into a database or API), and you can start to get a sense of how you, as a client, can use contexts to make your own code more predictable and performant.
We’ve covered the main mechanics of the various context utilities in Go and discussed when and how they might be used. You can leverage this tool in many ways, so treat this as more of a starting bell than a finish line.
Thanks for reading! Stay tuned for more.