Introduction

The context package in Go provides a mechanism to prevent the application from doing unnecessary work, hence preserving resources. The context in go it used for following purposes:

  • Carry deadline information: e.g. cancel the current task after a given timestamp.
  • Carry cancellation information: e.g. when a user has exited the website, stop any operations that were initiated.
  • Carry request-scoped values: e.g. adding request-id at the entry point of your request and retrieving it at the database access layer.

In this post, we will discuss how to use the deadline and cancellation features to simplify use cases like:

  • Cancel task or batch of tasks based on a condition.
  • Finish or gracefully exit a task within a specified duration.
  • Finish or gracefully exit a task within a given deadline.

The Context interface is defined as below. The first three methods in the interface provide everything we need to perform efficient context cancellations

1
2
3
4
5
6
type Context interface {
  Deadline()(deadline time.Time, ok bool)  // time when the ctx will cancel
  Done() <-chan struct{}                   // channel to track ctx cancellation
  Err() error                              // reason for ctx cancellation
  Value(key interface{}) interface{}       // to pass request-scoped values
}

Context Tree

To understand cancellations better, we should first understand the context tree in the Go application. Most practical applications will use a derived context that is an n-th child of the parent context. These derived contexts will form a context tree that is cancellable.

To create a context tree, let us look at the below examples. Here, we first derive the contexts from the parent context and use it to call the longRunningOperation. Our final goal is to stop the longRunningOperation when an error happens or the client disconnects.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main(){  
	ctx := context.Background()                     // parent context	
	levelOneCtx, _ := context.WithCancel(ctx)       // first level context
	longRunningOperation(levelOneCtx)
}

func handleRequest(w http.ResponseWriter, req *http.Request) {
	ctx := req.Context()                        // parent context from req
	levelOneCtx, _ := context.WithCancel(ctx)         // 1st level context 
	levelTwoCtx, _ := context.WithCancel(levelOneCtx) // 2nd level context
	longRunningOperation(levelTwoCtx)
}

Mechanics

The cancellation of context happens in 2 steps. First the caller emits a cancellation signal based on a a condition (for example an error). The callee of the function then receives this signal to abort the current running operation.

Emitting the cancellation event.

The context.WithCancel(), context.WithTimeout() and context.WithDeadline() functions return a derived context with a second argument of the type context.CancelFunc, which is a function. You can call the CancelFunc from the caller to emit a cancellation signal.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func main(){  
	ctx := context.Background()	
	ctx, stop := context.WithCancel(ctx)
	// OR ctx, stop := context.WithTimeout(ctx, time.Second*3)
	// OR ctx, stop := context.WithDeadline(ctx, time.Now().Add(time.Second*5))

	// use the context to do some work in the background
	go doSomething(ctx) 

	// other operation that results in a error
	if err != nil {
		stop() // send the cancellation signal to all functions using ctx
	}
}

Listening to the cancellation.

The context provides ctx.Done() method that returns <-chan struct{}. We can listen to a close event on this channel to identify if the context is cancelled. Additionally, querying the ctx.Err() after the ctx.Done() is closed, will provide us the cancellation reason.

If you are dealing with the goroutines, then you can listen to the ctx.Done() channel in the callee function and abort. For sequential functions, you can just check for a not-nil value on ctx.Err() and abort when the err occurs

1
2
3
4
5
6
7
8
9
func doSomething(ctx context.Context) error {
	for {
		// do something
		select {
		case <-ctx.Done(): 		// closes when the caller cancels the ctx
	  		return ctx.Err() 	// has a value on context cancellation
		}
	}
}

In the next section, we will look at some examples

Example : Simple context cancellation

