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()
andcontext.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.