Go Context Tutorial
Welcome Gophers! 👋
In this tutorial, we are going to be covering contexts in Go and how we can use them within our own Go applications.
Contexts Overview
So first of all, what are contexts?
I like to imagine contexts almost like a parcel of information that is sent between the various layers of your application. Your parcel is created at the edge of your application - typically when a new API request comes in. This parcel will then be delivered to your service layer and onto your storage layer.
This parcel starts off containing a few important pieces of functionality at first which are:
- The ability to store additional information that can be passed down the chain.
- The ability to control cancellation - you can create parcels that act as ticking time bombs that can stop the execution of your code if they exceed either a specific deadline or timeout value.
Context With Value
Let’s start off by looking at this first bit of functionality, which is the ability to store additional information.
package main
import (
"context"
"fmt"
)
func enrichContext(ctx context.Context) context.Context {
return context.WithValue(ctx, "api-key", "my-super-secret-api-key")
}
func doSomethingCool(ctx context.Context) {
apiKey := ctx.Value("api-key")
fmt.Println(apiKey)
}
func main() {
fmt.Println("Go Context Tutorial")
ctx := context.Background()
ctx = enrichContext(ctx)
doSomethingCool(ctx)
}
In this example, we’ve started off by creating a new ctx
object using the context
package and the .Background
function that returns a non-nil, empty Context.
Adding Values to Our Context
In the enrichContext
function above, we’ve taken in the existing content and effectively wrapped it with a new context that contains our api-key
for demonstration purposes.
// this creates a new context.Context struct which contains the key/value
// api-key: my-super-secret-api-key
context.WithValue(ctx, "api-key", "my-super-secret-api-key")
Note - It’s important to note that the
WithValue
function returns a copy of the existing context and doesn’t modify the original context.
Reading Values from our Context
In the doSomethingCool
function, we’ve then taken in this new ctx
struct. We have then decided that we need the api-key
from that context to, for example, interact with an API endpoint that requires authentication.
// Here we are retrieving the value of 'api-key' and
// assigning it to 'apiKey'.
apiKey := ctx.Value("api-key")
If you are retrieving values from your ctx
, it’s good to note that when you try and access a key/value pair from the ctx object that doesn’t exist, it will simply return nil
. If you need a value from a context, you may want to check, for example, if apiKey != nil
and then return with an error if the key you need is not set.
Bad Practices - Using Contexts For Everything
It should be noted that whilst you can certainly use contexts to pass information between the layers of your application, you absolutely need to use this only for things that truly need to be propagated through.
You shouldn’t use contexts as a bucket for all of your information. It’s a supplementary object that you can store things like request IDs
for example or trace IDs
which can then be used for logging and tracing purposes.
Context Deadlines using WithTimeout
In some high-performance systems, you may need to return a response within a deadline. In a previous company I’ve worked at, we had roughly 2 seconds to return a response within our system or the action taking place would fail.
The context.Context
struct contains some of the functionality that we need in order to control how our system behaves when our system exceeds a deadline.
package main
import (
"context"
"fmt"
"time"
)
func doSomethingCool(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("timed out")
return
default:
fmt.Println("doing something cool")
}
time.Sleep(500 * time.Millisecond)
}
}
func main() {
fmt.Println("Go Context Tutorial")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go doSomethingCool(ctx)
select {
case <-ctx.Done():
fmt.Println("oh no, I've exceeded the deadline")
}
time.Sleep(2 * time.Second)
}
Let’s try and run this now:
Go Context Tutorial
doing something cool
doing something cool
doing something cool
doing something cool
doing something cool
oh no, I've exceeded the deadline
timed out
Program exited.
Go Playground Link - https://go.dev/play/p/tB2CRK-lvcW
Creating a Context WithTimeout
On the second line of the main()
function in the above snippet we’ve created a new context and a cancel
function using WithTimeout():
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
We’ve then gone to start a goroutine that we want to stop if it exceeds the 2 second timeout period that we’ve imposed. We have then deferred the call to cancel()
and kicked off the goroutine that we want to timeout if our deadline is exceeded.
Within the doSomethingCool
function, you’ll notice we have a for
loop that is simulating a long running process. Within this, we are constantly checking to see if the Done channel within the parent context object has been closed due to timeout, and if it hasn’t we continue to print doing something cool
every half-second before the timeout is exceeded.
Context Errors
The context
object also features Err()
which can be useful for when you need to return the error that caused your fucntion to halt.
Whenever you call this Err()
function, it’s going to return nil
if Done is not yet closed. If Done is closed, then Err is going to return the error explaining why it was closed.
In the below case, we’ll see that this returns with a context deadline exceeded
error:
package main
import (
"context"
"fmt"
"time"
)
func doSomethingCool(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("timed out")
err := ctx.Err()
fmt.Println(err)
return
default:
fmt.Println("doing something cool")
}
time.Sleep(500 * time.Millisecond)
}
}
func main() {
fmt.Println("Go Context Tutorial")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go doSomethingCool(ctx)
select {
case <-ctx.Done():
fmt.Println("oh no, I've exceeded the deadline")
}
time.Sleep(2 * time.Second)
}
And the output from this is going to look a little like this:
Go Context Tutorial
doing something cool
doing something cool
doing something cool
doing something cool
doing something cool
oh no, I've exceeded the deadline
timed out
context deadline exceeded
Program exited.
Go Playground Link - https://go.dev/play/p/WbDYunsEr5h
Conclusion
In this tutorial, we looked at what contexts are and how we can use them for things such as value propagation through our app. We also looked at how we can use contexts to set timeouts within our code to ensure we aren’t wasting compute resources and finally we looked at how we can access the errors from a context using the Err
method.