Blog

A little bit of context

Jun 11, 2022 | 9 minutes read

Tags: go

Go’s context is a very central package and can be very useful and powerful if it’s used correctly. For example, the net/http or the the grpc packages rely on it. On the other hand, its concepts can be a little difficult to understand and can be confusing, leading developpers to skip it or use it in a wrong manner.

Let’s try to explore it !

As the name suggests, context allows to pass a context to a program or a part of a program, like a function, a request handler or a goroutine ; where a context can be some arbitrary or well-defined values or a deadline.

For example, a context can be :

  • A maximum allowed duration to execute a network call (aka “timeout”)
  • Some request-specific values in a HTTP handler like an authentification
  • An application configuration passed to a sub package

A context is stored in a variable implementing the context.Context interface, which is, by convention or as a best practice, passed to functions as their first argument, for example :

func myContextedFunc(ctx context.Context, foo string, bar interface{}) error {
    // ...
    return nil
}

This way, a developer knows that function taking a context.Context as its first argument will accept and use context.

Contexts are almost all derived from another context, like russian dolls, meaning that they’re created by saying “I take a context, I add or remove these properties, and here’s a new context”. Two notable exceptions are context.Background() and context.TODO().

These two functions creates a new and empty context, with no values or deadline, and so they don’t take any arguments :

ctx := context.Background()
ctx2 := context.TODO()

In fact, if you look at the context package source, you’ll see that they have the same value, emptyCtx, an empty context with no value or deadline.

So what’s the difference between context.Background() and context.TODO() ? It’s mostly lexical.

context.Background() should be called only once by program, in the highest level, like a main function or an initializer package. In the russian dolls image, it’ll be the outer doll, the one containing all the others. The context returned should be sent to the first function call that takes a package and derives it :

package main

func main() {
    ctx := context.Background()

    ctx = otherPackageA.Init(ctx)
    ctx = otherPackageB.Init(ctx)

    // ...
}

The context created in the highest level function will now “flows” through the program, each part inheritng from previous context and adding its own specifics for the next ones.

context.TODO() should be used where you find yourself asking “I must send a context, but I don’t know which one yet.. You can see it as adding a comment like :

// @TODO : We pass a new context here, it should be defined later accordingly
package.FuncReceivingContext(context.TODO(), otherArg)

FuncReceivingContext will receive a new context, not derived from any other, with no value and no deadline.

Some linters and static analysis tools like contextcheck will ensure that all contexts are derived from a single context.Background() and fails if code is creating another background context elsewhere in the code. That’s where context.TODO() is ok to use.

The context package offers 4 functions to create derived contexts.

A call to WithCancel returns a new context derived from the context passed as argument and a function.

A call to the Done() method of the new context returns a channel that is closed when calling the function returned, called the cancel function.

Once the Done() channel is closed, the Err() method of the new context returns the context.Canceled error (whose message is “context canceled”).

This pattern is used to indicate to a function when to stop something, for example :

package main

import (
	"context"
	"fmt"
	"time"
)

func counter(ctx context.Context) {
	start := time.Now()

	// We wait for the Done() channel to be closed
	<-ctx.Done()
	// And we check the duration since we started waiting
	fmt.Printf("You waited %.2f seconds before calling cancel()\n", time.Since(start).Seconds())
}

func main() {
	// We create a new context with a cancel from the background context
	ctx, cancel := context.WithCancel(context.Background())
	// We launch counter() in background with the created context
	go counter(ctx)
	// We wait 0.3 seconds
	time.Sleep(300 * time.Millisecond)
	// And we call cancel() to stop the execution of counter()
	cancel()

	// Extra sleep is here to let the time for fmt.Printf to complete
	time.Sleep(time.Millisecond)
}

// You waited 0.30 seconds before calling cancel()
(try it !)

WithCancel is oftenly used in combination with defer() to run a task in background during a program execution :

package main

import (
	"context"
	"fmt"
	"time"
)

func runInBackground(ctx context.Context) {
    tick := time.NewTicker(60 * time.Second)
    go func() {
        for {
            select {
            case <-ctx.Done():
                // main has stopped, we stop this background run
                tick.Stop()
                return // to stop goroutine execution (goroutine leak)
            case <- tick.C:
                // Do something every minute like cleaning up resources
            }
        }
    }()
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    runInBackground(ctx)

    // launch program ...
}

If the context passed to WithCancel is already a cancel context, the Done() channel will be closed when the returned cancel function is called or when the parent’s context cancel function is called, whichever comes first.

WithDeadline creates a cancel context (see above), but with the assurance that Done() will be closed when the deadline passed as second argument is passed.

If the Done() channel is closed because the cancel function was called, the Err() method will return the context.Canceled error, but if it’s closed because the deadline has expired, it will return the context.DeadlineExceeded error (whose message is “context deadline exceeded”).

This pattern is used to give a task a deadline to complete :

package main

import (
	"context"
	"fmt"
	"time"
)

