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

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

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

PrimitiveProblem
CallbacksInversion of control; composability requires nesting ("callback hell")
std::futureEager — work starts immediately; no control over execution context; chaining copies/moves the shared state
CoroutinesGreat for sequential async, but composing parallel work needs a framework on top
Raw threadsNo 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.

cpp
Scheduler → schedule() → Sender → | then()    |
                                   | let_value() | → connected to Receiver → operation.start()
                                   | when_all()  |

Core Abstractions

ConceptWhat it represents
senderLazy description of work; produces values, errors, or a stop signal
receiverTyped callback: must handle set_value, set_error, set_stopped
schedulerFactory for senders tied to an execution context (thread pool, event loop, GPU queue)
operation_stateThe 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

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

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

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

cpp
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

AlgorithmSignatureDescription
just(vs...)→ sender_of<Vs...>Immediately provide values
just_error(e)→ senderImmediately signal an error
just_stopped()→ senderImmediately signal stop
then(snd, fn)→ senderTransform the value with fn
upon_error(snd, fn)→ senderHandle errors; fn returns a new value
upon_stopped(snd, fn)→ senderHandle stop signal
let_value(snd, fn)→ senderfn returns a sender — enables dependent async chains
let_error(snd, fn)→ senderError recovery via a new sender
on(sched, snd)→ senderRun snd on sched
schedule(sched)→ senderStart a chain on sched
when_all(snds...)→ senderWait for all; tuple of values
when_any(snds...)→ senderComplete on first success; cancel rest
bulk(snd, n, fn)→ senderParallel for i in [0,n) with fn(i, values...)
into_variant(snd)→ senderNormalize variant value types
sync_wait(snd)→ optional<tuple<Vs...>>Block current thread; drive the sender
start_detached(snd)voidFire-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.

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

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

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

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

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

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

The tag_invoke dispatch is how P2300 enables open extension without virtual dispatch.

Comparison with std::future and Coroutines

std::futureCoroutines (co_await)std::execution
LazyNo — starts immediatelyYes (stackless resumption)Yes — no work until start()
ComposableAwkward (.then() allocates)Yes, sequentiallyYes, including parallel
Structured concurrencyNoWith frameworks (e.g. std::generator)Built-in (when_all)
CancellationNoVia stop tokens + frameworkBuilt-in via stop tokens
Scheduler controlNoPartially (awaiter controls)Full — scheduler is explicit
OverheadHeap allocation per futureFrame allocation per coroutineZero-allocation when chained
Error modelexception_ptr onlyexception_ptr or expectedTyped: any error type

Compiler and Library Support

P2300 was accepted for C++26. Implementations as of 2026:

ImplementationStatus
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
MSVCUnder 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.

cmake
# vcpkg
find_package(stdexec CONFIG REQUIRED)
target_link_libraries(my_target PRIVATE stdexec::stdexec)
cpp
// 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_wait returns std::optional<std::tuple<Vs...>> — empty if the sender sent a stop signal; throws if it sent an exception_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_all cancels siblings on the first error; when_any cancels siblings on the first success
  • Prefer let_value over then when the transformation is itself async; use then for cheap synchronous transforms