Stop Tokens and Cancellation

This section teaches cooperative cancellation from the ground up, explaining C++20 stop tokens as a general-purpose notification mechanism and how Capy uses them for coroutine cancellation.

Prerequisites

Part 1: The Problem

Cancellation matters in many scenarios:

  • A user clicks "Cancel" on a download dialog

  • A timeout expires while waiting for a network response

  • A connection drops unexpectedly

  • An application is shutting down

The Naive Approach: Boolean Flags

The obvious solution seems to be a boolean flag:

std::atomic<bool> should_cancel{false};

void worker()
{
    while (!should_cancel)
    {
        do_work();
    }
}

This approach has problems:

  • No standardization — Every component invents its own cancellation flag

  • Race conditions — Checking the flag and acting on it is not atomic

  • No cleanup notification — The worker just stops; no opportunity for graceful cleanup

  • Polling overhead — Must check the flag repeatedly

The Thread Interruption Problem

Some systems support forceful thread interruption. This is dangerous because it can leave resources in inconsistent states—files half-written, locks held, transactions uncommitted.

The Goal: Cooperative Cancellation

The solution is cooperative cancellation: ask nicely, let the work clean up. The cancellation requestor signals intent; the worker decides when and how to respond.

Part 2: C++20 Stop Tokens—A General-Purpose Signaling Mechanism

C++20 introduces std::stop_token, std::stop_source, and std::stop_callback. While named for "stopping," these implement a general-purpose Observer pattern—a thread-safe one-to-many notification system.

The Three Components

std::stop_source

The Subject/Publisher. Owns the shared state and can trigger notifications. Create one source, then distribute tokens to observers.

std::stop_token

The Subscriber View. A read-only, copyable, cheap-to-pass-around handle. Multiple tokens can share the same underlying state.

std::stop_callback<F>

The Observer Registration. An RAII object that registers a callback to run when signaled. Destruction automatically unregisters.

How They Work Together

#include <stop_token>
#include <iostream>

void example()
{
    std::stop_source source;

    // Create tokens (distribute notification capability)
    std::stop_token token1 = source.get_token();
    std::stop_token token2 = source.get_token();  // Same underlying state

    // Register callbacks (observers)
    std::stop_callback cb1(token1, []{ std::cout << "Observer 1 notified\n"; });
    std::stop_callback cb2(token2, []{ std::cout << "Observer 2 notified\n"; });

    std::cout << "Before signal\n";
    source.request_stop();  // Triggers all callbacks
    std::cout << "After signal\n";
}

Output:

Before signal
Observer 1 notified
Observer 2 notified
After signal

Immediate Invocation

If a callback is registered after request_stop() was already called, the callback runs immediately in the constructor:

std::stop_source source;
source.request_stop();  // Already signaled

// Callback runs in constructor, not later
std::stop_callback cb(source.get_token(), []{
    std::cout << "Runs immediately!\n";
});

This ensures observers never miss the signal, regardless of registration timing.

Type-Erased Polymorphic Observers

Each stop_callback<F> stores a different callable type F. Despite this, all callbacks for a given source can be invoked uniformly. This is equivalent to having vector<function<void()>> but with:

  • No heap allocation per callback

  • No virtual function overhead

  • RAII lifetime management

Thread Safety

Registration and invocation are thread-safe. You can register callbacks, request stop, and invoke callbacks from any thread without additional synchronization.

Part 3: The One-Shot Nature

Critical limitation: stop_token is a one-shot mechanism.

  • Can only transition from "not signaled" to "signaled" once

  • No reset mechanism—once stop_requested() returns true, it stays true forever

  • request_stop() returns true only on the first successful call

  • You cannot "un-cancel" a stop_source

Why This Matters

If you design a system that needs to cancel and restart operations, you cannot reuse the same stop_source. Each cycle requires a fresh source and fresh tokens.

The Reset Workaround

To "reset," create an entirely new stop_source:

std::stop_source source;
auto token = source.get_token();

// ... distribute token to workers ...

source.request_stop();  // Triggered, now permanently signaled

// To "reset": create new source
source = std::stop_source{};  // New shared state
// Old tokens are now orphaned (stop_possible() returns false)

// Must redistribute new tokens to ALL holders of the old token
auto new_token = source.get_token();

This is manual and error-prone. Any code still holding the old token will not receive new signals.

Design Implication

