references/senders.md
Senders and receivers
std::execution (P2300), adopted into C++26. The standard model for async and parallel work, and the most elaborate compile-time pipeline in the language: a | then(f) | continues_on(sched) | then(g) composes into a single type, connect recursively builds one nested operation-state aggregate, and start runs it. No allocation, no reference counting, no vtables.
Sourced from the NVIDIA/stdexec docs and headers.
Where it stands
- June 2024: P2300R10 adopted into C++26.
- June 2025 (Sofia): feature freeze. Also adopted: P2079 parallel scheduler,
P3149 async_scope/counting_scope, P3552 coroutine task.
- ISO publication expected late 2026. Networking interop is being explored for
C++29.
Implementations. NVIDIA/stdexec is the reference: header only, Apache 2.0 with LLVM exceptions, no dependencies, C++20 minimum, GCC 12+, Clang 16+, MSVC 14.43+. GPU support means nvc++; nvcc is not supported. bemanproject/execution is a second implementation focused on conformance and is explicitly not production ready. Vendor standard libraries are still in flight, so do not assume #include <execution> gives you senders.
CPMAddPackage(NAME stdexec GITHUB_REPOSITORY NVIDIA/stdexec GIT_TAG main)
target_link_libraries(app PRIVATE STDEXEC::stdexec)
The five nouns
| Concept | One line |
|---|---|
| scheduler | Cheap copyable handle to an execution context: thread pool, GPU stream, run loop. schedule(sched) returns a sender. Equality means "same execution resource". |
| sender | A description of async work. Lazy: does nothing until connected and started. |
| receiver | The callback triple set_value(vs...), set_error(e), set_stopped(). Mostly an implementation detail of adaptors. |
| operation state | What connect(sender, receiver) produces. Immovable. Must stay alive until completion. start() runs it. |
| environment | Key/value store queryable by tag, attached to receivers and to senders as attributes. Carries stop token, allocator, scheduler, domain. |
The protocol, exactly:
schedule(sched) -> sender
connect(sender, receiver) -> operation_state
start(op) -> void, noexcept
// then exactly ONE of set_value / set_error / set_stopped, exactly once
Three completion channels is the central idea: a sender generalises a function by supporting multiple value shapes, multiple error types, and a cancellation signal.
Completion signatures
Every sender declares what it can complete with. This is the pipeline's type system, and it is what type-checks the graph at compile time.
using completion_signatures = stdexec::completion_signatures<
stdexec::set_value_t(int),
stdexec::set_error_t(std::exception_ptr),
stdexec::set_stopped_t()>;
Adaptors compute their output signatures from their input's via transform_completion_signatures. then(f) maps each set_value_t(Vs...) to set_value_t(decltype(invoke(f, Vs...))), forwards errors and stopped unchanged, and adds set_error_t(std::exception_ptr) only if f can throw. A void-returning f yields set_value_t(), a value completion with no datums.
The concept ladder, weakest to strongest: sender<S>, sender_in<S, Env> (what generic adaptor code constrains on), sender_to<S, R> (used right before connecting).
Algorithm catalogue
Factories, at the head of a pipeline: just, just_error, just_stopped, schedule, read_env. read_env(query) pulls a value out of the receiver's environment onto the value channel; get_stop_token(), get_scheduler(), get_allocator() are one-line wrappers, and you should prefer the wrappers.
Value channel:
then(f)wherefreturns a valuelet_value(f)wherefreturns a sender. The monadic bind, and what to
reach for whenever the continuation is itself asynchronous.
auto bad = just(7) | then(fetch_async); // value type is a sender. Nothing runs.
auto good = just(7) | let_value(fetch_async); // runs it
Error and stopped channels: upon_error/let_error, upon_stopped/let_stopped (same value-versus-sender split), plus stopped_as_error and stopped_as_optional. upon_error and upon_stopped consume their channel: the resulting sender no longer has it.
Scheduling: the three everyone confuses
| Adaptor | Work runs | Completion delivered |
|---|---|---|
schedule(sch) | on sch | on sch |
starts_on(sch, sndr) | on sch | on sch, stays put |
continues_on(sndr, sch) | on sndr's scheduler | on sch, permanent handoff |
on(sch, sndr) | on sch | back on the start scheduler, round trip |
starts_on is "start here and stay", continues_on is "hand off", on is "side trip and come back". starts_on has no pipe form; the scheduler comes first.
The canonical two-context pipeline. Splitting I/O and CPU across separate schedulers is what avoids oversubscribing one while starving the other:
auto sndr = starts_on(io_sched, read_data_sndr)
| continues_on(cpu_sched)
| then(process_data)
| continues_on(io_sched)
| then(write_result);
when_all
Runs inputs concurrently and value-completes with the concatenation of every input's value datums.
- Lazy. Naming senders in
when_alldoes not start them. They all start when
the composed sender starts.
- Concurrent is not parallel. Without wrapping each branch in
starts_on,
they all run synchronously inside the caller's start.
auto sndr = when_all(
starts_on(cpu, sndr_a),
starts_on(cpu, sndr_b),
starts_on(cpu, sndr_c)); // now it is actually parallel
Fail fast: if any input errors or is stopped, when_all requests stop on the others and completes with the first observed error or stopped. Later failures are discarded. Each input must have exactly one value-completion shape; otherwise use when_all_with_variant.
transfer_when_all is deprecated. Write when_all(...) | continues_on(sch).
Parallel loops
bulk(policy, shape, f) invokes f(i, vs...) for every i in [0, shape). Predecessor values are passed as extra arguments and are shared across all iterations, not a per-index view. Policies are seq, par, par_unseq. Whether iterations actually run in parallel is up to the scheduler.
bulk_chunkedcallsf(begin, end, vs...)per chunk. Use when the body
benefits from per-chunk amortisation: thread-local accumulators, vectorisation setup, batched allocation.
bulk_unchunkedguarantees one call per index.
bulk is implemented in terms of bulk_chunked, and bulk is the GPU hook: nvexec rewrites it into a CUDA kernel launch.
Consumers
| Consumer | Returns | Eager? | Who owns the opstate |
|---|---|---|---|
sync_wait | optional<tuple<...>> | lazy | the caller's stack frame |
sync_wait_with_variant | optional<variant<tuple<...>...>> | lazy | the caller's stack frame |
exec::start_detached | void | eager | itself, heap allocated |
spawn(sndr, token) | void | eager | the async scope |
spawn_future(sndr, token) | sender | eager | the async scope |
sync_wait returns an engaged optional on value, a disengaged optional on stopped, and throws on error. It requires exactly one value-completion shape. It blocks, driving an internal run_loop on the calling thread, so it is top-level code only: main, tests, leaf utilities. Never mid-pipeline and never on an executor thread. To "wait" mid-pipeline you want let_value or a coroutine co_await.
start_detached and spawn statically reject senders with an error channel. There is no caller to deliver an error to, so handle it in-pipeline with upon_error or let_error first. start_detached is an stdexec extension; spawn is the standardised scope-tracked equivalent and is preferable whenever a natural owning scope exists.
Structured concurrency
The invariant: a sender contained in another sender completes before its parent completes. That is what makes the model free of deadlocks and data races by construction, and what lets you reason about a subtree locally. It is the direct analogue of structured programming with functions.
When work does not nest cleanly, a server spawning one task per request, use an async scope:
exec::async_scope scope;
stdexec::spawn(
just(42) | then([](int x) { std::println("background: {}", x); }),
scope.get_token());
// before the scope is destroyed
stdexec::sync_wait(scope.join());
spawn_future is the same but returns a one-shot observer sender for the result. The work is already running when it returns; the sender observes it, it does not start it. spawn_future versus when_all is exactly the eager versus lazy choice: when_all to compose inside a pipeline, spawn_future when work must start immediately or you do not yet know how many results you will collect.
ensure_started historically filled this role. P3109 flagged its destructor semantics as a footgun and it was replaced by the scope-based design.
Coroutines
stdexec::task<T, Env> (P3552) is the sender-aware coroutine type. Inside it, senders are awaitable and awaitables are senders.
auto my_task() -> stdexec::task<int> {
int x = co_await some_sender();
co_return x + 1;
}
- An awaited sender that errors makes the coroutine throw.
- One that stops means the coroutine is never resumed: it and its callers are
destroyed.
taskcarries a stop token, allocator, and scheduler affinity in its
environment. with_error<E> lets it complete on the error channel with a typed error.
Mix the two freely. Coroutines handle non-linear control flow and type erasure better; sender graphs express fan-out and structure better.
Why the error messages look like that
stdexec's algorithms are not hand-written sender classes. They are s-expressions: __sexpr<DescriptorFn> is one generic sender template parameterised by __desc<Tag, Data, Child...> and deriving from __tuple<Tag, Data, Child...>. then(sndr, f) is literally a tuple of (tag, data, children...), and behaviour comes from specialising __sexpr_impl<Tag> with a handful of static lambdas. That is why then's whole implementation is about 60 lines, and why the entire pipeline is one nested aggregate object.
Two practical consequences:
- Sender pipelines are destructurable:
auto& [tag, data, child] = sndr;.
That is how transform_sender rewrites them.
- Error messages are engineered. There are
STDEXEC_ERROR_*static_assert
strings and _WITH_PRETTY_SENDER_<S> demangling. Read the static_assert text before the template backtrace. It often names the exact problem, for example "The sender cannot be decay-copied. Did you forget a std::move?".
Writing your own
A sender adaptor: wrap the receiver, not the sender. About 70 lines for a first-class citizen. The shape:
template <stdexec::receiver R, class Fn>
struct simple_then_receiver {
using receiver_concept = stdexec::receiver_tag;
R rcvr_; Fn fn_;
template <class... Vs>
void set_value(Vs&&... vs) noexcept {
try {
stdexec::set_value(std::move(rcvr_),
std::invoke(std::move(fn_), static_cast<Vs&&>(vs)...));
} catch (...) {
stdexec::set_error(std::move(rcvr_), std::current_exception());
}
}
template <class E>
void set_error(E&& e) noexcept { stdexec::set_error(std::move(rcvr_), static_cast<E&&>(e)); }
void set_stopped() noexcept { stdexec::set_stopped(std::move(rcvr_)); }
auto get_env() const noexcept { return stdexec::get_env(rcvr_); }
};
- Every completion member is
noexceptand returnsvoid. Static asserts at the
dispatch site enforce both.
connecttakesthisas an rvalue: senders are moved into the operation state.- You often write no operation state at all. Connecting the predecessor to your
wrapping receiver returns the predecessor's opstate, and you return it directly. Only adaptors needing extra storage (stop callbacks, child variants, values held across a hop) write their own.
- Compute the output signatures with
transform_completion_signaturesin a
static consteval get_completion_signatures member. Hardcoding them is the one shortcut worth calling out as a shortcut.
A scheduler: build bottom up, opstate then sender then scheduler. The opstate deletes its move constructor to assert immovability, because the framework and the receiver may hold pointers into its storage. The scheduler concept is structural: no tag alias, just .schedule() plus equality and copyability. Equality must mean "same execution resource", because continues_on uses it to elide redundant hops. stdexec::run_loop is the best worked reference: a single-threaded run loop in about 250 lines.
A domain is how a scheduler takes over the algorithms rather than just the context: a GPU scheduler wanting then lambdas on device, a tracing scheduler wrapping every algorithm in a span. transform_sender runs inside connect, twice: once for the completing domain (set_value_t, advertised by the predecessor, what GPU schedulers hook) and once for the starting domain (start_t, from the receiver's environment). Never capture the Env - it arrives by const reference and a sender that captures it will dangle. Use it for compile-time decisions only.
Gotchas
thenwith a sender-returning function is almost always a bug. Uselet_value.when_allwithoutstarts_onis concurrent, not parallel.- Operation states are immovable.
connectrelies on prvalue copy elision. sync_waitblocks. Top-level only. Never mid-pipeline, never on a pool thread.start_detachedandspawnstatically reject senders that can error.spawn_futureis eager,when_allis lazy. Choose deliberately.- Always
join()an async scope before it is destroyed. set_value,set_error,set_stopped, andstartmust benoexceptand
return void.
- Exactly one completion signal, exactly once, after start.
- Nothing runs until a consumer runs it. A sender you build and drop is a no-op.
nvccis unsupported. GPU meansnvc++.- Read the static_assert text before the template backtrace.
when_allrequires one value-completion shape per input, else
when_all_with_variant.
stopped_as_optionalrequires exactly one value completion with exactly one
argument.
- Scheduler equality must mean "same resource", or
continues_onwill elide
hops wrongly.
Learning it
The best single artifact is examples/nvexec/maxwell* in the stdexec repository: one stencil solver written six ways (std, stdpar, snr, cuda, cpu_st, cpu_mt). Reading them side by side teaches the model faster than the guide does.