Timo Savola

2017-09-06

Context cancellation acknowledgement

Cancellation

I was excited when I first learned about the context package for Go. It attempted to solve one of the biggest deficiencies of Go (in my opinion): opaque APIs without a way to shut an operation down in an orderly manner. Even as I was missing cancellation support while writing server code, I actually felt that it was probably more of a problem for e.g. desktop applications. Long ago, I had a conversation with Aral Balkan about Pulse (Syncthing), and he explained that Go isn't all that helpful if you want to restart an application. Context helped me understand what he was talking about.

Lately there has been some pushback against Context. I got angry (read: afraid) when I saw the titles of Context should go away for Go 2 and Context isn’t for cancellation, but their contents make sense. While I don't see as many problems with it as they do, I've myself criticized it (just a bit) in my talk. What I care about is that cancellation support doesn't go away, but flourishes in one form or another.

Acknowledgement

Dave Cheney mentions the lack of cancellation acknowledgement support in his aforementioned blog entry. Incidentally, I had lately struggled with that, so it gave me pause. I wanted to see if it could be retrofitted on the current Context API.

Cancellation request with acknowledgement signaling is effectively a synchronous operation. That has an interesting (helpful) property which the usual asynchronous cancellation doesn't have: the acknowledgement signal listeners know exactly which operation(s) they want to hear from. The program logic which cannot proceed before an operation has been fully canceled is certainly somehow coupled with the invocation of the operation in the first place.

Luckily Context is such a bloated interface, with Values and all; my contextack package leverages them for registering acknowledgement channels. The idea is that the implementation of a cancellable operation is accompanied with a context value key (exported with the public API). Client code which needs the acknowledgement acquires a signal channel by calling WithAck(ctx, key) before the operation. It can then use the channel whenever it needs to know when the operation has been fully terminated. Overly simple example:

import "context"
import "github.com/tsavola/contextack"
import "github.com/tsavola/contextack/example"

func DoStuff(ctx context.Context) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	ctx, ack := contextack.WithAck(ctx, example.WaitDoneAck)
	go example.Wait(ctx)

	cancel()
	<-ack
}

The implementation of the operation simply calls Ack(ctx, key) when it exits. It should be noted that if the operation is not invoked for some reason, it should be acknowledged anyway—otherwise the listeners would hang. Example package:

import "context"
import "github.com/tsavola/contextack"

type ackKey int

var WaitDoneAck ackKey // Represents the termination of the Wait operation.

// Wait until ctx is cancelled, and acknowledge that it was respected.
func Wait(ctx context.Context) error {
	defer contextack.Ack(ctx, WaitDoneAck)
	<-ctx.Done()
	return ctx.Err()
}

An example use case for this is an architecture where parallel operations access a shared resource, and that resource cannot be shut down before the operations no longer access it. The Go way of doing something like that would be to use separate channels for each operation and copy data to/from the shared resource's channel. So, I guess the cancellation-acknowledgement approach could be said to be somewhat un-gooey...