Nov 03, 2019

Understanding Go's context package

The context package and feature is a core part of how most Go applications are built today. Very often, you want to define a timeout, deadline or have the ability to cancel a certain operation and all its invoked functionality from running respectively. When building applications for the web, all of those decisions are usually made inside of a request-response cycle.

Incoming requests to a server should create a Context, and outgoing calls to servers should accept a Context.

The chain of function calls between them must propagate the Context, optionally replacing it with a derived Context created using WithCancel, WithDeadline, WithTimeout, or WithValue.

When a Context is canceled, all Contexts derived from it are also canceled.

Using these statements from the context godoc page, we can confirm the place contexts should be used in, as well as contexts being able to be canceled and all derived (sub-)contexts getting canceled at that point, too.

Getting started

To create the "root" context in each request, we'll have to declare a Background context, which will never be canceled, has no deadline and no values, either.

import "context"

func handleRequest() {
  // Create a fresh root context
  ctx := context.Background()
}

Imagine we want to declare a top-level timeout for the request to complete in a specified time span and otherwise return an error. For this we'd utilize the WithTimeout helper the context package provides to set up and handle a potential timeout in our application.

import (
  "context"
  "time"
)

func handleRequest() {
  // Create a fresh root context
  ctx := context.Background()

  timeoutCtx, cancel := context.WithTimeout(ctx, 2 * time.Minute)

  // Release resources if slowOperation completes before timeout elapses
  defer cancel()

  // Construct a channel for communication and start the slow operation
  errChannel := make(chan error)
  go slowOperation(ctx, errChannel)

  select {
    case e := <-slowOperation:
      // If the error (or nil) comes back
      // before the case below is triggered,
      // we completed the slow operation before
      // the timeout was reached.
    case <-ctx.Done():
      // Handle the timeout
  }
}

func slowOperation(ctx context.Context, errChannel chan<- error) {
  // Do some slow things in here
}

In general, using the context helpers and passing down the context to all operations will enable you to build guarantees as to how your application will behave in situations where you cannot predict the actual performance and related problems by having an escape hatch to cancel running operations.

The only feature I'd be careful using is context values, which open up a way to share request-scoped data along with all layers the context is passed to.

This will most likely result in you trying to stuff everything into the context but this ends up in your code being a lot harder to read and reason about later on as you don't know where specific data points originate from.

If you really want to share data, pass it down to the actual consumer rather than wrapping it into a black box.

Helpful resources for learning more about the Go context

Wrapping up

Now that you know where and how to use Go's context feature, you're one step closer to building reliable and stable applications that stand real-world conditions.

If you have any further questions, suggestions or feedback in general, don't hesitate to jump in my DMs or send a mail.