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

std::execution: Senders, Receivers, and Structured Async

std::execution and the Senders/Receivers model: schedulers, sender factories, adaptors, and the connect/start protocol for structured async concurrency in C++26.

std::execution (adopted for C++26 via P2300) is the most significant addition to C++ concurrency since std::thread. It replaces ad-hoc callback chains, std::future/std::promise overhead, and unstructured async patterns with a single composable model: senders describe work; receivers consume it; schedulers say where it runs.

Before studying this page, you should be comfortable with C++20 coroutines and std::atomic. After it, you'll understand why structured concurrency prevents deadlocks by construction, how to compose async task graphs without allocating, and what the connect/start protocol looks like under the hood.


The problem with unstructured concurrency

std::future and std::promise are low-level primitives β€” they transfer values between threads, but they say nothing about structured lifetime:

cpp
// Unstructured: who owns this future? When does the computation stop?
auto f1 = std::async(std::launch::async, compute_a);
auto f2 = std::async(std::launch::async, compute_b);
int result = f1.get() + f2.get();
// What if f2 throws? f1's thread is gone, its destructor is a detach.

Eric Niebler's analogy: unstructured concurrency is to structured concurrency what goto is to for/while/if. You can express anything, but you can't reason about lifetime or cancellation locally.

Senders enforce a contract: a sender cannot complete before all its child senders complete. Cancellation propagates upward. No detached threads. No leaked work.


The three abstractions

Sender

A sender describes asynchronous work β€” it is a description, not running code. Nothing executes until a sender is connected to a receiver and started.

cpp
// A sender that produces the string "hello"
std::execution::sender auto s = std::execution::just(std::string{"hello"});
// s describes work. Nothing has run yet.

Senders compose via pipe syntax:

cpp
using namespace std::execution;

sender auto pipeline =
    just(42)
    | then([](int x) { return x * 2; })
    | then([](int x) { return std::to_string(x); });
// Still nothing has run.

Receiver

A receiver is a multi-channel callback. Where a function has one return, a receiver has three channels:

  • set_value(values...) β€” work succeeded, here are the results
  • set_error(err) β€” work failed with this error
  • set_stopped() β€” work was cancelled

End users almost never implement receivers directly. They appear inside algorithm implementations that chain senders together. The framework routes completion signals automatically.

Scheduler

A scheduler is a lightweight handle to an execution context (thread pool, event loop, GPU stream). It provides one operation: schedule(), which returns a sender that, when started, causes subsequent work to execute in that context.

cpp
// The system_context is a thread pool shared by the process
std::execution::system_context ctx;
std::execution::scheduler auto sch = ctx.get_scheduler();

sender auto work =
    schedule(sch)                        // start on pool thread
    | then([]{ return heavy_compute(); });

Sender factories, adaptors, consumers

The entire std::execution API falls into three groups:

Factories (produce senders from non-senders)

FactoryWhat it produces
just(v...)Sender that immediately completes with v...
just_error(e)Sender that immediately fails with error e
just_stopped()Sender that immediately cancels
schedule(sch)Sender that transitions to scheduler sch
read_env(tag)Sender that reads a property from the execution environment

Adaptors (sender β†’ sender)

AdaptorWhat it does
then(f)Apply f to the value channel
upon_error(f)Apply f to the error channel
upon_stopped(f)Apply f when stopped
let_value(f)Like then, but f returns a sender (monadic bind)
let_error(f)Error-channel monadic bind
starts_on(sch, snd)Start snd on scheduler sch
continues_on(sch)After current completion, continue on sch
on(sch, snd)Run snd on sch, return to original scheduler
when_all(s1, s2, ...)Fan-out: complete when all complete
split(snd)Allow a single sender to be consumed by multiple continuations
bulk(n, f)Run f(i, values...) for each index i in [0, n)
stopped_as_optional()Map stopped β†’ empty optional, value β†’ optional<T>
stopped_as_error(e)Map stopped β†’ error
into_variant()Collapse multiple value signatures into variant<tuple<...>>

Consumers (sender β†’ result, blocking)

Consumers live in std::this_thread:

