DEV Community

Mario Carrion
Mario Carrion

Posted on • Originally published at mariocarrion.com

Learning Go: Context package

Back in August 2016 Go 1.7 was released, it included among other things a new package called context. This package was originally implemented in golang.org/x/net/context but it was copied over to the standard library during this release.

This change made other standard library packages, like net, net/http and database/sql, to be updated to add support for the context package but without breaking existing APIs. That's why we see functions with similar name and arguments but with an extra context argument added as the first one, like database/sql.*DB 's Ping method:

func (db *DB) Ping() error
func (db *DB) PingContext(ctx context.Context) error
Enter fullscreen mode Exit fullscreen mode

This decision was made because of the version one compatibility promise, however behind the scenes most of those methods use context but with default values, usually context.Background():

func (db *DB) Ping() error {
    return db.PingContext(context.Background())
}
Enter fullscreen mode Exit fullscreen mode

One important thing to notice about those new methods is the fact that they defined a de-facto convention where if we need to use context.Context in our functions then it should be the first argument called ctx.

Let's dive into the context package.



What is in the context package?

The context package defines a type called Context that is used for deadlines, cancellation signals as well as a way to use request-scoped values.

context.Context is an interface type that defines four functions:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
Enter fullscreen mode Exit fullscreen mode

Depending on how we plan to use context.Context we may or may not use all the methods:

  • Deadline(): returns the time when the context should end, if no deadline is set then the returned bool value is false. This function is useful in cases where a context is received and we want to calculate if there's enough time to complete the work to be done.
  • Done(): returns a channel that is closed when the context ends, the way this channel is closed depends on how the context.Context was initialized, please refer to the docs for concrete details.
  • Err(): if there was an error then it returns a non-nil error when the returned channel in Done() was closed, either context.DeadlineExceeded or context.Canceled, otherwise nil.
  • Value(key interface{}): it's used to get a request-scoped value stored in the context, used in conjunction with context.WithValue.

It's most likely you're already using context in one way or another, for example if you're using database/sql or net/http; some projects rely heavily on context to achieve their goal, for example OpenTelemetry uses it intensively for instrumentation.

The code used for this post is available on Github.

Deadlines

Deadlines define a way to indicate something has been completed using time, there are two ways to do that:

Deadlines

If you refer to the source code you will notice that context.WithTimeout uses context.WithDeadline behind the scenes but adding the timeout to the current time to indicate the deadline:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
    return WithDeadline(parent, time.Now().Add(timeout))
}
Enter fullscreen mode Exit fullscreen mode

Both functions return a new context.Context as well as a context.CancelFunc function; this new context.Context is a copy of the parent one with deadline details attached to it and it's meant to be used as the argument for any subsequent calls that are supposed to be using a deadline.

The function context.CancelFunc should be called (usually via a defer) when the corresponding block of code using the new context.Context is completed, this is to propagate the cancellation to other functions using the context in case the deadline was reached.

For example:

ctx, cancel := context.WithTimeout(context.Background(), 1 * time.Millisecond)
defer cancel()

select {
case <-time.After(1 * time.Second):
    fmt.Println("overslept")
case <-ctx.Done():
    fmt.Println(ctx.Err()) // prints "context deadline exceeded"
}
Enter fullscreen mode Exit fullscreen mode

The code above will always print context deadline exceeded because the value we indicated in the call context.WithTimeout is 1 millisecond, if we modify that value to be something higher than 1 second then it will print out overslept.

This is because the select is expecting for one of two channels to receive a message, either the one returned by time (via time.After()) or the one indicated in context (via ctx.Done()).

Cancellation signals

Cancellation signals define a way to indicate something has been completed by explicitly calling a CancelFunc function, there is one way to do it:

Cancellation

  • context.WithCancel: it returns a copy of the context and a CancelFunc to indicate when to cancel some work.

Similar to the Deadlines, for Cancellation signals a context.Context is returned as well as a CancelFunc; this function should be explicitly called to indicate when a returned context is canceled to propagate to other functions using the same context the work should stop.

The difference between context.WithCancel and context.WithDeadline/context.WithTimeout is the explicitness, so instead of defining a timeout the CancelFunc should be called explicitly. In all three cases we always need to call the returned CancelFunc to properly propagate the cancellation details to other functions using the same context.

The function context.CancelFunc should be called (usually via a defer) when the corresponding block of code using the new context.Context is completed, this is to propagate the cancellation to other functions using the context in case the deadline was reached.

For example:

ch := make(chan struct{})

run := func(ctx context.Context) {
    n := 1
    for {
        select {
        case <-ctx.Done(): // 2. "ctx" is cancelled, we close "ch"
            fmt.Println("exiting")
            close(ch)
            return // returning not to leak the goroutine
        default:
            time.Sleep(time.Millisecond * 300)
            fmt.Println(n)
            n++
        }
    }
}

ctx, cancel := context.WithCancel(context.Background())
go func() {
    time.Sleep(time.Second * 2)
    fmt.Println("goodbye")
    cancel() // 1. cancels "ctx"
}()

go run(ctx)

fmt.Println("waiting to cancel...")

<-ch // 3. "ch" is closed, we exit

fmt.Println("bye")
Enter fullscreen mode Exit fullscreen mode

The code above is a bit more elaborated than the one used for Deadlines, the key part is the explicit cancel() call in the goroutine; that cancel() call is what in the end stops the run function that receives the cancelable context.

Request-scoped values

Request-scoped values define a way to set and get values that apply to concrete instances of context.Context, they are meant to be used only during a user request, for example during an HTTP request to pass down information to the subsequent internal calls, there is one way to do it:

Request-scoped values

  • context.WithValue: it returns a copy of the context that happens to include the value set.

For example:

ctx := context.WithValue(context.Background(), auth, "Bearer hi")

//-

bearer := ctx.Value(auth)
str, ok := bearer.(string)
if !ok {
    log.Fatalln("not a string")
}

fmt.Println("value:", str)
Enter fullscreen mode Exit fullscreen mode

The code above uses both context.WithValue and context.Context.Value; context.WithValue returns a copy of the parent context with the associated value and key, then we can use that returned context and call its method Value to get the previously assigned value.

The complexity of context.WithValue is not about the implementation or usage but rather when to make those calls, recall the point of using this function is only for request-scoped values not things meant to live all the time during the execution of the program.

Some common examples include defining a value for JSON Web Tokens or extra headers, in both cases the values are meant to be passed around multiple requests to augment the subsequent requests.

Conclusion

Understanding how to use context.Context is important when dealing with instructions that require cancellation, for example HTTP requests, database commands or remote produce calls; not only because we need to define a sane value to indicate when to stop a running request to avoid waiting forever but also to identify when a remote call was canceled to properly react to that event.

context.Context provides a simpler way to deal with multiple goroutines to coordinate their work, to identify timeouts and to determine when those happen. Because instrumentation is an important part of any distributed system, knowing how those values are sent and defined between different calls is useful to understand the flow of our program.

Recommended Reading

If you're looking to expand more about context.Context, I recommend reading the following links:

Discussion (0)