func doSomethingSlow(ctx context.Context) {
	select {
	case <-time.After(1 * time.Second):
		fmt.Printf("could do something slow\n")
	case <-ctx.Done():
		fmt.Printf("could not do something slow because %s happened\n", ctx.Err())
	}
}

func doSomethingFast(ctx context.Context) {
	select {
	case <-time.After(1 * time.Microsecond):
		fmt.Printf("could do something fast\n")
	case <-ctx.Done():
		fmt.Printf("could not do something fast because %s happened\n", ctx.Err())
	}
}

func main() {
	deadline := time.Now().Add(500 * time.Millisecond)
	ctx, cancel := context.WithDeadline(context.Background(), deadline)
	defer cancel()

	go doSomethingSlow(ctx)
	go doSomethingFast(ctx)

	time.Sleep(time.Second)
}

// could do something fast
// could not do something slow because context deadline exceeded happened
(try it !)

If the parent context (the one passed as first argument) already has a deadline set, the new context will be set with the sooner deadline between parent’s and passed ones.

Even if the deadline has passed, this is still the function creating the new context’s responsability to call the cancel function, as calling it will release resources allocated to create the deadline. So ALWAYS call cancel() when creating Deadline context.

WithTimeout works exactly the same way as WithDeadline does, except that the second argument is not a time but a duration, to be added to current time.

Instead of doing

deadline := time.Now().Add(500 * time.Millisecond)
newContext, cancel := context.WithDeadline(previousContext, deadline)
defer cancel()

You just do

newContext, cancel := context.WithTimeout(previousContext, 500 * time.Millisecond)
defer cancel()

WithValue is used to create a new context with any arbitrary value in it. It can be very useful if used wisely, respecting a few guidelines.

Basically, any value can be passed for the key and value as long as they’re comparable:

newContext := context.WithValue(previousContext, "answer", 42)

But as a best practice (seriously, follow this one), it is important to not use any built-in type (string, number, known struct or interface) for the key value. For an even best practice, always use an unexported type of the package using the context’s value.

The reason behind is that everyone can add a new value to any context using WithValue, that means that if you use a string as key for example, a collision with any other package using the same string can happen and the value will be replaced.

A typical implementation of this guideline can be :

package myownpackage

type contextValueKeyType string

var contextValueKey = contextValueKeyType("my key name")

func SetContextValue(ctx context.Context, value string) context.Context {
    return context.WithValue(ctx, contextValueKey, value)
}

func GetContextValue(ctx context.Context) (string, error) {
    v, ok := context.Value(ctx, contextValueKey).(string)
    if !ok {
        return "", errors.New("value was not set in context")
    }
    return v, nil
}

So it’ll be used in other package without any possible collision :

ctx := context.Background()

value, err := myownpackage.GetContextValue(ctx)
// err = errors.New("value was not set in context")

ctx = myownpackage.SetContextValue(ctx, "test")
value, err := myownpackage.GetContextValue(ctx)
// value = "test"

It is important to keep in mind that context values are not meant to pass parameters to a function but to keep a/some values in a context, for example, an authentification result through an http request handling or a configuration through a program.

package main

import (
	"context"
	"net/http"
	"time"
)

func main() {
    // We create a deadline context that expires in 2 seconds
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    // cancel() is called after execution to release resources
	defer cancel()
    
    // we pass ctx so that client will use this deadline context
	req, err := http.NewRequestWithContext(ctx, "GET", "http://www.google.com", nil)

	if err != nil {
		panic(err)
	}

	client := http.Client{}
	resp, err := client.Do(req)

	if err != nil {
		if err == context.DeadlineExceeded {
			panic("fetch took more than 2 seconds")
		}
		// another error than timeout
		panic(err)
	}
	println(resp)
}

package main

import (
	"context"
	"fmt"
	"time"
)

// slowFunction is a slow function that takes 200 milliseconds to run
func slowFunction() int {
	time.Sleep(200 * time.Millisecond)
	return 42
}

// wrapWithDeadline will ensure that running slowFunction() is done before the deadline specified
// in ctx, if any.
func wrapWithDeadline(ctx context.Context) (int, error) {
	// We launch the execution of slowFunction() in a goroutine, result will be sent in a channel
	res := make(chan int)
	go func() {
		res <- slowFunction()
	}()

	// If the result channel receives the result of slowFunction first, the result is returned
	// If the deadline hits first, the context.DeadlineExceeded error is returned
	select {
	case <-ctx.Done():
		return 0, ctx.Err()
	case v := <-res:
		return v, nil
	}
}

func main() {
	ctx := context.Background()

	ctx1, cancel1 := context.WithTimeout(ctx, 100*time.Millisecond)
	defer cancel1()
	res, err := wrapWithDeadline(ctx1)
	fmt.Println(res) // 0
	fmt.Println(err) // context deadline exceeded

	ctx2, cancel2 := context.WithTimeout(ctx, 300*time.Millisecond)
	defer cancel2()
	res, err = wrapWithDeadline(ctx2)
	fmt.Println(res) // 42
	fmt.Println(err) // <nil>
}
(try it !)