cpp
// Blocks current thread until sender completes
auto result = std::this_thread::sync_wait(pipeline);
// result is optional<tuple<values...>>
// - optional<tuple<std::string>> for just(42) | then(to_string)
// - empty optional if sender completed with set_stopped()
// - throws if sender completed with set_error()

Working with schedulers

Moving between execution contexts

cpp
#include <execution>
using namespace std::execution;

system_context ctx;
auto pool = ctx.get_scheduler();

// io_context (conceptually β€” your async I/O library provides one)
// auto io   = io_ctx.get_scheduler();

sender auto work =
    just(read_request_from_socket())        // on current thread
    | continues_on(pool)                    // switch to thread pool
    | then([](Request req) {
        return process(req);                // heavy work on pool
      })
    | continues_on(io)                      // back to I/O thread
    | then([](Response resp) {
        write_response_to_socket(resp);
      });

std::this_thread::sync_wait(std::move(work));

continues_on lets you describe context switches as part of the pipeline β€” no manual thread coordination, no callbacks.

starts_on vs continues_on vs on

cpp
// starts_on: the WHOLE pipeline runs starting on sch
sender auto a = starts_on(pool, just(42) | then(compute));

// continues_on: after current completion, CONTINUE on sch
sender auto b = just(42) | then(f) | continues_on(pool) | then(g);

// on: run the wrapped sender on sch, then RETURN to original scheduler
sender auto c = just(42) | then(f) | on(pool, just(1) | then(h)) | then(k);
//              ^^^^ runs on original     ^^^^ runs on pool          ^^^^ back to original

Concurrent fan-out with when_all

cpp
using namespace std::execution;

system_context ctx;
auto pool = ctx.get_scheduler();

sender auto fetch_a = starts_on(pool, just() | then(fetch_from_db_a));
sender auto fetch_b = starts_on(pool, just() | then(fetch_from_db_b));

sender auto combined = when_all(std::move(fetch_a), std::move(fetch_b))
    | then([](ResultA a, ResultB b) {
        return merge(a, b);
      });

auto [result] = std::this_thread::sync_wait(std::move(combined)).value();

when_all starts both fetches concurrently and joins when both complete. If either fails, the error propagates and the other branch is cancelled β€” no leak, no dangling.


Bulk execution

bulk maps a function over a range of indices, potentially in parallel:

cpp
std::vector<float> data(1024);

sender auto fill =
    just(std::span<float>{data})
    | bulk(data.size(), [](std::size_t i, std::span<float> v) {
        v[i] = static_cast<float>(i) * 0.1f;
      });

std::this_thread::sync_wait(std::move(fill));

The scheduler decides whether to parallelize bulk across threads or run it serially. The same code runs on CPU thread pools, GPU schedulers, or embedded single-thread contexts without change.


let_value: monadic composition

When the next stage of your pipeline needs to produce a sender rather than a plain value, use let_value:

cpp
// load_image returns a sender<Image>
// enhance_image(img) also returns a sender<Image>
// We want: load β†’ enhance β†’ save, where each step is async

sender auto pipeline =
    load_image("input.png")               // sender<Image>
    | let_value([](Image img) {           // img -> sender<Image>
        return enhance_image(std::move(img));
      })
    | let_value([](Image img) {           // img -> sender<void>
        return save_image(std::move(img), "output.png");
      });

If you used then here instead of let_value, you'd get sender<sender<Image>> β€” a nested sender, not what you want. let_value is the monadic bind that flattens one level.


Error and cancellation handling

Senders carry error types statically. You can handle them without exceptions:

cpp
sender auto safe_pipeline =
    load_from_network()               // might complete with set_error(std::error_code)
    | upon_error([](std::error_code ec) -> std::string {
        return "default_value";       // recover: turn error into a value
      })
    | then([](std::string s) { process(s); });

Cancellation (stop tokens) integrates with std::stop_source:

cpp
std::stop_source stop;

sender auto cancellable =
    starts_on(pool, long_running_work())
    | stopped_as_error(std::runtime_error{"cancelled"});

// From another thread:
stop.request_stop();

The stopped_as_optional and stopped_as_error adaptors let you map the stopped channel into the type system rather than handling it separately.


