#Backend

Understanding Cancelation in Concurrent Systems: Three Distinct Concepts Developers Conflate

Marta Kowalska
Marta Kowalska
3 min read

A technical explainer clarifies the differences between synchronous cancelation, asynchronous cancelation, and graceful shutdown — three patterns that serve distinct purposes in concurrent software but often get confused.

Three cancelation patterns appear repeatedly in concurrent systems, yet developers frequently blur the lines between them. Confusing these patterns leads to bugs, hangs, and wasted CPU cycles.

The first is synchronous cancelation. This is a control flow structure that unwinds the stack immediately. When you call task.cancel(), the task finishes before the call returns. Error handling drives most synchronous cancelation in practice. Every thrown exception or returned error breaks out of loops and conditionals, triggering cleanup through RAII, finally blocks, or defer. Developers use this pattern constantly without always recognizing it as cancelation.

The second is asynchronous cancelation. This is a communication protocol between two parties. One party requests cancelation synchronously, then waits for acknowledgment. The call task.request_cancelation() returns immediately — the task may still be running. Only after task.join().await does the task finish.

Asynchronous cancelation appears whenever you need to stop work without corrupting state. Consider a CPU thread pool. You offload buffer encryption to a separate thread as part of handling a user request. When the request gets canceled — perhaps the user left — you cannot simply abandon the thread. The buffer must stay tied up until the work finishes, or another piece of code might reuse that memory, causing data races. But you also cannot cancel the thread synchronously if it sits in the middle of a hyper-optimized SIMD loop. The solution: split the buffer into chunks, check cancelation status after each chunk, and have the caller wait for at least one chunk of work.

The same pattern shows up in io_uring. Submit a write with a buffer to the kernel, and that buffer must remain tied up until the write completes. You can cancel the write to make it finish faster, but you still have to wait.

The third concept is graceful shutdown. This is an application-level pattern for handling connections, operating at a higher abstraction than the two cancelation types. A web service implements graceful shutdown by stopping its accept loop — rejecting new connections — while continuing to serve existing connections until clients disconnect. Combined with a load balancer that routes new requests to different instances, this pattern enables rolling upgrades without service disruptions.

The distinction matters in practice. At TigerBeetle, the team identifies these patterns explicitly. Grid.cancel is an asynchronous cancelation used during state sync. When a replica falls far enough behind that event-based transfer stops working and state transfer becomes necessary, it must cancel all outstanding grid read operations. Some reads hit local disk and must wait for completion. Remote reads, which likely triggered the state sync in the first place, get abandoned.

StateMachine.reset is a synchronous cancelation in the same flow. Rather than propagating asynchronous cancelation through every intermediate layer — Forest, Tree, Compaction, Scan — the code asynchronously cancels just the Grid and then synchronously resets everything else. This keeps the codebase simpler.

A related idea is crash-only software. If your program can survive an arbitrary SIGKILL from an OOM killer or a power loss, you can implement the Quit button by self-terminating with SIGKILL. This simplifies the implementation while increasing test coverage for powerloss scenarios.

Tail tolerance — querying several nodes and picking the fastest response — handles both crash faults and gray failures. A very slow node looks identical to a crashed one. A crash is just a degree of slowness.

Comments

Loading comments...