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:
// 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.
// 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:
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 resultsset_error(err)β work failed with this errorset_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.
// 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)
| Factory | What 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)
| Adaptor | What 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:
// 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
#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
// 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 originalConcurrent fan-out with when_all
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:
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:
// 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:
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:
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:
// 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:
- Build a sender (no execution yet)
connect(sender, receiver)β produces anoperation_state, which owns all the statestart(op_state)β execution begins; theoperation_statemust 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:
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)#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:
| Coroutines | std::execution | |
|---|---|---|
| Abstraction level | Single async task | Composable task graphs |
| Concurrency | Sequential by default | when_all for fan-out |
| Scheduler control | Hidden in the awaitable | Explicit, first-class |
| Heterogeneous (GPU) | Not designed for it | Yes, via scheduler |
| Cancellation | std::stop_token | Built into protocol |
| Learning curve | Lower | Higher |
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::async | std::execution | |
|---|---|---|
| Lazy vs eager | Eager (work starts immediately) | Lazy (work starts at sync_wait/start) |
| Composition | Manual .then() or .get() blocking | Pipeline algebra via | |
| Overhead | Heap allocation per future, shared_ptr control block | Zero-overhead if scheduler avoids heap |
| Cancellation | None | set_stopped() + stop tokens |
| Execution context | Thread or inline | Any scheduler |
| Error handling | Exception in .get() | Error channel, no exception required |
Quick-reference: algorithm selection
| Goal | Use |
|---|---|
| Start a value in a pipeline | just(v) |
| Transform the result | then(f) |
| Chain another async operation | let_value(f) |
| Handle an error | upon_error(f) or let_error(f) |
| Run concurrently and join | when_all(s1, s2) |
| Move to another thread | continues_on(sch) |
| Run N things in bulk | bulk(n, f) |
| Block caller until done | std::this_thread::sync_wait(s) |
| Recover from cancellation | stopped_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:
// 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 asyncsync_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.