Programming Languages · Concurrency Models

Cooperative Cancellation and the Cancel-Safety Contract

Why asynchronous cancellation needs an explicit protocol, what cancel safety means for an operation interrupted at an await point, and where the guarantee is impossible to provide.

Stopping a thread from outside is easy to request and hard to do correctly. The requesting side has no way to know what the target was in the middle of, and the target has no way to refuse. Every design for cancellation is an attempt to make that asymmetry tolerable, and the designs differ mainly in how much they ask of the code being cancelled.

The problem is not delivering the signal. It is that the interrupted operation may hold a resource, may have partially applied a change, or may be waiting for a reply that will now never be read. Cancellation that ignores this produces leaks and inconsistent state that surface far from the cancellation site.

Three delivery styles

Asynchronous cancellation stops the target wherever it is. POSIX threads support this, and the standard distinguishes it from deferred cancellation precisely because of the damage. Under asynchronous cancellation a thread can be stopped between any two instructions, including inside a malloc or a library call that holds an internal lock; the lock stays held and every other thread that needs it blocks indefinitely. POSIX therefore permits asynchronous cancellation only for code that holds no resources, and makes deferred cancellation, honoured at designated points, the default.

Deferred cancellation checks a flag at points where the implementation knows it is safe to stop: blocking system calls, pthread_testcancel, and a small set of documented functions. Between those points the thread runs to completion of its current unit of work. This is cooperative in the sense that the cancellation is honoured only at the checkpoints, but it is not cooperative in the sense of requiring the code to poll; the runtime does the checking.

Interrupt-based cancellation, as in Java, sets a flag and additionally wakes the thread if it is blocked in an interruptible operation. Blocking calls that are interruptible throw an InterruptedException; blocking calls that are not interruptible, such as a read on a plain InputStream, leave the thread blocked with the flag set. The idiom is that the exception clears the flag and the catching code must either rethrow or restore it, which is a discipline the type system does not enforce.

Poll-based cancellation, as in most async runtimes, is the same idea with the check moved into the future’s poll. The task holds a cancellation token; the runtime sets it and wakes the task; the next poll sees the flag and returns a cancelled result, or the future is dropped outright. The difference from thread interruption is that the check happens at suspension points rather than at blocking calls, and there are far more of them.

Cancel safety is a property of an await point

An async operation is cancel-safe if dropping it at any await point leaves the system in a state consistent with not having run it. That is a strong property, and it is not the default.

Consider a frame read split into two awaits. The first read completes, having consumed the length prefix from the socket into a buffer owned by the enclosing future. The second read is still pending when the future is dropped, and the buffer goes with it. The prefix has left the socket and the payload never arrived, so the next attempt to read a frame begins in the middle of the previous one. This is not a leak; it is data loss, and it is silent.

// Not cancel-safe: the two reads are one logical frame.
let n = stream.read(&mut buf).await?;      // the length prefix is now in `buf`
let m = stream.read(&mut payload).await?;  // dropped here, the prefix is gone for good

The standard library and the major async libraries document which operations are cancel-safe and which are not, because the property cannot be inferred from the signature. A single read into a caller-owned buffer is cancel-safe in this sense: it either transfers bytes and completes, or transfers nothing and leaves the buffer untouched. Composition is what breaks the property. An operation that loops internally to fill a fixed range, an exact read of n bytes for instance, is documented as not cancel-safe, because a drop in the middle of the loop may already have consumed part of the requested range; the two-await frame read above fails the same test. An operation that either completes or has done nothing, such as accepting a connection or acquiring a semaphore permit, has the safe form.

The useful mental test is to ask what the operation would leave behind if it returned early: bytes consumed, a partial write, a lock acquired. If the answer is “nothing”, the operation is cancel-safe.

Select and the losers

The combinator that makes cancel safety urgent is the one that races several futures and takes the first to complete. The losers are dropped at arbitrary points, so every future in such a race must be cancel-safe, and this must hold for the composed operation, not just for its parts.

A concrete failure: racing a receive against a timeout, where the receive is implemented as “read a length prefix, then read that many bytes”. The first read completes, the second is pending, the timeout fires, and the future is dropped. The length prefix has been consumed and the payload has not, so the stream is now misaligned and every subsequent read parses garbage. The fix is to make the whole frame read a single cancel-safe operation, buffering the partial frame and resuming it later, or to avoid racing on a stream that cannot be reset.

The same issue appears in a different form when a select loop re-creates the future on each iteration. A future that is recreated discards its internal state, so a partially completed read restarts from the beginning, and if the underlying operation is a stream, it restarts from wherever the stream now is. Holding the future across iterations instead of recreating it preserves the state, at the cost of pinning it in place.

Where cancellation cannot be honoured

Some operations cannot be cancelled at all, and pretending otherwise produces worse bugs than admitting the limit.

A blocking system call that the platform does not interrupt will run to completion. A write to a file on a local filesystem is typically not interruptible, and the close that follows is not either. A DNS resolution may block for the resolver’s full timeout, which is measured in seconds.

A network send that has been accepted by the kernel is not retractable. The bytes are in flight; cancelling the application’s wait does not remove them, and the peer may receive a partial message. Protocols that need atomicity across cancellation must therefore have an application-level abort or a framing that can be resynchronised.

Cleanup itself is not cancellable in a useful sense. A destructor that must release a resource cannot wait for a network round trip, and if it does, cancellation latency becomes unbounded. This is why the structured-concurrency discipline of joining on scope exit is easier to implement than a general cancellation tree: the join gives cleanup a defined place to happen, in order, before the scope returns, as described in Structured Concurrency and the Task Scope.

Designing for it rather than around it

The practical rules that follow from the above are narrow.

Make cancellation a token that is passed explicitly, so that the set of operations which can observe it is visible in the signatures. A cancellation that arrives through a global or ambient channel is invisible at the call site and cannot be reasoned about locally.

Check the token at suspension points, not in loops. A loop that checks between iterations is too coarse: the iteration itself may take arbitrarily long, and the check delays cancellation by that amount.

Define the semantics of a cancelled operation. Either it did not happen, or it happened and is observable, or it partially happened and the partial state is documented. The middle option is the only one that composes, and it is why operations that mutate shared state are usually excluded from the set of cancel-safe ones.

Distinguish cancellation from failure in the error type. A cancelled operation is not a failed one, and code that retries on error will retry on cancellation if the two are conflated, which is the opposite of what was asked for.

None of this is enforced by a type system in current mainstream languages. Session types come closest, because a protocol can declare that a participant may abort at specific points and not others, which is the same distinction expressed in the type of the channel rather than in documentation; see Session Types and the Shape of a Channel Protocol. Until that is common, cancel safety remains a property established by inspection and by tests that drop futures at inconvenient moments.

References

  1. Rust Standard Library: std::future::Future — The Rust project
  2. Java Thread.interrupt API documentation — Oracle
  3. IEEE Std 1003.1-2017 (POSIX.1): Thread Cancellation — IEEE