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 than set_value(receiver) or set_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:

  1. It requests cancellation on the other pending child senders via their stop tokens.
  2. It waits for all other child operations to reach an exit point (either set_value, set_error, or set_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_for without 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_uring or POSIX poll()/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.