Using context cancellation in Go 💀
Many people who have worked with Go, would have encountered it’s context library. Most use context
with downstream operations, like making an HTTP call, or fetching data from a database, or while performing async operations with go-routines. It’s most common use is to pass down common data which can be used by all downstream operations. However, a lesser known, but highly useful feature of context
is it’s ability to cancel, or halt an operation mid-way.
This post will explain how we can make use of the context
libraries cancellation features, and go through some patterns and best practices of using cancellation to make your application faster and more robust.
Why do we need cancellation?
In short, we need cancellation to prevent our system from doing unnessecary work.
Consider the common situation of an HTTP server making a call to a database, and returning the queried data to the client:
The timing diagram, if everything worked perfectly, would look like this:
But, what would happen if the client cancelled the request in the middle? This could happen if, for example, the client closed their browser mid-request. Without cancellation, the application server and database would continue to do their work, even though the result of that work would be wasted:
Ideally, we would want all downstream components of a process to halt, if we know that the process (in this example, the HTTP request) halted:
Context cancellation in Go
Now that we know why we need cancellation, let’s get into how you can implement it in Go. Because the event of “cancellation” is highly contextual to the transaction, or operation being performed, it’s only natural that it gets bundled along with the context
.
There are two sides to cancellation, that you might want to implement: 1. Listening for the cancellation event 1. Emitting the cancellation event
Listening for the cancellation event
The Context
type provides a Done()
method, which returns a channel that receives an empty struct{}
type everytime the context receives a cancellation event. Listening for a cancellation event is as easy as waiting for <- ctx.Done()
.
For example, lets consider an HTTP server that takes two seconds to process an event. If the request gets cancelled before that, we want to return immediately:
func main() {
// Create an HTTP server that listens on port 8000
http.ListenAndServe(":8000", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// This prints to STDOUT to show that processing has started
fmt.Fprint(os.Stdout, "processing request\n")
// We use `select` to execute a peice of code depending on which
// channel receives a message first
select {
case <-time.After(2 * time.Second):
// If we receive a message after 2 seconds
// that means the request has been processed
// We then write this as the response
w.Write([]byte("request processed"))
case <-ctx.Done():
// If the request gets cancelled, log it
// to STDERR
fmt.Fprint(os.Stderr, "request cancelled\n")
}
}))
}
The source code for all the examples can be found here
You can test this by running the server and opening localhost:8000 on your browser. If you close your browser before 2 seconds, you should see “request cancelled” printed on the terminal window.
Emitting a cancellation event
If you have an operation that could be cancelled, you will have to emit a cancellation event through the context. This can be done using the WithCancel
function in the context package, which returns a context object, and a function. This function takes no arguments, and does not return anything, and is called when you want to cancel the context.
Consider the case of 2 dependent operations. Here, “dependent” means if one fails, it doesn’t make sense for the other to complete. In this case, if we get to know early on that one of the operations failed, we would like to cancel all dependent operations.
func operation1(ctx context.Context) error {
// Let's assume that this operation failed for some reason
// We use time.Sleep to simulate a resource intensive operation
time.Sleep(100 * time.Millisecond)
return errors.New("failed")
}
func operation2(ctx context.Context) {
// We use a similar pattern to the HTTP server
// that we saw in the earlier example
select {
case <-time.After(500 * time.Millisecond):
fmt.Println("done")
case <-ctx.Done():
fmt.Println("halted operation2")
}
}
func main() {
// Create a new context
ctx := context.Background()
// Create a new context, with its cancellation function
// from the original context
ctx, cancel := context.WithCancel(ctx)
// Run two operations: one in a different go routine
go func() {
err := operation1(ctx)
// If this operation returns an error
// cancel all operations using this context
if err != nil {
cancel()
}
}()
// Run operation2 with the same context we use for operation1
operation2(ctx)
}
Time based cancellation
Any application that needs to maintain an SLA (service level agreement) for the maximum duration of a request, should use time based cancellation. The API is almost the same as the previous example, with a few additions:
// The context will be cancelled after 3 seconds
// If it needs to be cancelled earlier, the `cancel` function can
// be used, like before
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
// The context will be cancelled on 2009-11-10 23:00:00
ctx, cancel := context.WithDeadline(ctx, time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC))
For example, consider making an HTTP API call to an external service. If the service takes too long, it’s better to fail early and cancel the request:
func main() {
// Create a new context
// With a deadline of 100 milliseconds
ctx := context.Background()
ctx, _ = context.WithTimeout(ctx, 100*time.Millisecond)
// Make a request, that will call the google homepage
req, _ := http.NewRequest(http.MethodGet, "http://google.com", nil)
// Associate the cancellable context we just created to the request
req = req.WithContext(ctx)
// Create a new HTTP client and execute the request
client := &http.Client{}
res, err := client.Do(req)
// If the request failed, log to STDOUT
if err != nil {
fmt.Println("Request failed:", err)
return
}
// Print the statuscode if the request succeeds
fmt.Println("Response received, status code:", res.StatusCode)
}
Based on how fast the google homepage responds to your request, you will receive:
Response received, status code: 200
or
Request failed: Get http://google.com: context deadline exceeded
You can play around with the timeout to achieve both of the above results.
Is this code safe to run?
func doSomething() {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
someArg := "loremipsum"
go doSomethingElse(ctx, someArg)
}
Would it be safe to execute the doSomething
function?
This code is not safe to run!
When we execute doSomething
, we create a context and defer its cancellation, which means the context will cancel after doSomething
finishes execution.
We pass this same context to the doSomethingElse
function, which may rely on the context provided to it. Since doSomethingElse
is executed in a different goroutine, its likely that the context will cancel before doSomethingElse
finishes it’s execution.
Unless this is explicitly what you want, you should always create a new context when running a function in a different goroutine, like so:
func doSomething() {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
someArg := "loremipsum"
go doSomethingElse(context.Background(), someArg)
}
Gotchas and caveats
Although context cancellation in Go is a versatile tool, there are a few things that you should keep in mind before proceeding. The most important of which, is that a context can only be cancelled once. If there are multiple errors that you would want to propogate in the same operation, then using context cancellation may not the best option. The most idiomatic way to use cancellation is when you actually want to cancel something, and not just notify downstream processes that an error has occured.
Another thing that you have to keep in mind is that the same context instance should be passed to all functions and go-routines that you would potentially want to cancel. Wrapping an already cancellable context with WithTimeout
or WithCancel
will lead to multiple possibilities through which your context could be cancelled, and should be avoided.
The source code for all the above examples can be found here
Comments
Written by Soham Kamani, an author,and a full-stack developer who has extensive experience in the JavaScript ecosystem, and building large scale applications in Go. He is an open source enthusiast and an avid blogger. You should follow him on Twitter