drio

Golang context construct (cancelation)

The context construct has always been a bit difficult to grasp for me. In this post (and future ones) we are going to explore the context package and its practical uses. One of my favorite references for learning go (and there are many is the Jon Bodner Learning go. I'll be using that as a reference.

What is it?

Servers need some mechanism to handle metadata per each request. Maybe you need some data to correctly parse the request and also ways to stop processing requests. The golang context package contains all the bits related to context. Context is not implemented in Go as feature of the language, instead it is just an instance of the Context interface.

Cancellation

There first practical usage of context is for cancelling goroutines that we don't want to continue running. Perhaps you have a bunch of http requests and one of them fails. If so, we don't want the other goroutines to continue computing so we cancel them. We will get to the example in the book, but let's start small. See this code:

package main

import (
	"context"
	"log"
	"os"
	"sync"
	"time"
)

func main() {
	// control variable to simulate a failure in the first goroutine
	fail := false
	if len(os.Args) == 2 && os.Args[1] == "fail" {
		fail = true
	}

	// create an empty context and extend it to add the bits we need to perform
	// cancellation (cancel function among other things)
	ctx := context.Background()
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	// Use a waiting group construct so the main goroutine (main)
	// waits for the two goroutines to complete
	var wg sync.WaitGroup
	wg.Add(2)

	go func() {
		// always signal the wg
		defer wg.Done()
		// if fail enabled, use the ctx cancellation function so other goroutines
		// can be aware we have cancelled this one
		if fail {
			log.Println("One cancelled")
			cancel()
			return
		}
		// if no failure, wait for 2 seconds
		time.Sleep(2 * time.Second)
		log.Println("One done (2 sec)")
	}()

	go func() {
		defer wg.Done()
		// This goroutine blocks in the channel returned by ctx.Done() and if
		// we get a value, we tell the wg construct we are done. At that point
		// both goroutines have completed and the program completes
		go func() {
			<-ctx.Done()
			log.Println("cancelling two because the other failed")
			wg.Done()
		}()
		time.Sleep(4 * time.Second)
		log.Println("Two done (4 sec)")
	}()
	// block the main goroutine until our two goroutines complete
	wg.Wait()
	log.Println("All done")
}

If we now look at the example, we see we create first a couple of testing servers. Those are special type of servers that use the loopback interface for connections. They are ideal for testing. One of them is "slow" (takes a few seconds to complete) and the other one is fast but returns a different message depending on a error parameter in the request. If it is true it will return "error".

All the magic happens in the client.go file. There we have exactly the same logic as in our toy example but instead of sleeping, we call the callServer() function to make http requests against the servers.

In the callServer() function, we execute the request. The goroutine blocks until we get an answer from the server, or, alternatively the fast goroutine may have return an "error" instead "ok". If so, the code will execute the cancellation function. The other goroutine, somewhere in the http.Client logic (standard library) will have some code (goroutine) that reads on the channel returned by the ctx.Done() function. If it receives a value, it will stop processing the request and return. At that point both goroutines are completed and the program exists.