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: Implementing Stoppable Awaitables

The examples above show polling for cancellation with token.stop_requested(). For awaitables that suspend indefinitely—waiting for I/O, a lock, or an external event—you need a std::stop_callback to wake the coroutine when cancellation arrives.

The Dangerous Pattern

A std::stop_callback fires synchronously on whatever thread calls request_stop(). If the callback resumes the coroutine directly, the coroutine runs on the wrong thread:

// WRONG — causes use-after-free
std::optional<std::stop_callback<std::coroutine_handle<>>> stop_cb;

std::coroutine_handle<> await_suspend(
    std::coroutine_handle<> h, io_env const* env)
{
    stop_cb.emplace(env->stop_token, h);  // Resumes inline!
    return std::noop_coroutine();
}

When an external thread calls request_stop(), h.resume() executes the coroutine on that thread. The coroutine machinery sets the thread-local frame allocator to the executor’s allocator—poisoning the calling thread’s TLS. When the executor’s pool destructs, the TLS pointer becomes dangling. The next coroutine allocation on that thread dereferences freed memory.

This bug is deterministic, not a race condition. It manifests as a heap-use-after-free in unrelated code—wherever the next coroutine frame happens to be allocated on the poisoned thread.

The Correct Pattern: resume_via_post

Use stop_resume_callback and io_env::post_resume to post the resume through the executor, ensuring the coroutine runs on the correct thread:

#include <boost/capy/ex/io_env.hpp>

struct my_stoppable_awaitable
{
    std::optional<stop_resume_callback> stop_cb_;
    // ... other members for the async operation ...

    bool await_ready() { return false; }

    std::coroutine_handle<> await_suspend(
        std::coroutine_handle<> h, io_env const* env)
    {
        if (env->stop_token.stop_requested())
            return h;  // Already cancelled

        stop_cb_.emplace(env->stop_token, env->post_resume(h));

        start_async_operation(h, env);
        return std::noop_coroutine();
    }

    void await_resume() { /* check result or throw */ }
};

stop_resume_callback is a type alias for std::stop_callback<resume_via_post>. post_resume(h) creates a resume_via_post callable that posts the coroutine handle through this environment’s executor.

When request_stop() fires the callback, the coroutine handle is posted to the executor’s queue instead of resumed inline. The executor’s worker thread picks it up and resumes it in the correct execution context.

Capy’s built-in I/O awaitables (via Corosio) already use the post-back pattern internally. This guidance applies when writing your own custom awaitables.

Part 9: 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. Pass the token through run_async so it propagates automatically via the execution environment—the task accesses it with co_await this_coro::stop_token instead of receiving it as a function argument:

class download_manager
{
    executor_ref executor_;
    std::stop_source stop_source_;

public:
    void start_download(std::string url)
    {
        // Token propagated via io_env, not as a function argument
        run_async(executor_, stop_source_.get_token())(download(url));
    }

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

task<void> download(std::string url)
{
    auto token = co_await this_coro::stop_token;  // From run_async's io_env
    while (!token.stop_requested())
    {
        co_await fetch_next_chunk(url);
    }
}

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.