Hello, fellow Gophers!
This week we’re covering Go’s Context package, now a part of the Go standard library. You can check the code out here: https://golang.org/pkg/context/.
As you can see, it’s not a huge package at one file (the others are tests) and just 563 lines, with many of these being comment documentation!
But, let’s start with an interesting fact - well, a fact interesting to me at least!
Not a single one of my Go books covers “Context”, at least not in the first editions I mostly own!
Although Context as a package dates back to 2014, it didn’t become part of the standard library until Go 1.7.
Since it has only been an official Go package since mid-August 2016, any Golang book that predates this probably doesn’t mention the Context package at all!
So, before we get into Context, a “full-disclosure” moment. I had to read up a little on Context myself before writing this tutorial! There’s still a lot of Go code in the wild that makes no use of Context at all! It’s quite easy not be too familiar with Context by not using it all the time - even in 2020!
In this tutorial, I hope to convey Context in simple terms using plain English - that’s the value-add hopefully - please let me know how I do in the comments!
Let’s get started.
What is Context?
Context solves a problem in Go. It allows us to maintain control across processes, which, in Go, usually means across Goroutines.
This leads us down our first “rabbit-hole” since to-date, in these tutorials, we’ve made no mention of Goroutines, nor the related concepts of Channels and Concurrency.
We’re not going to cover these in great detail here either!
It’s enough, for now, for us to think of Goroutines as extremely lightweight threads (they start with a stack memory size of just 2KB) and we can run many thousands of Goroutines at the same time.
In Go, we call this Concurrency.
Goroutines enable Go to be both fast and memory-efficient while handling large workloads by running them concurrently.
Getting back to the problem; while it’s incredibly easy to run a function concurrently by spawning a new Goroutine, the problem, historically, has been how to manage that Goroutine once it has started.
Channels handle one part of this problem: “Communication”.
Communication between individual Goroutines, or between Goroutines and our main() function (which is itself a Goroutine) is performed over Channels.
Put another way, Channels allow us to access the results of the work performed by the Goroutine, to be notified of errors and, of work completed.
But there’s a second piece to the problem; how do we ensure Goroutines finish their work?
The simple answer is, we can’t. Much of the work we ask them to do will be dependent on factors we can’t control, like querying databases or making HTTP requests to remote APIs.
Sure, if they complete their work, we get the output we expected - and probably be notified over a Channel - but what if they can’t complete their work, or can't complete it within a timeframe that makes the work valuable to us?
In situations like these we can end up with Goroutines that are blocked until they complete, and if they can’t complete at all, Goroutines that are blocked indefinitely.
This is a problem, because, as well as allocating memory we can’t reclaim, this means our programs might not behave as we expect.
The Context package is designed to solve this problem. It provides control over Goroutines manually via cancellation, and automatically via timeouts and deadlines.
Using one (or more) of these mechanisms we can limit a Goroutine's run-time, by providing it with a timeout or deadline for its operation, effectively time constraints it must do its work under or, alternatively, we can cancel it conditionally at some point.
So, although we can’t ensure a Goroutine completes its work, Context allows us to limit the execution window for that work.
Context is created and passed to the Goroutines whose Context we wish to set. Context can be chained, and by this, I mean additional rules can be added to the current* Context.
The Context package also allows key/values to be stored within a Context and passed to Goroutines, but more on this later.
context.Background
The top-level Context is an empty context (as distinct from a nil-context), which we create with the Context package’s Background function, typically in the main() function of our program.
You’ll also see context.Background used extensively in Unit Tests.
Additional Context operations build on this empty context.
context.TODO
Because not all Go code uses Context, because of its late addition to the standard library, and, when developing or refactoring code to use Context it may not be immediately obvious how the Context will be made available, we have TODO.
As with the Background function, TODO returns an empty context which can be used as a placeholder for Context.
TODO should be used in function arguments in preference to Background. The distinction between Background and TODO is important when using static analysis tools which check for correct Context propagation at compile-time.
Idioms, idioms…
‘ctx’ is the idiomatic name for a Go variable which holds a Context.
Adhering to this convention will make your code more readable to others and, by extension, automatically make you more familiar with other Developers’ code. Worth it in my opinion.
Using Cancellation in a Context
As we said earlier, cancellation allows us to manually cancel a Goroutine.
To implement cancellation in the current Context we use the WithCancel function. It returns a function we can use to stop the Goroutine.
Likely, we will call this “cancel” function conditionally, in response to something else, say a control signal to terminate the program so that we may clean up and exit gracefully.
In the example below, we call the “cancel” function after a short delay.
package main
import (
"context"
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func main() {
ctx := context.Background()
cancelAfter := 10 * time.Second
ctx, cancel := context.WithCancel(ctx)
// After the 'cancelAfter' time, call the contexts cancellation function
time.AfterFunc(cancelAfter, cancel)
DoWorldDomination(ctx)
RunLonger()
}
func DoWorldDomination(ctx context.Context) {
wg.Add(1)
go func() {
defer wg.Done()
for {
fmt.Println("Mwah hah ha haaahhhh... Golang at Speed can never be stopped!!")
select {
case <-time.After(2 * time.Second):
case <-ctx.Done():
err := ctx.Err()
fmt.Printf("Oh!! '%v'. Those meddling kids!!!!\n", err)
return
}
}
}()
wg.Wait()
}
// RunLonger simulates a server program which remains active
// once the context is cancelled. Demonstrates that goroutine
// is no longer running
func RunLonger() {
for i := 0; i < 4; i++ {
time.Sleep(2 * time.Second)
fmt.Println("simulating long running program")
}
}
You can play with this example here: https://play.golang.org/p/c6yuw-RRDGA.
Note, how we build on the empty context returned by context.Background() function? Note also the “go” keyword which starts the anonymous function as a Goroutine, the select statement syntax and, also, the <-ctx.Done() syntax.
Finally, notice also how we use Waitgroups and the Sync package to block the program’s execution until the Gorotuine’s work is done, or its Context is cancelled.
We’ll go into more detail on these things in later tutorials, but, basically, we’re using this code to listen for when the Context says, “that’s it everyone - enough, time to stop”.
So Context tells us we’re done, but thankfully, we’ll get a more sensible message than the one above, it’ll include a specific reason the Context is ending.
We can obtain this reason using the context.Err() function and, in the above example the message, as well as making a Scooby-Doo reference, states ‘context canceled’.
Setting Timeouts for a Context
In the previous example, we looked at manual cancellation, although we cheated a little and ran the cancellation function after a short delay, we could have called it conditionally in a number of ways.
In this example and the next, we will look at how we might automatically cancel Goroutines.
First, we’ll set a timeout on the Context.
Timeouts, specify a duration: how “long” we are prepared to wait. Here is an example, very similar to the previous one, but this time, we are setting a timeout.
We can also remove the code we used to fake the cancellation event in that last example.
See below:
package main
import (
"context"
"fmt"
"time"
"sync"
)
var wg sync.WaitGroup
func main() {
ctx := context.Background()
timeoutAfter := 10 * time.Second
ctx, cancel := context.WithTimeout(ctx, timeoutAfter)
defer cancel()
DoWorldDomination(ctx)
RunLonger()
}
func DoWorldDomination(ctx context.Context) {
wg.Add(1)
go func() {
defer wg.Done()
for {
fmt.Println("Mwah hah ha haaahhhh... Golang at Speed can never be stopped!!")
select {
case <-time.After(2 * time.Second):
case <-ctx.Done():
err := ctx.Err()
fmt.Printf("Oh!! '%v'. Those meddling kids!!!!\n", err)
return
}
}
}()
wg.Wait()
}
// RunLonger omitted for brevity
A working example is on the Playground: https://play.golang.org/p/OyH2_GbQzkW.
Run it and check out the context.Err() message, see how it’s different when we set a timeout on a Context?
Setting Deadlines for a Context
A deadline on a Context is similar to setting a timeout, but, rather than specifying a duration in which the operation must complete, we instead specify an actual time that the operation may not go beyond.
Configuring a Context to have a deadline is, as you might expect, very similar to setting a timeout on a Context.
Check out the modified example below, which can be found on the Go Playground here: https://play.golang.org/p/lN0_H0pTiMJ.
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx := context.Background()
// we set a time here, not a duration of now plus
// 10 seconds in the future
deadlineTime:= time.Now().Add(10 * time.Second)
ctx, cancel := context.WithDeadline(ctx, deadlineTime)
defer cancel()
DoWorldDomination(ctx)
RunLonger()
}
func DoWorldDomination(ctx context.Context) {
wg.Add(1)
go func() {
defer wg.Done()
for {
fmt.Println("Mwah hah ha haaahhhh... Golang at Speed can never be stopped!!")
select {
case <-time.After(2 * time.Second):
case <-ctx.Done():
err := ctx.Err()
fmt.Printf("Oh!! '%v'. Those meddling kids!!!!\n", err)
return
}
}
}()
wg.Wait()
}
// RunLonger omitted for brevity
Can you spot the differences in the two examples?
Also, note the defer cancel() syntax. By using defer (to make sure cancel runs no matter what) we can ensure Context is cleaned up, or, as the Go compiler puts it, “avoid context leak”.
Chaining Context
Earlier in this tutorial, we stated it was possible to add further rules to an existing Context. For example, we could set a cancellation on a Context, and then also a timeout, or a deadline, or both.
I called this “chaining context”.
While this makes what’s happening easy to reason about, for clarity, we should say that Context is passed by value, and returned by value - it is not a pointer.
So, strictly speaking, what we receive back when “chaining” new run-time constraints to an existing Context, is actually a new Context.
That said, I like to reason about this process in terms of “chaining” because a Context can end up with multiple rules!
Here’s an example in which we “chain” setting a key/value, a cancellation, and then a timeout on the Context:
package main
import (
"context"
"fmt"
"time"
)
type ctxKey string
func main() {
// change these variables to change what happens first
var doWorkAt time.Duration = 7 * time.Second
var cancelAfter time.Duration = 8 * time.Second
var timeoutAfter time.Duration = 6 * time.Second
// background empty context
ctx := context.Background()
// add a value, we'll print this as our work
var key ctxKey = "newletter"
ctx = context.WithValue(ctx, key, "Golang At Speed!!!")
// add cancellation, fake cancel event using a timer
ctx, cancel := context.WithCancel(ctx)
time.AfterFunc(cancelAfter, cancel)
// add Timeout
ctx, cancel = context.WithTimeout(ctx, timeoutAfter)
defer cancel()
// do the work
doWork(ctx, key, doWorkAt)
}
func doWork(ctx context.Context, key ctxKey, doWorkAfter time.Duration) {
select {
case <-time.After(doWorkAfter):
value := ctx.Value(key)
if value != nil {
fmt.Print("The best newsletter is ", ctx.Value(key))
return
}
fmt.Printf("no context value found for %s", key)
case <-ctx.Done():
errString := fmt.Sprintf("%v", ctx.Err())
switch errString {
case "context deadline exceeded":
fmt.Println("deadline was exceeded, before the work could be done")
case "context canceled":
fmt.Println("context was canceled before the work could be done")
}
}
}
If that’s not very easy to read, and it probably isn’t, the full example above can be found on the Go Playground here: https://play.golang.org/p/iw9hNYnTkDi.
Notice how we use a switch statement within the select statement to discover the reason Context has reached its done state.
Try changing the durations on the variables at the top of main() to see what happens.
Passing key/values to a Context
We’ll often see “chaining” when we use a Context to store values on top of setting cancellations, timeouts or deadlines.
Yes, it’s possible to use Context to pass key/values to functions that need them - but we need to be careful how we use this.
Using Context to pass around arguments makes functions more opaque and so harder to understand.
So, we really shouldn’t be using Context to pass data around which is proper to our application’s business logic.
But applications do need data which is incidental the application itself - so not a part of the business logic - session info and JWT tokens spring to mind. Context?
The example below illustrates how we pass key/values using Context, and provides another example of context “chaining” since it holds both key/values and then, we set a timeout constraint.
package main
import (
"context"
"fmt"
"time"
"sync"
)
type ctxKey string
var wg sync.WaitGroup
func main() {
ctx := context.Background()
// set a value, not the custom type
var key ctxKey = "evilAim"
ctx = context.WithValue(ctx, key, "Golang at Speed can never be stopped")
// add a timeout
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
DoWorldDomination(ctx, key)
RunLonger()
}
func DoWorldDomination(ctx context.Context, key ctxKey) {
value := ctx.Value(key)
if value == nil {
fmt.Printf("no context value found for '%s'", key)
return
}
wg.Add(1)
go func() {
defer wg.Done()
for {
fmt.Printf("Mwah hah ha haaahhhh... %s\n", value)
select {
case <-time.After(2 * time.Second):
case <-ctx.Done():
err := ctx.Err()
fmt.Printf("Oh!! '%v'. Those meddling kids!!!!\n", err)
return
}
}
}()
wg.Wait()
}
// RunLonger omitted
The example is on the Go Playground here: https://play.golang.org/p/C8MUL-8pSs9.
Context in HTTP Servers and Clients
HTTP Servers and Clients present an ideal use-case for Context. Before Context, using Go’s default HTTP Server or Client could be problematic. It was better to create custom Clients and Servers that allowed us further control over the configuration, specifically, over HTTP request and response timeouts.
Now, we have Context to assist us, and it’s been implemented in Go’s HTTP standard library.
I found the article below on how to use Context when making HTTP requests and serving HTTP responses, by Bill Kennedy of Ardan Labs.
Bill is a prolific member of the Go community and someone who’s played a big part in my own journey learning Go. Nobody writes Go tutorials better than Bill in my opinion - so I’m not going to bother trying :)
Instead, you should check out Bill’s article on using Context in HTTP servers and clients here.
Summary
So that’s Context, in some detail and, hopefully, in simple/plain English!?
One thing I love about writing these tutorials is that my own understanding of the topic is much better after posting than before posting.
While this is always true, it has been especially true of Context. Even now, my knowledge is probably, a long way off perfect, but hopefully good enough to help other aspiring Gophers.
If you liked this tutorial, please share, please subscribe and please feedback in the comments! Critical feedback - both good and bad - will help me improve this series.
Until next week. Stay Safe. Stay Gopher.
Best,
Ollie.
Ollie, regarding the example below:
https://play.golang.org/p/Dt6fzcHF-go
I might have misunderstood something but I wonder if the go-routine inside the method is really cancelled? You see, if you put a infinite loop after the ”doWorldDomination” call in the main loop, the go-routine still performs its work.
Most applications live longer and using this method would mean that goroutines arent actually cancelled.
Ollie: Great article!
I think the section on 'Chaining Context' can be developed and explained more clearly along 2 main points:
1. A parent context will always impact all its descendent child contexts
2. A child context will always impact all its descendent child contexts but not its parent or ancestor contexts
In that sense, I think the Context behaves like traditional inheritance in OOP.