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
-
Completed The IoAwaitable Protocol
-
Understanding of how context propagates through coroutine chains
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
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
Part 3: The One-Shot Nature
|
Critical limitation:
|
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.
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.
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.