std::execution — Senders & Receivers (C++26)
P2300 Senders/Receivers: structured, composable async that decouples work from execution context — portable parallelism across thread pools, GPU queues, and event loops without callbacks.
std::execution (P2300)since C++26std::execution provides a Senders/Receivers framework: senders describe asynchronous work as lazy, composable values; receivers are typed callbacks for value/error/stop outcomes; schedulers represent execution contexts. Together they enable structured, cancellable concurrency that is completely decoupled from the underlying threading model.
Overview
Every previous C++ async primitive has a structural flaw:
| Primitive | Problem |
|---|---|
| Callbacks | Inversion of control; composability requires nesting ("callback hell") |
std::future | Eager — work starts immediately; no control over execution context; chaining copies/moves the shared state |
| Coroutines | Great for sequential async, but composing parallel work needs a framework on top |
| Raw threads | No cancellation, no structured lifetime, scheduler coupling |
P2300 solves these structurally. A sender is a description of work — it hasn't started yet, carries no shared state, and costs nothing to create. A receiver is the typed callback that accepts the three possible outcomes: set_value, set_error, set_stopped. Connecting them creates an operation state that owns all resources for the duration of the operation. This is the foundation of structured concurrency.
Scheduler → schedule() → Sender → | then() |
| let_value() | → connected to Receiver → operation.start()
| when_all() |Core Abstractions
| Concept | What it represents |
|---|---|
sender | Lazy description of work; produces values, errors, or a stop signal |
receiver | Typed callback: must handle set_value, set_error, set_stopped |
scheduler | Factory for senders tied to an execution context (thread pool, event loop, GPU queue) |
operation_state | The live, started async operation; owns all resources; non-copyable, non-movable |
sender_of<T> | Constrains a sender to always produce a value of type T |
Hello World
#include <execution>
#include <print>
namespace ex = std::execution;
int main() {
// just() creates a sender that immediately provides a value
auto snd = ex::just(42)
| ex::then([](int x) { return x * 2; })
| ex::then([](int x) { std::println("result: {}", x); });
// sync_wait drives the sender on the current thread, blocking until done
ex::sync_wait(std::move(snd)); // prints: result: 84
}The key insight: nothing executes until sync_wait (or start_detached, or another consumer) is called. The pipeline is a value — it can be stored, returned from functions, or passed to other algorithms.
Schedulers and Execution Contexts
A scheduler abstracts over where work runs. schedule(sched) returns a sender that, when started, completes on that scheduler's execution context.
#include <execution>
#include <thread_pool> // implementation-defined; e.g. std::static_thread_pool
namespace ex = std::execution;
int main() {
std::static_thread_pool pool{4}; // 4-thread pool
auto sched = pool.get_scheduler();
auto result = ex::sync_wait(
ex::schedule(sched) // start on pool
| ex::then([]{ return compute_heavy_thing(); })
| ex::then([](int v){ return v + 1; })
);
// result is std::optional<std::tuple<int>>
if (result) std::println("got: {}", std::get<0>(*result));
}ex::on(scheduler, sender) transitions an existing sender chain onto a different scheduler:
// Run work described by `snd` on `sched`, then return to the calling context
auto snd = ex::on(sched, ex::just(42) | ex::then(heavy_fn));Composing Pipelines with |
All std::execution algorithms support the pipe operator — the same left-to-right composition as std::ranges:
auto pipeline =
ex::just(std::vector<int>{1, 2, 3, 4, 5})
| ex::then([](auto v){ std::ranges::sort(v); return v; })
| ex::then([](auto v){ return v.back(); }) // largest element
| ex::then([](int n){ std::println("max: {}", n); });
ex::sync_wait(std::move(pipeline));Key Algorithms
| Algorithm | Signature | Description |
|---|---|---|
just(vs...) | → sender_of<Vs...> | Immediately provide values |
just_error(e) | → sender | Immediately signal an error |
just_stopped() | → sender | Immediately signal stop |
then(snd, fn) | → sender | Transform the value with fn |
upon_error(snd, fn) | → sender | Handle errors; fn returns a new value |
upon_stopped(snd, fn) | → sender | Handle stop signal |
let_value(snd, fn) | → sender | fn returns a sender — enables dependent async chains |
let_error(snd, fn) | → sender | Error recovery via a new sender |
on(sched, snd) | → sender | Run snd on sched |
schedule(sched) | → sender | Start a chain on sched |
when_all(snds...) | → sender | Wait for all; tuple of values |
when_any(snds...) | → sender | Complete on first success; cancel rest |
bulk(snd, n, fn) | → sender | Parallel for i in [0,n) with fn(i, values...) |
into_variant(snd) | → sender | Normalize variant value types |
sync_wait(snd) | → optional<tuple<Vs...>> | Block current thread; drive the sender |
start_detached(snd) | → void | Fire-and-forget; no result |
Structured Concurrency with when_all
when_all waits for all sub-senders to complete before forwarding their values as a tuple. If any fails or stops, the others receive a stop request.
namespace ex = std::execution;
auto fetch_user = ex::on(io_sched, fetch_from_db(user_id));
auto fetch_prefs = ex::on(io_sched, fetch_from_db(prefs_id));
auto fetch_cache = ex::on(cache_sched, read_cache(user_id));
auto combined = ex::when_all(
std::move(fetch_user),
std::move(fetch_prefs),
std::move(fetch_cache)
) | ex::then([](User u, Prefs p, Cache c) {
return merge(u, p, c);
});
auto result = ex::sync_wait(std::move(combined));All three fetches run concurrently. If fetch_user throws, the stop token is signalled to the other two, and combined fails with that error.
Dependent Chains with let_value
then transforms a value synchronously. When the next step is itself async (returns a sender), use let_value:
auto pipeline =
ex::just(user_id)
| ex::let_value([&](int id) {
// Returns a sender — the outer pipeline waits for this
return ex::on(db_sched, query_user(id));
})
| ex::let_value([&](User u) {
return ex::on(db_sched, query_permissions(u.role));
})
| ex::then([](Permissions p) {
return p.can_write;
});This is the structured alternative to .then() chaining on std::future — no shared state, no heap allocation for the future object, and full cancellation support.
Error Handling
Errors flow through the sender chain as typed errors (not exceptions by default). Use upon_error or let_error to recover:
auto safe_pipeline =
ex::just(risky_input)
| ex::then(parse_int) // may set_error with std::errc
| ex::upon_error([](std::errc e) {
std::println("parse failed: {}", std::make_error_code(e).message());
return 0; // recover with default value
})
| ex::then(process);If a sender calls set_error with an exception, sync_wait rethrows it. All other error types come back in the optional return value being empty, or you handle them in the pipeline.
Cancellation with Stop Tokens
Every receiver carries a stop token — a lightweight handle to a stop source. Algorithms like when_all use this to cancel sibling operations when one fails.
auto cancellable =
ex::schedule(sched)
| ex::then([](ex::stop_token st) {
// Check for stop inside long-running work
while (!st.stop_requested()) {
do_chunk_of_work();
}
});just_stopped() creates a sender that immediately signals stop — useful for testing stop propagation.
bulk for Data Parallelism
bulk is the structured parallel-for primitive. It runs fn(index, value...) for each index [0, n), on the scheduler's execution context:
auto parallel_sum =
ex::just(std::span<const double>{data})
| ex::bulk(data.size(), [](std::size_t i, std::span<const double> d) {
// runs in parallel; no shared writes here
partial_sums[i] = d[i] * d[i];
})
| ex::then([](std::span<const double>) {
return std::ranges::fold_left(partial_sums, 0.0, std::plus{});
});Implementing a Custom Receiver
Understanding the receiver contract clarifies why the framework composes:
// A receiver that prints the result
struct PrintReceiver {
// Called on success
friend void tag_invoke(ex::set_value_t, PrintReceiver&&, int value) {
std::println("value: {}", value);
}
// Called on error (std::exception_ptr by convention)
friend void tag_invoke(ex::set_error_t, PrintReceiver&&, std::exception_ptr ep) {
try { std::rethrow_exception(ep); }
catch (const std::exception& e) { std::println("error: {}", e.what()); }
}
// Called on cancellation
friend void tag_invoke(ex::set_stopped_t, PrintReceiver&&) {
std::println("stopped");
}
};
// Connect and start manually (normally done by sync_wait or start_detached)
auto op = ex::connect(ex::just(42), PrintReceiver{});
ex::start(op); // prints: value: 42The tag_invoke dispatch is how P2300 enables open extension without virtual dispatch.
Comparison with std::future and Coroutines
std::future | Coroutines (co_await) | std::execution | |
|---|---|---|---|
| Lazy | No — starts immediately | Yes (stackless resumption) | Yes — no work until start() |
| Composable | Awkward (.then() allocates) | Yes, sequentially | Yes, including parallel |
| Structured concurrency | No | With frameworks (e.g. std::generator) | Built-in (when_all) |
| Cancellation | No | Via stop tokens + framework | Built-in via stop tokens |
| Scheduler control | No | Partially (awaiter controls) | Full — scheduler is explicit |
| Overhead | Heap allocation per future | Frame allocation per coroutine | Zero-allocation when chained |
| Error model | exception_ptr only | exception_ptr or expected | Typed: any error type |
Compiler and Library Support
P2300 was accepted for C++26. Implementations as of 2026:
| Implementation | Status |
|---|---|
| stdexec (NVIDIA) | Production-ready reference implementation; most complete |
| libunifex (Facebook/Meta) | Earlier prototype; predates P2300 final syntax |
| GCC 15+ | Partial, <execution> under development |
| Clang 20+ | Partial, follow stdexec for now |
| MSVC | Under development |
Use the stdexec library (available on GitHub and vcpkg as stdexec) to target C++23 codebases today — it provides std::execution semantics and will map directly to the standard once compilers ship it.
# vcpkg
find_package(stdexec CONFIG REQUIRED)
target_link_libraries(my_target PRIVATE stdexec::stdexec)// With stdexec, namespace is stdexec:: not std::execution::
// but usage is identical
#include <stdexec/execution.hpp>
namespace ex = stdexec;Key Rules
- Senders are lazy: connecting or piping them creates no threads and starts no work
- Each sender may only be connected once — after
connect, ownership transfers to the operation state sync_waitreturnsstd::optional<std::tuple<Vs...>>— empty if the sender sent a stop signal; throws if it sent anexception_ptr- Operation states must not be moved or copied after
start()— their address is part of the protocol - Stop tokens flow downward through the chain; senders should poll
stop_token::stop_requested()at yield points when_allcancels siblings on the first error;when_anycancels siblings on the first success- Prefer
let_valueoverthenwhen the transformation is itself async; usethenfor cheap synchronous transforms