{ Soham Kamani }

AboutBlog GithubTwitter

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:

client server model diagram

The timing diagram, if everything worked perfectly, would look like this:

timing diagram with all events finishing

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:

timing diagram with http request cancelled, and other processes still taking place

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:

timing diagram with all processes cancelling once HTTP request is cancelled

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
  2. 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.

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


Like what I write? Join my mailing list, and I'll let you know whenever I write another post

Comments

Soham Kamani

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