Starting with Go 1.7, the context package was officially introduced into the official standard library. In fact, we often encounter “context” in Go programming, both in general server code and in complex concurrent programs. Today, we’re going to dive into its implementation and best practices.

The official documentation explains the context package as follows.

Package context defines the Context type, which carries deadlines, cancelation signals, and other request-scoped values across API boundaries and between processes.

In simple terms, “context” can be understood as a running state, a scene, a snapshot of a program unit. Context means that there is an upper and lower layer that passes the content to the lower layer, while the program unit refers to the Goroutine. Each Goroutine needs to know the current execution state of the program before it can be executed, and this execution state is usually encapsulated in a “context” variable that is passed to the Goroutine to be executed. The context package is designed to simplify the handling of multiple Goroutines for a single request and operations related to request deadlines, cancellation signals, and request field data. A common example is that in a Go implementation of a server application, each network request typically requires the creation of a separate Goroutine for processing, which may involve multiple API calls, which in turn may open other Goroutines; since these Goroutines are all processing the same network request, they Since they are all handling the same network request, they often need access to shared resources such as user authentication token rings, request deadlines, etc. And if the request times out or is cancelled, all Goroutines should exit and release the relevant resources immediately. Using contexts allows Go developers to easily implement interactions between these Goroutines, track and control them, and pass request-related data, cancel Goroutine signals or deadlines, etc.

go context

Contextual data structures

The core data structure in the context package is a nested structure or a one-way inheritance structure. Based on the initial context (also called the “root context”), developers can define their own methods and data to inherit from the “root context” depending on the usage scenario; it is this hierarchical organization of the context that allows developers to define some different features in each layer of the context. This hierarchical organization also makes the context easy to extend and its responsibilities clear.

The most fundamental data structure in the context package is the Context interface.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type Context interface {
    // Done returns a channel that is closed when this Context is canceled or times out.
    Done() <-chan struct{}

    // Err indicates why this context was canceled, after the Done channel is closed.
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    Value(key interface{}) interface{}
}

The Context interface defines four methods.

  • The Done() method returns a read-only channel of any type; using this method the developer can do some cleanup operations after receiving a cancellation request from the context, or when the deadline is up, and then exit the Goroutine and release the relevant resources, or call the Err() method to get the reason why the context was cancelled, or call the Value() method to get the the relevant value in the context.
  • The Err() method returns the reason why the context was cancelled; this method is typically called when the channel returned by the Done() method has data (indicating that the corresponding context was cancelled).
  • Deadline() method i.e. sets the deadline of the context, at which time the context will automatically initiate a cancellation request; when the second return value ok is false it means that no deadline has been set and if it needs to be cancelled, the cancellation function needs to be called to cancel it.
  • The Value() method gets the “value” bound to the context by the “key”; this method is thread-safe and, like the Err() method, is generally called when the channel returned by the Done() method has data.

Context implementation principles

Since Context is defined as an interface, to use it the developer has to implement the 4 methods defined by the interface. However, this is not the recommended usage of context. The context package defines an implementation of the Context interface called emptyCtx.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
    return
}

func (*emptyCtx) Done() <-chan struct{} {
    return nil
}

func (*emptyCtx) Err() error {
    return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
    return nil
}

The definition of the emptyCtx structure we saw above shows that emptyCtx is an implementation of the Context interface that cannot be cancelled and does not set a deadline or carry any value. However, we do not use it directly, but create two different Context objects based on emptyCtx by using two factory methods in the context package, and use these two Context objects as the top-level “root context” when we need to start a context, and then These contexts are finally organized into a tree structure; thus, when a context is cancelled, all the contexts inherited from it are automatically cancelled. The factory methods of the two context objects are defined as follows.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var (
    background = new(emptyCtx)
    todo = new(emptyCtx)
)

func Background() Context {
    return background
}

func TODO() Context {
    return todo
}
  • The Background() factory method gets the default value of the context and is mainly used in the main() function, initialization, and test code as the “root context” of the context tree structure, which cannot be cancelled.
  • The TODO() factory method creates a todo context, which is generally used less often and should only be used when you are not sure which context should be used.

Context inheritance derivation

Once you have a “root context”, how do you derive more “child contexts”? This relies on a series of With functions provided by the context package.

