How to Use Contexts in Golang Applications

Published on March 21, 2023

Explain Contexts advantages for controlling deadlines, cancellation signals and passing values across application levels.

Introduction

In modern application sometimes it’s useful for a function to know about the environment in addition to information required to work on its own. For example, function is handling HTTP request may refer to user specific parameters such as location or timezone for service for relevant response. Another example, function awareness about client connection may help cancel computation when connection is closed or operation was cancelled by client. In this case, application mat stops preparing the response that will never be used.

All mentioned features, such as: execution deadline, cancellation signal, passing values provided by context package in Go standard library.

Declaration of Context interface:

// A Context carries a deadline, a cancellation signal, and other values across
// API boundaries.
// Context's methods may be called by multiple goroutines simultaneously.
type Context interface {
	// Deadline returns the time when work done on behalf of this context
	// should be canceled. Deadline returns ok==false when no deadline is set.
	Deadline() (deadline time.Time, ok bool)

	// Done returns a channel that's closed when work done on behalf of this
	// context should be canceled. Done may return nil if this context can
	// never be canceled.
	Done() <-chan struct{}

	// If Done is not yet closed, Err returns nil.
	// If Done is closed, Err returns a non-nil error explaining why:
	// Canceled if the context was canceled
	// or DeadlineExceeded if the context's deadline passed.
	Err() error

	// Value returns the value associated with this context for key, or nil
	// if no value is associated with key.
	Value(key any) any
}

Context creation

Example of context creation:

ctx := context.Background()

context.Background() - returns new empty context, without deadline, without any values. Usually, context creation using this method located in very beginning of program. Empty context is not so useful on its own, you have to enrich context with additional information.

Also, there is context.TODO() that does same thing as previous method, but context.TODO() preferable to use if your don’t know properly which kind of context to use in certain case.

Derived contexts

For adding features into empty context you should to use appropriate functions from context package:

  • context.WithDeadline() and context.WithTimeout() - for context with deadline.
  • context.WithCancel() - for cancelable context.
  • context.WithValue() - for context containing values.

All of these function accepts context object created before and creates a copy of given context with new details in addition. Existing context will not be affected on new context derivation.

Context with Cancellation

Cancellable context creation example:

ctx, cancelFn := context.WithCancel(context.Background())

context.WithCancel() accepts context object and return new context and cancel function as result. Calling this cancel function produces cancellation of created context.

Listening to context cancellation

Context cancellation propagate signals to gracefully terminate goroutines. Context cancellation does not kill goroutines automatically. You should to listen cancellation signals and implement goroutine finishing. The Done() <-chan struct{} returns channel that will be closed on context cancelled. We can listen to channel closed event to identify context cancellation.

Consider sample code below for better understanding. The plan is to create goroutine that will print message to output every second and stop it by cancelling context.

package main

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

func main() {
  // Create ticker
  ticker := time.NewTicker(time.Second)
  defer ticker.Stop()

  // create context with cancel and start printer
  ctx, cancelFn := context.WithCancel(context.Background())

  // run goroutine
  go func() {
    for {
      select {
      case <-ctx.Done():
        return
      case <-ticker.C:
        fmt.Println("I'm alive!")
      }
    }
  }()

  time.Sleep(3 * time.Second)
  // cancel the context
  cancelFn()

  // wait a bit and finish program
  time.Sleep(3 * time.Second)
  fmt.Println("Finish")
}

Output:

I am alive!
I am alive!
I am alive!
Finish

In the beginning application we create ticker with 1 second interval and context object with cancel function. After, we start goroutine with infinite loop inside, that waiting signals from context or ticker. We use <-ctx.Done(): for selecting context cancellation signal. We will wait 3 seconds before cancel context, this pause lest goroutine to print 3 messages. Also, we have 3 seconds break before finishing program to ensure goroutine stopped and doesn’t print messages anymore.

Context Canceled error

The Err() error of context interface returns Cancelled error if context was cancelled. Check out the example:

func watchContext(ctx context.Context) {
	<-ctx.Done()
	if errors.Is(ctx.Err(), context.Canceled) {
		fmt.Println("Context cancelled!")
	}
	fmt.Println("Context done")
}

This function wail until context is done. And determine context termination reason by matching error using errors.Is() function.

Context Cancellation Propagation

Consider the code listed below. In this example we create three contexts, derived from one another. Imagine them as a tree, from root to leaf: rootCtx -> childCtx -> grandchildCtx. Start goroutines waiting cancellation for each of these contexts. Cancelling of root context results to cancelling all child contexts.

Context cancellation propagation explanation:

package main

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

// watchContext prints message on context cancelled
func watchContext(ctx context.Context, name string) {
  <-ctx.Done()
  fmt.Println(name, ctx.Err().Error())
}

