std::execution — Senders/Receivers (C++26)
"P2300 std::execution: structured async with senders, receivers, schedulers, and composable operation chains targeting C++26."
std::execution (P2300)since C++26A structured concurrency framework where lazy senders describe async operations, receivers handle their three completion channels (value, error, stopped), and schedulers abstract execution contexts — composable via pipe operators like the ranges library.
Overview
Before P2300, async C++ forced a choice between incompatible models:
| Approach | Problems |
|---|---|
std::future / std::async | Blocking destructors, no cancellation, no composition, hidden thread creation |
| Raw callbacks | Unstructured lifetimes, error propagation by convention only |
| Coroutines alone | No scheduler abstraction; who drives the event loop? |
| Executors (pre-P2300) | Never standardized; multiple incompatible proposals |
std::execution unifies these with three orthogonal abstractions:
- Scheduler — a lightweight handle to an execution context (thread pool,
io_uring, GPU stream).schedule(sched)returns a sender that, when started, places work on that context. - Sender — a description of async work, not the work itself. Lazy: nothing executes until a sender is connected to a receiver and started.
- Receiver — the sink for a sender's result. Handles exactly one of three exhaustive, mutually exclusive completion signals.
The three completion channels are the foundation of the model:
std::execution::set_value(receiver, values...); // success
std::execution::set_error(receiver, error); // failure (typically std::exception_ptr)
std::execution::set_stopped(receiver); // cancellationNamespace note:
std::execution::par,seq,par_unseq— the C++17 parallel algorithm execution policies — share thestd::executionnamespace but are entirely unrelated to P2300's sender/receiver machinery. They cannot compose withschedule(),then(), or any P2300 algorithm.
Syntax
The mechanical substrate is connect → operation state → start. Pipe syntax is ergonomic sugar over it:
// connect() wires a sender to a receiver, returning an operation_state object.
// The operation_state owns all resources for the operation's lifetime.
// start() initiates the async work — op must outlive the operation.
auto op = std::execution::connect(my_sender, my_receiver{});
std::execution::start(op); // op must not be destroyed until a completion signal firessync_wait wraps this for top-level use, blocking the calling thread:
// Returns std::optional<std::tuple<Ts...>>
// Returns nullopt if the sender completed via set_stopped
auto result = std::execution::sync_wait(
std::execution::schedule(sched)
| std::execution::then([] { return 42; })
);
auto [val] = result.value(); // val == 42Core sender algorithms (all are customization point objects, CPOs):
ex::just(1, 2.0f, "x") // completes immediately with given values
ex::just_error(ep) // immediately propagates error
ex::just_stopped() // immediately signals cancellation
ex::schedule(sched) // schedules a unit of work onto sched
ex::then(sender, fn) // map: fn(values...) → new value(s)
ex::let_value(sender, fn) // flatmap: fn(values...) → new sender (async chain)
ex::let_error(sender, fn) // error recovery: fn(error) → new sender
ex::let_stopped(sender, fn) // cancellation recovery: fn() → new sender
ex::upon_error(sender, fn) // fn(error) → value (not a sender)
ex::upon_stopped(sender, fn) // fn() → value
ex::transfer(sender, sched) // resume on a different scheduler
ex::when_all(senders...) // fan-out: completes when all succeed; cancels rest on failure
ex::bulk(sender, n, fn) // calls fn(i, values...) for i in [0,n), parallelisable
ex::split(sender) // multicast: share one sender's result across consumers
ex::stopped_as_optional(sender) // stopped → nullopt; value → optional<value>
ex::into_variant(sender) // collapse multiple value completions into std::variantHeader: P2300 targets
<execution>in C++26. For use today,#include <stdexec/execution.hpp>from NVIDIA/stdexec is the reference implementation (namespace stdexec). Examples below usenamespace ex = stdexec.
Examples
Basic pipeline:
ex::static_thread_pool pool{std::thread::hardware_concurrency()};
auto sched = pool.get_scheduler();
auto task =
ex::schedule(sched)
| ex::then([] {
return std::vector<int>{1, 2, 3, 4, 5};
})
| ex::then([](std::vector<int> v) {
return std::accumulate(v.begin(), v.end(), 0);
});
auto [result] = ex::sync_wait(std::move(task)).value();
// result == 15; both lambdas ran on pool threadsError recovery with let_error:
auto task =
ex::schedule(sched)
| ex::then([] -> std::string {
throw std::runtime_error("fetch failed");
return "data";
})
| ex::let_error([](std::exception_ptr) {
// fn returns a new sender — execution continues on success path
return ex::just(std::string{"fallback"});
})
| ex::then([](std::string s) { return s + " processed"; });
auto [val] = ex::sync_wait(std::move(task)).value();
// val == "fallback processed"Parallel fan-out with when_all:
auto squared = [&](int x) {
return ex::schedule(sched) | ex::then([x] { return x * x; });
};
auto [a, b, c] = ex::sync_wait(
ex::when_all(squared(2), squared(3), squared(5))
).value();
// a==4, b==9, c==25 — three tasks ran concurrentlyScheduler transfer — CPU pool → I/O context → CPU pool:
auto pipeline =
ex::schedule(cpu_pool.get_scheduler())
| ex::then([] { return build_http_request(); })
| ex::transfer(io_sched) // hand off to I/O scheduler
| ex::then([](HttpRequest req) {
return blocking_send_receive(req); // blocks I/O thread, not CPU pool
})
| ex::transfer(cpu_pool.get_scheduler()) // back to CPU pool
| ex::then([](HttpResponse resp) {
return parse_json(resp.body);
});Bulk parallel work:
std::vector<double> data(1'000'000);
auto task =
ex::schedule(sched)
| ex::bulk(data.size(), [&](std::size_t i) {
data[i] = std::sqrt(static_cast<double>(i));
});
ex::sync_wait(std::move(task));
// data[i] == sqrt(i) for all i, filled in parallel across pool threadsCoroutine integration (stdexec-specific; the standard interface for C++26 is still being finalized):
#include <exec/task.hpp> // stdexec coroutine support
exec::task<int> async_compute(ex::scheduler auto sched) {
// co_await suspends and resumes on sched's context
int raw = co_await (
ex::schedule(sched)
| ex::then([] { return expensive_compute(); })
);
co_return raw * 2;
}
auto [result] = ex::sync_wait(async_compute(pool.get_scheduler())).value();Best Practices
Use let_value for async flatmap, not then. When a computation itself yields a sender (e.g., an async I/O call), then wraps it in another sender layer. let_value flattens it:
// Wrong — produces sender<sender<Response>>, not sender<Response>
auto bad = ex::schedule(sched) | ex::then([&] { return async_read(fd); });
// Correct — let_value flattens the returned sender
auto good = ex::schedule(sched) | ex::let_value([&] { return async_read(fd); });Constrain functions accepting senders with concepts:
// Accept any sender completing with int — prevents type-erasing with std::function
void process(ex::sender_of<int> auto s); // C++26 concept constraintUse counting_scope for dynamic task spawning. start_detached provides no way to await completion or propagate errors. P3149's counting_scope (available in stdexec as exec::counting_scope, targeting a future standard) is the structured alternative:
exec::counting_scope scope;
scope.spawn(some_work_sender | ex::on(sched));
// ... spawn more tasks ...
co_await scope.join(); // wait for all spawned work before scope destructionSignal cancellation through set_stopped, not exceptions. Throwing an exception for cancellation contaminates the error channel, forcing every error handler to distinguish "real failure" from "cancelled." Use stop tokens to cooperatively check stop_requested() and let the framework route through set_stopped.
Common Pitfalls
Senders are lazy — construction is not execution:
auto s = ex::schedule(sched) | ex::then([] { do_work(); });
// do_work() has NOT been called. s is a description.
ex::sync_wait(std::move(s)); // do_work() runs hereDestroying the operation state before completion is UB. The object returned by connect() must remain alive until one completion signal (set_value, set_error, or set_stopped) has been delivered to the receiver. This is an explicit lifetime contract — not enforced by the type system.
sync_wait inside an async chain deadlocks. sync_wait is for program entry points only. Calling it from within a sender's work blocks the thread serving the scheduler, which starves single-threaded contexts and inverts the scheduler hierarchy in thread pools.
when_all cancels on first error. If any child sender fails or is cancelled, when_all propagates cancellation to the others and delivers the first error. Partially-completed sibling results are discarded. Use when_all_with_variant or into_variant when partial results matter.
Ignoring that when_all propagates stopped. If the enclosing scope's stop token is requested while when_all is running, the stopped signal propagates inward — all children are cancelled and the outer sender completes via set_stopped, not set_value. Callers that only match the value channel will miss this case.
See Also
std::stop_token— cancellation primitive wired into the stopped channelstd::coroutines— coroutine primitives; senders integrate as awaitablesstd::jthread— C++20 thread with built-instop_source- P2300R10 — full specification adopted into C++26 working draft
- P3149 —
counting_scope— structured dynamic task spawning