If you need repeatable signals, stop_token is the wrong tool. Consider:

  • Condition variables for repeatable wake-ups

  • Atomic flags with explicit reset protocol

  • Custom event types

Part 4: Beyond Cancellation

The "stop" naming obscures the mechanism’s generality. stop_token implements one-shot broadcast notification, useful for:

  • Starting things — Signal "ready" to trigger initialization

  • Configuration loaded — Notify components when config is available

  • Resource availability — Signal when database connected or cache warmed

  • Any one-shot broadcast scenario

Part 5: Stop Tokens in Coroutines

Coroutines have a propagation problem: how does a nested coroutine know to stop? If you pass a stop token explicitly to every function, your APIs become cluttered.

Capy’s Answer: Automatic Propagation

Capy propagates stop tokens downward through co_await. When you await a task, the IoAwaitable protocol passes the current stop token to the child:

task<> parent()
{
    // Our stop token is automatically passed to child
    co_await child();
}

task<> child()
{
    // Receives parent's stop token via IoAwaitable protocol
    auto token = co_await get_stop_token();  // Access current token
}

No manual threading—the protocol handles it.

Accessing the Stop Token

Inside a task, use get_stop_token() to access the current stop token:

task<> cancellable_work()
{
    auto token = co_await get_stop_token();

    while (!token.stop_requested())
    {
        co_await do_chunk_of_work();
    }
}

Part 6: Responding to Cancellation

Checking the Token

task<> process_items(std::vector<Item> const& items)
{
    auto token = co_await get_stop_token();

    for (auto const& item : items)
    {
        if (token.stop_requested())
            co_return;  // Exit early

        co_await process(item);
    }
}

Cleanup with RAII

RAII ensures resources are released on early exit:

task<> with_resource()
{
    auto resource = acquire_resource();  // RAII wrapper
    auto token = co_await get_stop_token();

    while (!token.stop_requested())
    {
        co_await use_resource(resource);
    }
    // resource destructor runs regardless of how we exit
}

The operation_aborted Convention

When cancellation causes an operation to fail, the conventional error code is error::operation_aborted:

task<std::string> fetch_with_cancel()
{
    auto token = co_await get_stop_token();

    if (token.stop_requested())
    {
        throw std::system_error(
            make_error_code(std::errc::operation_canceled));
    }

    co_return co_await do_fetch();
}

Part 7: OS Integration

Capy’s I/O operations (provided by Corosio) respect stop tokens at the OS level:

  • IOCP (Windows) — Pending operations can be cancelled via CancelIoEx

  • io_uring (Linux) — Operations can be cancelled via IORING_OP_ASYNC_CANCEL

When you request stop, pending I/O operations are cancelled at the OS level, providing immediate response rather than waiting for the operation to complete naturally.

Part 8: Patterns

Timeout Pattern

Combine a timer with stop token to implement timeouts:

task<> with_timeout(task<> operation, std::chrono::seconds timeout)
{
    std::stop_source source;

    // Timer that requests stop after timeout
    auto timer = co_await start_timer(timeout, [&source] {
        source.request_stop();
    });

    // Run operation with our stop token
    co_await run_with_token(source.get_token(), std::move(operation));
}

User Cancellation

Connect UI cancellation to stop tokens:

class download_manager
{
    std::stop_source stop_source_;

public:
    void start_download(std::string url)
    {
        run_async(executor_)(download(url, stop_source_.get_token()));
    }

    void cancel()
    {
        stop_source_.request_stop();
    }
};

Graceful Shutdown

Cancel all pending work during shutdown:

class server
{
    std::stop_source shutdown_source_;

public:
    void shutdown()
    {
        shutdown_source_.request_stop();
        // All pending operations receive stop request
    }

    task<> handle_connection(connection conn)
    {
        auto token = shutdown_source_.get_token();

        while (!token.stop_requested())
        {
            co_await process_request(conn);
        }

        // Graceful cleanup
        co_await send_goodbye(conn);
    }
};

when_any Cancellation

when_any uses stop tokens internally to cancel "losing" tasks when the first task completes. This is covered in Concurrent Composition.

Reference

The stop token mechanism is part of the C++ standard library:

#include <stop_token>

Key types:

  • std::stop_source — Creates and manages stop state

  • std::stop_token — Observes stop state

  • std::stop_callback<F> — Registers callbacks for stop notification

You have now learned how stop tokens provide cooperative cancellation for coroutines. In the next section, you will learn about concurrent composition with when_all and when_any.