The code below demonstrates an example where we initiate some work in a goroutine. In our case, the work() function reads a file “example.txt” and does an arbitrary operation that takes around 100ms, simulated by time.Sleep(time.Millisecond*100) in the work() function. Our main function performs some more operations and encounters an error scenario. When the error occurs, we call the stop() CancelFunc, to abort any operations that the work function was performing.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
func main() {
	ctx, stop := context.WithCancel(context.Background())
	defer stop()
	
	go func() { // run the work in the background
		if err := work(ctx, "./example.txt"); err != nil {
			log.Println(err)
		}
	}()

	// perform some operation and that causes error
	time.Sleep(time.Millisecond * 150)
	if true { // err != nil
		stop()
	}
	time.Sleep(time.Second)
}

func work(ctx context.Context, filename string) error {
	if ctx.Err() != nil {
		return ctx.Err()
	}

	file, err := os.Open(filename)
	if err != nil {
		return err
	}
	defer file.Close()

	rd := bufio.NewReader(file)
	for {
		line, err := rd.ReadString('\n')
		if err != nil {
			if err == io.EOF {
				break
			}
			return err
		}
		time.Sleep(time.Millisecond * 100)
		log.Print(line) // do something with the line
		if ctx.Err() != nil {
			return ctx.Err()
		}
	}
	return nil
}

The work function is very straightforward. At the start of the function, we test if the context was canceled by checking the ctx.Err()value. If it is not nil, then we exit. Furthermore, while processing the file line by line, we again check for the same condition and return if the error is not nil. This way, we can abort our work() function, if the caller function cancels the context. Note that the error in this case is context canceled that indicates explicit cancellation.

Example : Simple timeout cancellation

There are several scenarios where we want to abort the operation when it takes more than a specified duration. In such a scenario, we use a context with a timeout duration. In this case, the context will implicitly cancel after the specified interval.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func main() {
	// create a parent context
	ctx := context.Background()

	// create a context with timeout of 2 seconds
	timeout, stop := context.WithTimeout(ctx, time.Second*2)
	defer stop()

	if err := longRunningOperation(timeout); err != nil {
		log.Println(err)
	}
}

func longRunningOperation(ctx context.Context) error {
	if ctx.Err() != nil {
		return ctx.Err()
	}
	for i := 0; i < 1000; i++ {
		time.Sleep(time.Millisecond * 20)
		if ctx.Err() != nil {
			return ctx.Err()
		}
	}
	log.Println("done")
	return nil
}

Note that the error in this case is context deadline exceeded that indicating that the longRunningOperation took more time than what was expected.

Example : Simple deadline cancellation

Another common scenario is to abort a task when it runs beyond a given timestamp. In such a scenario, we use a context with a deadline using context.WithDeadline() function. This function takes a timestamp as an argument and cancels when the execution continues beyond the provided timestamp.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func main() {
	// create a parent context or get it from http.Request
	ctx := context.Background()

	// the second argument is of type time.Time
	timeout, stop := context.WithDeadline(ctx, time.Now().Add(time.Second*2))
	defer stop()

	// call the longRunningOperation
	if err := longRunningOperation(timeout); err != nil {
		log.Println(err)
	}
}

func longRunningOperation(ctx context.Context) error {
	if ctx.Err() != nil {
		return ctx.Err()
	}
	for i := 0; i < 1000; i++ {
		time.Sleep(time.Millisecond * 20)
		if ctx.Err() != nil {
			return ctx.Err()
		}
	}
	log.Println("done")
	return nil
}

In this case, the error is again context deadline exceeded. It indicates that the function execution continued beyond provided timestamp.

Gotchas

  • Context cancellations do not kill goroutines automatically, they only propagate signals to implement graceful cancellations. The logic to abort the goroutine still needs to be implemented by the developer.
  • Context can be cancelled only once.
  • Passing an already cancelled context may cause unwanted results.
  • Same context should be passed to all the functions and goroutines from parent.
  • The heavy job in the longRunningOperation should be modified in such a way that it possible to check ctx.Err() value after regular interval. This might cause problems in implementing cancellations in certain type of jobs.

Conclusion

In this blog post, we discussed how cancellation happens in Go. All the examples and the corresponding code is available here. Readers are encouraged to clone the repository and try the examples. In case you have questions, please feel free to add a comment on this repository here.

References