Implementing Timeouts in C++26 std::execution: A Complete Guide to Cancellation and Races
With the adoption of P2300 (std::execution) into the C++26 working draft, C++ finally receives a standardized asynchronous foundation based on senders, receivers, and schedulers. However, transitioning from theoretical examples to real-world code often raises practical questions: How do we time out a long-running sender pipeline?
If you've searched online, you might have encountered hallucinated snippets using non-existent adapters like std::execution::when_any. Let’s explore what standard C++26 actually provides, how cooperative cancellation works under the hood, and how to properly implement timeouts in modern C++.
The Problem with when_any
In standard C++26 (P2300), the fundamental composition algorithm included is std::execution::when_all. There is currently no standardized std::execution::when_any in the core C++26 specification. While when_any (or race) is a frequently proposed extension—and is available in reference libraries such as NVIDIA's stdexec (sometimes named when_any in experimental branches)—it is not yet part of the standard C++ library namespace.
How Cancellation Works in std::execution
Before implementing a timeout, it is crucial to understand that C++ does not support asynchronous thread cancellation (i.e., preemptively killing an arbitrary thread or stack). Preemption is inherently unsafe because it bypasses destructors, mutex unlocks, and RAII invariants.
Instead, std::execution relies on cooperative cancellation via receiver environments and cancellation tokens (specifically, std::stop_token and std::stop_source introduced in C++20):
- A sender operation checks the stop token associated with its downstream receiver.
- When cancellation is triggered, the operation stops doing work cooperatively.
- The operation finishes by calling
set_stopped(receiver)rather thanset_value(receiver)orset_error(receiver, ...).
Approach 1: Using Stop Tokens with Timers
The standard way to interrupt an in-flight operation without exotic multi-sender race algorithms is by using a std::stop_source combined with a background timer (such as a timer queue or a separate thread).
Here is an idiomatic example using std::stop_source to cancel a task pipeline if it exceeds a deadline:
#include <chrono>
#include <iostream>
#include <stop_token>
#include <thread>
#include <stdexec/execution.hpp>
namespace exec = std::execution;
using namespace std::chrono_literals;
int main() {
std::stop_source stop_src;
// 1. Launch a timer that requests a stop after 2 seconds
std::jthread timer([token = stop_src.get_token(), &stop_src]() {
std::this_thread::sleep_for(2s);
stop_src.request_stop();
});
// 2. Define a task that respects stop tokens
auto work = exec::schedule(exec::inline_scheduler{})
| exec::then([token = stop_src.get_token()] {
std::cout << "Step 1 running...\n";
if (token.stop_requested()) return;
// Simulate heavy computation checking the token periodically
for (int i = 0; i < 5; ++i) {
if (token.stop_requested()) {
std::cout << "Cancelled during computation!\n";
return;
}
std::this_thread::sleep_for(1s);
}
std::cout << "Finished computation!\n";
});
// 3. Wait for execution
exec::sync_wait(std::move(work));
return 0;
}Approach 2: Race Adapters in Production Frameworks (e.g., exec::when_any)
If you use the prototype implementation stdexec, you have access to experimental race constructs. A race adapter connects multiple senders. When the first sender completes:
- It requests cancellation on the other pending child senders via their stop tokens.
- It waits for all other child operations to reach an exit point (either
set_value,set_error, orset_stopped) before completing itself.
Here is how a race condition behaves conceptually with a sleep timer sender:
#include <chrono>
#include <iostream>
#include <stdexec/execution.hpp>
// Assuming access to stdexec experimental extensions
#include <exec/when_any.hpp>
using namespace std::chrono_literals;
auto timeout(std::chrono::milliseconds dur) {
// In real systems, this wraps an event loop / OS timer (e.g. io_uring, epoll)
return /* timer sender completing after dur */;
}
int main() {
namespace ex = stdexec;
namespace xex = exec;
auto long_task = ex::just() | ex::then([] {
// Cooperative work
});
// Racing long_task against a 25-second timer
auto with_timeout = xex::when_any(
long_task,
timeout(25s)
);
ex::sync_wait(with_timeout);
}What Happens to In-Flight Senders in a Race?
A common misconception is that a timeout immediately terminates the CPU thread running the other operation. It does not. In sender/receiver pipelines:
- When the timeout sender fires, the racing adapter issues a stop request through the remaining senders' environments.
- If an in-flight sender contains blocking system calls or non-cooperative loops (e.g., raw
std::this_thread::sleep_forwithout a stop token), it will continue executing until it finishes. - The race adapter will wait until that operation finishes safely to avoid data corruption or resource leaks.
Best Practices for Writing Timeout-Resilient Pipelines
- Check the Environment Stop Token: If you write custom senders or algorithms, retrieve the stop token from the receiver environment via
exec::get_stop_token(exec::get_env(receiver)). - Avoid Indivisible Blocking Calls: Replace non-interruptible blocking calls with interruptible system primitives (such as Linux
io_uringor POSIXpoll()/select()with a stop descriptor). - Prefer Structured Concurrency: Rely on child sender destruction and cancellation notifications rather than manually tracking timestamps between every sub-step.