Under the hood: connect/start protocol

For library authors implementing custom senders:

cpp
// A minimal just_int sender (simplified from ACCU Overload 186)
struct just_int_op_state {
    using operation_state_concept = std::execution::operation_state_t;

    int value;
    auto receiver;   // holds the downstream receiver

    void start() noexcept {
        std::execution::set_value(std::move(receiver), value);
    }
};

struct just_int_sender {
    using sender_concept       = std::execution::sender_t;
    using completion_signatures = std::execution::completion_signatures<
        std::execution::set_value_t(int)
    >;

    int value;

    auto get_env() const noexcept { return std::execution::empty_env{}; }

    template<std::execution::receiver_of<completion_signatures> R>
    just_int_op_state<R> connect(R&& r) && {
        return {value, std::forward<R>(r)};
    }
};

auto just_int(int v) { return just_int_sender{v}; }

The sequence is always:

  1. Build a sender (no execution yet)
  2. connect(sender, receiver) β€” produces an operation_state, which owns all the state
  3. start(op_state) β€” execution begins; the operation_state must remain alive until completion

The operation_state cannot be moved or copied after start(). This allows implementations to take stable addresses to sub-objects.


Using stdexec today (before C++26 ships)

NVIDIA's stdexec is the reference implementation. Drop it in via CMake:

cmake
include(FetchContent)
FetchContent_Declare(stdexec
    GIT_REPOSITORY https://github.com/NVIDIA/stdexec
    GIT_TAG        main
)
FetchContent_MakeAvailable(stdexec)
target_link_libraries(myapp PRIVATE stdexec::stdexec)
cpp
#include <stdexec/execution.hpp>
namespace ex = stdexec;

int main() {
    ex::thread_pool pool{4};
    auto sch = pool.get_scheduler();

    auto [result] = ex::sync_wait(
        ex::schedule(sch)
        | ex::then([]{ return 6 * 7; })
    ).value();

    // result == 42
}

The Beman project (bemanproject/execution) provides another conforming implementation closer to the final standard wording.


std::execution vs coroutines

These are complementary, not competing:

Coroutinesstd::execution
Abstraction levelSingle async taskComposable task graphs
ConcurrencySequential by defaultwhen_all for fan-out
Scheduler controlHidden in the awaitableExplicit, first-class
Heterogeneous (GPU)Not designed for itYes, via scheduler
Cancellationstd::stop_tokenBuilt into protocol
Learning curveLowerHigher

Senders and coroutines integrate: co_await can suspend on a sender via as_awaitable. A coroutine-based async framework can expose its scheduler as a std::execution::scheduler.


std::execution vs std::future

std::future/std::asyncstd::execution
Lazy vs eagerEager (work starts immediately)Lazy (work starts at sync_wait/start)
CompositionManual .then() or .get() blockingPipeline algebra via |
OverheadHeap allocation per future, shared_ptr control blockZero-overhead if scheduler avoids heap
CancellationNoneset_stopped() + stop tokens
Execution contextThread or inlineAny scheduler
Error handlingException in .get()Error channel, no exception required

Quick-reference: algorithm selection

GoalUse
Start a value in a pipelinejust(v)
Transform the resultthen(f)
Chain another async operationlet_value(f)
Handle an errorupon_error(f) or let_error(f)
Run concurrently and joinwhen_all(s1, s2)
Move to another threadcontinues_on(sch)
Run N things in bulkbulk(n, f)
Block caller until donestd::this_thread::sync_wait(s)
Recover from cancellationstopped_as_optional()

Pitfalls

Operation state must stay alive. After connect, the operation state owns all the work. Letting it go out of scope before completion is UB:

cpp
// BUG: op_state destroyed before start() completes
{
    auto op = std::execution::connect(sender, receiver);
    std::execution::start(op);
} // op destroyed here β€” UB if start() is async

sync_wait on a sender that never completes β€” blocks forever. Ensure the sender eventually reaches one of the three completion channels.

when_all and partial failure β€” if one branch calls set_error, the others receive a stop request. They may not stop immediately; when_all waits for all branches to complete (via any channel) before delivering the error.