Go Context Tutorial

Elliot Forbes Elliot Forbes ⏰ 6 Minutes 📅 Dec 3, 2021

Welcome Gophers! 👋

In this tutorial, we are going to be covering contexts in Go and how we can use them within our own Go applications.

Contexts Overview

So first of all, what are contexts?

I like to imagine contexts almost like a parcel of information that is sent between the various layers of your application. Your parcel is created at the edge of your application - typically when a new API request comes in. This parcel will then be delivered to your service layer and onto your storage layer.

This parcel starts off containing a few important pieces of functionality at first which are:

  • The ability to store additional information that can be passed down the chain.
  • The ability to control cancellation - you can create parcels that act as ticking time bombs that can stop the execution of your code if they exceed either a specific deadline or timeout value.

Context With Value

Let’s start off by looking at this first bit of functionality, which is the ability to store additional information.

package main

import (
	"context"
	"fmt"
)

func enrichContext(ctx context.Context) context.Context {
	return context.WithValue(ctx, "api-key", "my-super-secret-api-key")
}

func doSomethingCool(ctx context.Context) {
	apiKey := ctx.Value("api-key")
	fmt.Println(apiKey)
}

func main() {
	fmt.Println("Go Context Tutorial")
	ctx := context.Background()
	ctx = enrichContext(ctx)
	doSomethingCool(ctx)
}

In this example, we’ve started off by creating a new ctx object using the context package and the .Background function that returns a non-nil, empty Context.

Adding Values to Our Context

In the enrichContext function above, we’ve taken in the existing content and effectively wrapped it with a new context that contains our api-key for demonstration purposes.

// this creates a new context.Context struct which contains the key/value
// api-key: my-super-secret-api-key
context.WithValue(ctx, "api-key", "my-super-secret-api-key")

Note - It’s important to note that the WithValue function returns a copy of the existing context and doesn’t modify the original context.

Reading Values from our Context

In the doSomethingCool function, we’ve then taken in this new ctx struct. We have then decided that we need the api-key from that context to, for example, interact with an API endpoint that requires authentication.

// Here we are retrieving the value of 'api-key' and
// assigning it to 'apiKey'.
apiKey := ctx.Value("api-key")

If you are retrieving values from your ctx, it’s good to note that when you try and access a key/value pair from the ctx object that doesn’t exist, it will simply return nil. If you need a value from a context, you may want to check, for example, if apiKey != nil and then return with an error if the key you need is not set.

Bad Practices - Using Contexts For Everything

It should be noted that whilst you can certainly use contexts to pass information between the layers of your application, you absolutely need to use this only for things that truly need to be propagated through.

You shouldn’t use contexts as a bucket for all of your information. It’s a supplementary object that you can store things like request IDs for example or trace IDs which can then be used for logging and tracing purposes.

Context Deadlines using WithTimeout

In some high-performance systems, you may need to return a response within a deadline. In a previous company I’ve worked at, we had roughly 2 seconds to return a response within our system or the action taking place would fail.

The context.Context struct contains some of the functionality that we need in order to control how our system behaves when our system exceeds a deadline.

package main

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

func doSomethingCool(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("timed out")
			return
		default:
			fmt.Println("doing something cool")
		}
		time.Sleep(500 * time.Millisecond)
	}

}

func main() {
	fmt.Println("Go Context Tutorial")
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	go doSomethingCool(ctx)
	select {
	case <-ctx.Done():
		fmt.Println("oh no, I've exceeded the deadline")
	}

	time.Sleep(2 * time.Second)
}

Let’s try and run this now:

Go Context Tutorial
doing something cool
doing something cool
doing something cool
doing something cool
doing something cool
oh no, I've exceeded the deadline
timed out

Program exited.

Go Playground Link - https://go.dev/play/p/tB2CRK-lvcW

Creating a Context WithTimeout

On the second line of the main() function in the above snippet we’ve created a new context and a cancel function using WithTimeout():

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)

We’ve then gone to start a goroutine that we want to stop if it exceeds the 2 second timeout period that we’ve imposed. We have then deferred the call to cancel() and kicked off the goroutine that we want to timeout if our deadline is exceeded.

Within the doSomethingCool function, you’ll notice we have a for loop that is simulating a long running process. Within this, we are constantly checking to see if the Done channel within the parent context object has been closed due to timeout, and if it hasn’t we continue to print doing something cool every half-second before the timeout is exceeded.

Context Errors

The context object also features Err() which can be useful for when you need to return the error that caused your fucntion to halt.

Whenever you call this Err() function, it’s going to return nil if Done is not yet closed. If Done is closed, then Err is going to return the error explaining why it was closed.

In the below case, we’ll see that this returns with a context deadline exceeded error:

package main

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

func doSomethingCool(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("timed out")
			err := ctx.Err()
			fmt.Println(err)
			return
		default:
			fmt.Println("doing something cool")
		}
		time.Sleep(500 * time.Millisecond)
	}

}

func main() {
	fmt.Println("Go Context Tutorial")
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	go doSomethingCool(ctx)
	select {
	case <-ctx.Done():
		fmt.Println("oh no, I've exceeded the deadline")
	}

	time.Sleep(2 * time.Second)
}

And the output from this is going to look a little like this:

Go Context Tutorial
doing something cool
doing something cool
doing something cool
doing something cool
doing something cool
oh no, I've exceeded the deadline
timed out
context deadline exceeded

Program exited.

Go Playground Link - https://go.dev/play/p/WbDYunsEr5h

Conclusion

In this tutorial, we looked at what contexts are and how we can use them for things such as value propagation through our app. We also looked at how we can use contexts to set timeouts within our code to ensure we aren’t wasting compute resources and finally we looked at how we can access the errors from a context using the Err method.