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

Error Handling with upon_error

upon_error is the inline recovery primitive for typed errors. Unlike let_error, which must return a new sender, upon_error expects the handler to return a plain value — keeping recovery paths as lightweight as then.

cpp
// C++26
#include <execution>
#include <system_error>
#include <print>

namespace ex = std::execution;

auto pipeline =
    ex::just(user_input)
    | ex::then(parse_as_int)              // throws / calls set_error with std::errc
    | ex::upon_error([](std::errc ec) -> int {
        // Recover: bad input becomes 0
        std::println("parse error: {}", std::make_error_code(ec).message());
        return 0;
      })
    | ex::then(process_value);            // sees 0 on failure, real value on success

auto result = ex::sync_wait(std::move(pipeline));

When multiple error types are possible, provide an overloaded handler or use let_error and branch on the type:

cpp
// C++26
auto robust =
    ex::just(payload)
    | ex::then(decode)
    | ex::let_error([](auto&& err) -> ex::sender auto {
        using E = std::decay_t<decltype(err)>;
        if constexpr (std::is_same_v<E, std::errc>) {
            // Recoverable: provide a default sender
            return ex::just(default_payload());
        } else {
            // Unrecoverable: re-signal the error
            return ex::just_error(std::forward<decltype(err)>(err));
        }
      })
    | ex::then(handle_payload);

upon_error only fires if the upstream sender signals set_error; a stopped sender passes through unchanged. If you need to convert a stop signal into a value, combine upon_stopped in the same chain.

Cancellation and Stop Tokens

Stop tokens in std::execution flow downward through the operation graph automatically. You never thread a stop_token through function arguments; instead, use get_stop_token(receiver) inside a custom sender, or poll via the environment inside then/let_value lambdas.

cpp
// C++26
namespace ex = std::execution;

// Long-running work that respects cancellation
auto cancellable_work(ex::scheduler auto sched) {
    return ex::schedule(sched)
        | ex::then([](/* stop token injected via env */ ) {
            // Retrieve the stop token from the current execution environment
            auto st = ex::get_stop_token(ex::get_env(/* receiver */));
            for (int i = 0; i < 1'000'000; ++i) {
                if (st.stop_requested()) break;  // honour cancellation
                do_unit_of_work(i);
            }
          });
}

when_all delivers cancellation automatically: as soon as one child sender signals set_error or set_stopped, the stop token shared with all siblings transitions to the stopped state.

cpp
// C++26
auto race =
    ex::when_all(
        ex::on(sched, long_io_operation()),       // may be cancelled
        ex::on(sched, another_io_operation()),    // may be cancelled
        ex::on(sched, fast_fallback_operation())  // first to succeed wins
    );
// If fast_fallback completes first with set_value, when_all stops the others.
// when_any is the correct combinator for "first success wins":
auto first_wins = ex::when_any(
    ex::on(sched, try_primary_source()),
    ex::on(sched, try_secondary_source())
);
auto result = ex::sync_wait(std::move(first_wins));

A stop_source can be held externally to trigger cancellation from outside the pipeline:

cpp
// C++26
std::stop_source src;
auto token = src.get_token();

// Pass the token into the pipeline through the environment or capture
std::thread watcher([&src] {
    std::this_thread::sleep_for(std::chrono::seconds(5));
    src.request_stop();   // cancel after 5 s
});

auto op = ex::sync_wait(
    ex::on(sched, long_running_sender_with_token(token))
);
watcher.join();

Scheduler-bound Pipelines

Binding a scheduler to a pipeline at the start keeps execution-context decisions at the call site, not inside library code. ex::on(sched, sender) wraps an entire sub-graph so it runs on sched, while ex::transfer(snd, sched) hops a mid-chain result to a new scheduler.

cpp
// C++26
namespace ex = std::execution;

// Compute on a CPU pool, then hand off result to an I/O scheduler for writing
auto pipeline =
    ex::just(input_data)
    | ex::on(cpu_pool.get_scheduler(),
          ex::then(compress_data)        // runs on CPU pool
        | ex::then(encode_result)
      )
    | ex::transfer(io_sched)             // hop to I/O thread for the write
    | ex::then(write_to_socket);         // runs on io_sched

ex::sync_wait(std::move(pipeline));

ex::transfer is equivalent to ex::on(sched, sender) when the sender is the entire tail — use whichever reads more clearly at the point of insertion.

cpp
// C++26
// Equivalently, using schedule_from (lower-level):
auto tail =
    ex::schedule_from(io_sched, encoded_sender)
    | ex::then(write_to_socket);

Pinning work to specific schedulers is essential for GPU pipelines, where compute senders must run on a CUDA scheduler and results must be copied back via a transfer scheduler:

cpp
// C++26 (illustrative — CUDA scheduler is vendor-provided)
auto gpu_pipeline =
    ex::just(host_data)
    | ex::on(cuda_sched, ex::then(gpu_kernel))   // executes on GPU stream
    | ex::transfer(cpu_sched)                     // DMA result back to host
    | ex::then(post_process);                     // runs on CPU

Testing with inline_scheduler

stdexec ships an inline_scheduler that drives all senders synchronously on the calling thread. This makes unit-testing sender pipelines straightforward — no thread pools, no timeouts, no non-determinism.

cpp
// C++26 (stdexec)
#include <stdexec/execution.hpp>
#include <exec/inline_scheduler.hpp>

namespace ex = stdexec;

void test_pipeline_transforms_value() {
    exec::inline_scheduler sched{};

    auto result = ex::sync_wait(
        ex::schedule(sched)
        | ex::then([]{ return 21; })
        | ex::then([](int x){ return x * 2; })
    );

    assert(result.has_value());
    assert(std::get<0>(*result) == 42);
}

Test error paths by injecting just_error at the start of the pipeline, bypassing the scheduler entirely:

cpp
// C++26
void test_upon_error_recovers() {
    auto result = ex::sync_wait(
        ex::just_error(std::errc::invalid_argument)
        | ex::upon_error([](std::errc) -> int { return -1; })
        | ex::then([](int v){ return v * 10; })
    );

    assert(result.has_value());
    assert(std::get<0>(*result) == -10);
}

Test cancellation by inserting just_stopped():

cpp
// C++26
void test_stop_propagates() {
    bool upon_stopped_called = false;

    ex::sync_wait(
        ex::just_stopped()
        | ex::upon_stopped([&]{ upon_stopped_called = true; })
    );

    assert(upon_stopped_called);
}

The inline_scheduler also makes it easy to assert execution-context transitions during integration tests: wrap the scheduler in a spy that records each schedule() call and verify the expected number of context switches occurred.

See Also

  • std::jthread — cooperative cancellation via std::stop_token, the same mechanism senders expose
  • std::async / std::future — the eager predecessor; understand the tradeoffs before migrating
  • Atomic operations — low-level building block used inside scheduler implementations
  • Condition variables — still useful inside custom schedulers for wait/notify
  • Lock-free programming — techniques relied on by high-performance scheduler implementations
  • P2300R10 (stdexec proposal) — the canonical specification; section 4 contains the full algorithm catalogue
  • stdexec on GitHub — production-ready reference implementation with examples and a rich set of extra schedulers (exec::io_uring_context, exec::system_context)