Skip to content
C++
Language
since C++26
Expert

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++26

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

ApproachProblems
std::future / std::asyncBlocking destructors, no cancellation, no composition, hidden thread creation
Raw callbacksUnstructured lifetimes, error propagation by convention only
Coroutines aloneNo 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:

cpp
std::execution::set_value(receiver, values...);  // success
std::execution::set_error(receiver, error);       // failure (typically std::exception_ptr)
std::execution::set_stopped(receiver);            // cancellation

Namespace note: std::execution::par, seq, par_unseq — the C++17 parallel algorithm execution policies — share the std::execution namespace but are entirely unrelated to P2300's sender/receiver machinery. They cannot compose with schedule(), then(), or any P2300 algorithm.

Syntax

The mechanical substrate is connect → operation state → start. Pipe syntax is ergonomic sugar over it:

cpp
// 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 fires

sync_wait wraps this for top-level use, blocking the calling thread:

cpp
// 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 == 42

Core sender algorithms (all are customization point objects, CPOs):

cpp
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::variant

Header: P2300 targets <execution> in C++26. For use today, #include <stdexec/execution.hpp> from NVIDIA/stdexec is the reference implementation (namespace stdexec). Examples below use namespace ex = stdexec.

Examples

Basic pipeline:

cpp
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 threads

Error recovery with let_error:

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

cpp
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 concurrently

Scheduler transfer — CPU pool → I/O context → CPU pool:

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

cpp
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 threads

Coroutine integration (stdexec-specific; the standard interface for C++26 is still being finalized):

cpp
#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:

cpp
// 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:

cpp
// Accept any sender completing with int — prevents type-erasing with std::function
void process(ex::sender_of<int> auto s);  // C++26 concept constraint

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

cpp
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 destruction

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

cpp
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 here

Destroying 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