1
2
3
4
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (ctx Context, cancel CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (ctx Context, cancel CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

With these four With functions, the developer can create a context tree, where each node can have any number of children and any number of node hierarchies.

  • WithCancel function, which passes a parent context as an argument, returns a child context, and a cancel function to cancel the returned context.
  • WithDeadline function, similar to the WithCancel function, but passes an additional deadline parameter, meaning that the corresponding context will be automatically cancelled at that point in time, or, of course, it can be cancelled in advance via the cancel function instead of waiting for that time.
  • The WithTimeout function is basically the same as the WithDeadline function, except that the parameter passed is the context timeout, meaning that the corresponding context will be automatically cancelled after how much time.
  • WithValue function is different from the other three functions, it is not used to cancel the context, but only to generate a context with bound key-value pair data, this bound data can be accessed through the context.Value method, generally when you want to pass data through the context you can use this method.

Context best practices

  • The “root context” is generally the background context, obtained by calling context.Background().
  • Do not put the context object in the structure definition, but pass it between functions as a parameter display.
  • Generally pass the context as the first argument to every function on the entry request and exit request links, with ctx as the recommended variable name.
  • Do not pass a context with the value nil to a function or method, otherwise the context tree will be broken when tracing.
  • The Value() method using a context should pass the required data, don’t pass everything using the Value() method; context passing data is thread-safe.
  • a context object can be passed to any number of Goroutines, and when a cancel operation is performed on it, all Goroutines will receive the cancel signal.

The following is my personal summary of typical usage examples of context.

  1. Use the Done() method to actively cancel the context。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    
    func process(ctx context.Context, wg *sync.WaitGroup) error {
        defer wg.Done()
        respC := make(chan int)
        // business logic
        go func() {
            time.Sleep(time.Second * 2)
            respC <- 10
        }()
        // wait for signal
        for {
            select {
            case <-ctx.Done():
                fmt.Println("cancel")
                return errors.New("cancel")
            case r := <-respC:
                fmt.Println(r)
            }
        }
    }
    
    func main() {
        wg := new(sync.WaitGroup)
        ctx, cancel := context.WithCancel(context.Background())
        wg.Add(1)
        go process(ctx, wg)
        time.Sleep(time.Second * 5)
        // trigger context cancel
        cancel()
        // wait for gorountine exit...
        wg.Wait()
    }
    

    Output.

    1
    2
    
    10
    cancel
    
  2. Timeout automatically cancels the context

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    
    func process(ctx context.Context, wg *sync.WaitGroup) error {
        defer wg.Done()
    
        for i := 0; i < 1000; i++ {
            select {
            case <-time.After(2 * time.Second):
                fmt.Println("processing... ", i)
    
            // receive cancelation signal in this channel
            case <-ctx.Done():
                fmt.Println("Cancel the context ", i)
                return ctx.Err()
            }
        }
        return nil
    }
    
    func main() {
        wg := new(sync.WaitGroup)
        ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
        defer cancel()
    
        wg.Add(1)
        go process(ctx, wg)
        wg.Wait()
    }
    

    Output.

    1
    2
    3
    
    processing...  0
    processing...  1
    Cancel the context  2
    
  3. Passing data between Goroutines via the context’s WithValue() method

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    func main() {
        ctx, cancel := context.WithCancel(context.Background())
        valueCtx := context.WithValue(ctx, "mykey", "myvalue")
    
        go watch(valueCtx)
        time.Sleep(10 * time.Second)
        cancel()
    
        time.Sleep(5 * time.Second)
    }
    
    func watch(ctx context.Context) {
        for {
            select {
            case <-ctx.Done():
                fmt.Println(ctx.Value("mykey"), "is cancel")
                return
            default:
                fmt.Println(ctx.Value("mykey"), "int goroutine")
                time.Sleep(2 * time.Second)
            }
        }
    }
    

    Output.

    1
    2
    3
    4
    5
    6
    7
    
    myvalue int goroutine
    myvalue int goroutine
    myvalue int goroutine
    myvalue int goroutine
    myvalue int goroutine
    myvalue int goroutine
    myvalue is cancel
    

Summary

The main purpose of “context” in Go is to synchronize cancellation signals or deadlines between multiple Goroutines or modules, to reduce the consumption of resources and long time occupation, and to avoid wasting resources, although passing values is also one of its functions, but this function is rarely used. We should also be very careful not to pass all parameters of a request in “context”, which is a very poor design. The more common usage scenario is to pass the authentication token of the user for the request and the request ID for distributed tracking.

In Go programming, it is usually not possible to kill a Goroutine directly, and the closing of a Goroutine is usually controlled by “channel + select”. However, in some scenarios, such as when many Goroutines are created to handle a request, and these Goroutines need to share some global variables, have a common deadline, etc., and can be closed at the same time, it is more difficult to use “channel + select” in this case, but You can easily cope with this by using “context”.