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.

  1. If a function takes a context.Context as an argument, it should be the function's first argument.
  2. context.Context should be bound to the ctx identifier.
  3. 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:

  1. Take a context.Context as input
  2. 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:

  1. Set up a channel to get the result of the blocking operation
  2. Do the blocking part in another goroutine and send the result to the channel set up before
  3. In the main control flow, listen to both channels, the result channel and the ctx.Done() channel
  4. 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.Contexts

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.Contextes 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:

  1. The context.Context associated with a cancel function, created with context.WithCancel(context.Context)
  2. The context.Context associate with a deadline, created with context.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.Contexts.

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:

  1. The parent context.Context completed, or
  2. 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.Contexts 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.Contexts.

The context.Context returned by context.WithDeadline completes when either:

  1. the parent context.Context completed, or
  2. the cancel function is invoked.
  3. 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.Contexts. 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.