func main() {
  rootCtx, rootCtxCancel := context.WithCancel(context.Background())
  childCtx, _ := context.WithCancel(rootCtx)
  grandchildCtx, _ := context.WithCancel(childCtx)

  go watchContext(rootCtx, "rootCtx")
  go watchContext(childCtx, "childCtx")
  go watchContext(grandchildCtx, "grandchildCtx")

  time.Sleep(time.Second)
  rootCtxCancel()

  time.Sleep(time.Second)
}

Output:

childCtx cancelled
grandchildCtx cancelled
rootCtx cancelled

Keep in mind, order of cancelling derived goroutines not guarantied. The order of output strings may be different on every run.

Time bound context

Control operation duration and time boundaries is another important aspect in software development. Golang context package provide utilities for controlling execution timeouts and deadlines. Further, these utilities will be explained by examples.

Context with Deadline

Syntax:

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

WithDeadline() returns new context copied from the parent context with adjusted deadline as first and cancel function as second result. Deadline of new context will be no later than d. Actually, deadline may be sooner if parent context has deadline which sooner than d.

Context with deadline will be done in three cases, whichever happens first:

  • when deadline expires;
  • cancel function is called;
  • parent context’s Done channel closed

Your code should call cancel function as soon as operations running on this Context complete for releasing resources associated with it.

Next example application starts goroutine waiting for context Done channel will be closed.

package main

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

func main() {
  fmt.Println("Start...")
  // create timer
  timer := time.NewTimer(5 * time.Second)
  // calculate deadline
  deadline := time.Now().Add(2 * time.Second)
  ctx, cancel := context.WithDeadline(context.Background(), deadline)
  defer cancel()

  go func() {
    <-ctx.Done()
    fmt.Println(ctx.Err().Error())
  }()

  // wait until timer and finish program
  <-timer.C
  fmt.Println("Finish.")
}

Output:

Start...
context deadline exceeded
Finish.

Context with Timeout

Usually you have a specific timeout for operation rather than specific time when operation should be completed. There is method creating context with timeout duration instead of deadline time.

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithTimeout returns WithDeadline(parent, time.Now().Add(timeout)).

Context DeadlineExceeded error

In a case when context closed by deadline expiration Err() method will return error context.DeadlineExceeded.

Context deadline propagation

Contexts are immutable in golang, therefore it is impossible to extend on delay deadline of parent context. Context created from parent context which have deadline, deadline of resulting context can not to be later than parent context’s deadline.

Following example shows deadline propagation for derived contexts:

package main

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

func main() {
  deadline := time.Now().Add(time.Minute)
  parentCtx, _ := context.WithDeadline(context.Background(), deadline)
  childCtx, _ := context.WithDeadline(parentCtx, deadline.Add(10*time.Second))

  paretnDeadline, _ := parentCtx.Deadline()
  childDeadline, _ := childCtx.Deadline()

  fmt.Println(deadline.Format("15-04-05"))
  fmt.Println(paretnDeadline.Format("15-04-05"))
  fmt.Println(childDeadline.Format("15-04-05"))
}

Output:

02-06-28
02-06-28
02-06-28

Output shows that both of contexts have same deadline. Attempt to create childCtx with later deadline has no effect. But, of course, you can define sooner deadline for child context.

Context with values

Context package makes possible to use context object as key-value storage. This feature allows to share data across application layers.

Syntax:

func WithValue(parent Context, key, val any) Context

You can use any type as key and value. Let’s consider the simplest example of using context as storage:

package main

import (
  "context"
  "fmt"
)

func main() {
  printerFn := func(ctx context.Context) {
    name := ctx.Value("name")
    fmt.Println(name)
  }

  ctx := context.WithValue(context.Background(), "name", "Bob")

  printerFn(ctx) // Will print "Bob"
}

Context values propagation

Context will inherit all values from the parent context, but can override someone. It will be shown in following example. We will add some modification to previous example:

package main

import (
	"context"
	"fmt"
)

func main() {
	printerFn := func(ctx context.Context) {
		fmt.Println(ctx.Value("name"), ctx.Value("age"))
	}

	rootCtx := context.WithValue(context.Background(), "name", "Bob")
	rootCtx = context.WithValue(rootCtx, "age", 23)
	childCtx := context.WithValue(rootCtx, "age", "17")

	printerFn(rootCtx)  // will print "Bob 23"
	printerFn(childCtx) // will print "Bob 17"
}

rootCtx in this example contains two values, childCtx derived from rootCtx inherit the value associated with key name and override the value of age.

Pros and cons of context values:

  • Flexibility - store any kind of data;
  • Lose compile-time type checking - you have to check types in runtime;

Conclusions

It is bad choice using context for everything in your application. Context package oriented for request scoped data, this is a data relevant and bound with currently processing request. Here request used in wide meaning, it is not only HTTP request. For example: it is a good practice to store in context user authorization data, because auth data strongly depends on current request. An application can process several request simultaneously, and each of request consists different authorization data. Context package provides good utilities for control deadlines and passing data across application layer without conflicts and collisions.