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: 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.