Golang and context - an explanation
The Golang standard library comes with a simple interface, context.Context
, which is a little harder to understand and use than the others.
The difficulties in getting the context.Context
interface may be the naming, maybe not super appropriate. In the goal of the structure, maybe too wide. Or in the fact, that it is not an everyday concern for some software.
However, it is a cornerstone in writing fast and efficient code in Golang, it is widely used in most libraries, and Golang developers should be at ease with its use.
In this article, we will shed some light and we will explain how to work with context.Context
.
As always comments at the end of the article are welcome as they help improve the article for future readers.
Convention
Before going into the deep part of the article, I believe it is useful to explain the convention around context.Context
, knowing these conventions will help in understanding this article and code that uses context.Context
.
context.Context
belongs to the Golang standard library, it is a package like all the others and it is not a core part of the language specification. It is just a very used interface.
However, it is a bit special since it is super useful and very pervasive in many codebases. For these reasons, there are few conventions around its use.
- If a function takes a
context.Context
as an argument, it should be the function's first argument. context.Context
should be bound to thectx
identifier.- Do not store a
context.Context
in a structure, but pass it around to any function that may need it
Those are only conventions, we can pass the context.Context
as the last argument and bounding to the foo
identifier. The code will still compile. But it won't be idiomatic.
Similarly, it is possible to store a context.Context
in a structure, but it is not idiomatic, and some linters will complain. The whole ecosystem is built around the idea that you will pass the context around.
Storing a context.Context
in a structure will make everything harder. But it is possible, and we will see that http.Request
store internally a context.Context
.
As always, conventions are not rigid rules, and there are occasions in which conventions should not be respected.
Context for cancellation
The simplest use of context.Context
is to carry and propagate termination signals.
The context.Context
interface requires a method .Done()
that, quoting from the documentation, returns a channel that's closed when work done on behalf of the context should be canceled.
So, if the channel returned by ctx.Done()
is closed, the context is completed, and we should abort the computation.
Classical examples are the ones that we mentioned in a previous post about making synchronous code asynchronous.
From a practical point of view, it means that if we are blocking, listening to ctx.Done()
select {
case <-ctx.Done():
// the operation should be cancelled
// the simplest way it is to just return
return ctx.Err()
case result <- resultCh:
// we got some result to use
}
and, for any reason, the context is canceled, the channel returned from ctx.Done()
is closed.
When a channel it's closed, all the goroutine that try to receive from that channel receives a default value. This allows multiple goroutines to receive notification of cancellation at the same time.
The reason why we don't send a value to the channel, but we close the channel, is because if we send a value, only a single goroutine will receive it. On the other hand, if we close the channel, all the goroutine blocking while receiving from it, will get a default value. This allows communicating the cancellation to multiple goroutines.
An important detail, that should not be glossed over it is that context.Context
should continuously be checked. Just because you passed a context.Context
to a function does not mean that the ctx
is being read or used at all. The function could as well ignore it. Hence, when writing functions that may be canceled, it is fundamental to:
- Take a
context.Context
as input - Actually check the
context.Context
for cancellation while doing slow or blocking operations
Just passing a context.Context
does not do anything, anything at all.
Why canceling a context.Context
Now that we understand the little tactical pieces of context.Context
, let's move to how effectively use it and on which occasions.
Usually context.Context
is used when there are slow operations or operations that may block, in the path of doing something useful.
Those kinds of operations are typically IO related operations, for instance reading from disk, reading from network, calling up a database, etc... nothing prevents us from using context.Context
in case of long CPU-bound operations, but it is quite uncommon.
Blocking operations are an issue when there is a deadline to fulfill. Maybe your clients need a response in a maximum of 1 second, or the value they get is already too old to be useful. Or maybe there is an internal timeout, after which a response is no longer accepted.
The most common use case is web requests. Both the client and the server, have an internal timeout, when the timeout expires the connection is broken. If the connection between the server and the client is broken, it is pointless to write a response since nobody will receive it. And it is even worse to keep consuming resources assembling a response, that we won't send.
Indeed, the default interface of an HTTP handler takes as input a *http.Request
which provides a method .Context()
that returns the context.Context
of the HTTP request.
When the *http.Request.Context()
is canceled, there is no use of the response, and all the operations necessary to create the response should not be executed. The context.Context
allow us to detect, deep into the call stack, between multiple goroutines, when we should stop doing all the operation necessary to assemble a response.
Handling context.Context
When we encounter blocking operations, the default approach is to:
- Set up a channel to get the result of the blocking operation
- Do the blocking part in another goroutine and send the result to the channel set up before
- In the main control flow, listen to both channels, the result channel and the
ctx.Done()
channel - Avoid leaking goroutines by finding a way to stop the blocking goroutine. Usually, this involves calling something like the
.Close()
method.
If we receive first from the result channel, great, we keep going with our standard flow.
If, on the other hand, we receive first from the ctx.Done()
channel it means that the whole operation should be canceled, and the fastest way is to just return an error.
Often, this setup is done for us by any library that talks directly with the network or the disk. If a function takes a context.Context
as argument, the usual assumption is that it is already managing the lifecycle of the context.Context
, so in most cases, it is not necessary for us to set up the infrastructure mentioned above. Still useful to know how to do it, since not all the libraries are context.Context
aware.
context.Context
and errors
After ctx.Done()
returns, ctx.Err()
contains a non-nill error.
The error helps in understanding what went wrong. In most applications does not really matter the reason why the context is completed. However, it may be useful to know if the operation was purposefully canceled, or if it just expired after a timeout.
var err error
select {
case <- ctx.Done():
err = ctx.Error()
case <-somethingElse:
// do nothing in this case
}
if err != nil {
switch {
case errors.Is(errDeadline, context.Canceled):
fmt.Println("The context was cancelled")
case errors.Is(errDeadline, context.DeadlineExceeded):
fmt.Println("The deadline of the context expired")
}
}
You can play around with these ideas in this playground.
Create and cancel context.Context
s
In most applications, we are not concerned with how the context.Context
is created, or even how it is canceled. We just use the context.Context
, trying to complete all the necessary operations before the context.Context
gets done.
However, sometimes, it is necessary to create and manage context.Context
es ourselves. Maybe you are modeling the business requirement that all the operations should be finalized in 1 second. Or, we want to make sure that contacting a third-party API takes less than 2 seconds.
In these cases, you may need to create a context.Context
yourself, that fits your specific needs.
context.Context
is an interface, so it cannot be instantiated directly.
The entry-point is usually context.Background()
that returns a default, non-nill, inert, empty, context.Context
. The context.Background()
does not expire, and it is always possible to create it.
Very close to context.Background()
there is context.TODO()
, which is basically the same, a default, non-nill, inert, empty, context.Context
The difference between context.TODO()
and context.Background()
is a semantic difference.
The context.TODO()
signal to the developer reading that the code is not finished, maybe the function that declares the context.TODO()
needs to be expanded in order to accept a real context, but there was no time yet but a context.Context
is needed. In cases like this, context.TODO()
is the right choice.
Then there are the two main context.Context
:
- The
context.Context
associated with a cancel function, created withcontext.WithCancel(context.Context)
- The
context.Context
associate with a deadline, created withcontext.WithDeadline(context.Context, time.Time)
.
There is a third one, the context associated with a timeout context.WithTimeout(ctx, time.Duration)
, which is syntax sugar over the context with a deadline.
You can notice, how to generate a new context.Context
is always necessary to pass context.Context
as input. This is the role of context.Background()
, which does not need any context.Context
as input, and it is the reason why we called it the entry-point context. It is the context.Context
necessary to generate all the other context.Context
s.
The context.Context
provided as input when creating a new context.Context
is defined as the parent context. When the parent context.Context
completes, all its children, grandchildren, and descendants complete as well.
WithCancel
context.WithCancel
returns a new context and a cancel
function. The new context is, as expected, associated with the cancel function.
ctx, cancel := context.WithCancel(context.Background())
The new context completes when either:
- The parent
context.Context
completed, or - the
cancel
function is invoked.
Invoking the cancel
function after the context.Context
is completed does nothing.
It is good practice, to always invoke the cancel
function, this ensures that none context.Context
s are leaked and that no goroutines are left waiting on a context.Context
that will never complete.
A good practice is to just defer cancel()
.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// do something
select {
case <-ctx.Done():
return ctx.Err()
case <-somethingElse:
// do something
}
This is a good practice is also enforced by some linters and it helps in avoiding storing the context in a structure.
Deferring the cancel
invocation makes it simple to reason about the lifetime of the context. We are sure that all the goroutines depending on the context.Context
associated with the cancel
function, will receive a termination signal.
One possible use of the context.WithCancel
is to make a race concurrent workload, get the first result, and stop the others.
WithDeadline
context.WithDeadline(context.Context, time.Time)
return a new context and a cancel
function.
ctx, cancel := context.WithDeadline(
context.Background(),
time.Now().Add(500*time.Millisecond))
The cancel
function works exactly as the cancel
function returned from context.WithCancel()
, similarly it is a good idea to just defer cancel()
to make sure we are not leaking context.Context
s.
The context.Context
returned by context.WithDeadline
completes when either:
- the parent
context.Context
completed, or - the
cancel
function is invoked. - the timeout expires
In the example above, if the parent context.Context
is still uncompleted, and the cancel()
function is not invoked, after 500 milliseconds, the context.Context
will complete.
This is useful to model requirements that dictate that a specific action needs to be completed by a specific time in the future.
If we pass as argument a time.Time
in the past, the context is completed immediately
WithTimeout
The context.WithTimeout(context.Context, time.Duration)
is a conventien function on top of context.WithDeadline
.
Indeed, in most cases, it is much more useful to specify the amount of time a specific operation is allowed to take, instead of by when, in the future, the operation should complete.
context.WithTimeout(ctx context.Context, duration time.Duration)
is equivalent to context.WithDeadline(ctx, time.Now().Add(duration))
context.Context
communicate only down
Now we have developed a clear understanding of how to use and how to create context.Context
s. Another piece of information that I believe is useful to highlight, is how context.Context
communicates only to their children, not their parents.
This makes absolute sense and it is the most natural approach.
If I create a derivate context.Context
with either context.WithCancel
or context.WithDeadline
, the derived context.Context
should not be allow to cancel the parent.
On the other hand, when the parent of a context.Context
completes, all its children complete as well.
This follows from the context.Context
semantic.
If a whole operation is allowed to take 1 second, none of its children should take more than 1 second.
context.Context
to store values
The last point I want to talk about is the ability of context.Context
to store arbitrary values under arbitrary keys. Both values and keys are passed as raw interface{}
.
You can set the value of a specific key using the context.WithValue(ctx context.Context, key, value interface{}) context.Context
function. As custumary, the function returns a new context.Context
with the value set.
To retrieve the value we can use the context.Value(key interface{}) interface{}
method.
Beware that as a key we should not use a default type (like string). This is to avoid having multiple packages, from different developers, overwrites each other keys.
The usual trick, on this occasion, is to define a custom type, that can be string, and use the instantiation of that type as a key. An example clarifies:
package main
import (
"context"
"fmt"
)
type keyOne string
type keyTwo string
func main() {
ctx := context.Background()
ctx = context.WithValue(ctx, keyOne("one"), "valueOne")
ctx = context.WithValue(ctx, keyTwo("one"), "valueTwo")
fmt.Println(ctx.Value(keyOne("one")).(string))
fmt.Println(ctx.Value(keyTwo("one")).(string))
}
You can run the example in the playground.
Even if the underline key is always the string one
, the dynamic type is different and this allows to store it, in the same context.Context
two separated values.
One last detail is about the return type of ctx.Value(key interface{}) interface{}
which is a generic interface{}
. This means that the user of the value will need to cast the interface to a usable type.
Usually, it is not a problem to cast back the value, since we have set it ourselves, but in some disorganized codebase, it can lead to problems.
Overusing the capacity of context.Context
to store values may lead to very messy codebases where values are passed around as values in context.Context
instead of as parameters to functions. Eventually, a context.Context
will be a valid one depending on the path it took in the codebase, making it extremely difficult to debug and improve the codebase.
Conclusion
The article is completed and now published, but it is a living document, that I would like to improve over time.
I encourage any reader who was puzzled by anything in the article, either the wording, the style, the code, the examples, etc... to let me know in the comments. I will update the article to make it more clear for everybody.
Similarly, if you would like me to discuss some other topics, please let me know in the comments.