std::execution — Senders/Receivers (C++26)
"P2300 std::execution: structured async with senders, receivers, schedulers, and composable operation chains targeting C++26."
std::execution (P2300)since C++26A structured concurrency framework where lazy senders describe async operations, receivers handle their three completion channels (value, error, stopped), and schedulers abstract execution contexts — composable via pipe operators like the ranges library.
Overview
Before P2300, async C++ forced a choice between incompatible models:
| Approach | Problems |
|---|---|
std::future / std::async | Blocking destructors, no cancellation, no composition, hidden thread creation |
| Raw callbacks | Unstructured lifetimes, error propagation by convention only |
| Coroutines alone | No scheduler abstraction; who drives the event loop? |
| Executors (pre-P2300) | Never standardized; multiple incompatible proposals |
std::execution unifies these with three orthogonal abstractions:
- Scheduler — a lightweight handle to an execution context (thread pool,
io_uring, GPU stream).schedule(sched)returns a sender that, when started, places work on that context. - Sender — a description of async work, not the work itself. Lazy: nothing executes until a sender is connected to a receiver and started.
- Receiver — the sink for a sender's result. Handles exactly one of three exhaustive, mutually exclusive completion signals.
The three completion channels are the foundation of the model:
std::execution::set_value(receiver, values...); // success
std::execution::set_error(receiver, error); // failure (typically std::exception_ptr)
std::execution::set_stopped(receiver); // cancellationNamespace note:
std::execution::par,seq,par_unseq— the C++17 parallel algorithm execution policies — share thestd::executionnamespace but are entirely unrelated to P2300's sender/receiver machinery. They cannot compose withschedule(),then(), or any P2300 algorithm.
Syntax
The mechanical substrate is connect → operation state → start. Pipe syntax is ergonomic sugar over it:
// connect() wires a sender to a receiver, returning an operation_state object.
// The operation_state owns all resources for the operation's lifetime.
// start() initiates the async work — op must outlive the operation.
auto op = std::execution::connect(my_sender, my_receiver{});
std::execution::start(op); // op must not be destroyed until a completion signal firessync_wait wraps this for top-level use, blocking the calling thread:
// Returns std::optional<std::tuple<Ts...>>
// Returns nullopt if the sender completed via set_stopped
auto result = std::execution::sync_wait(
std::execution::schedule(sched)
| std::execution::then([] { return 42; })
);
auto [val] = result.value(); // val == 42Core sender algorithms (all are customization point objects, CPOs):
ex::just(1, 2.0f, "x") // completes immediately with given values
ex::just_error(ep) // immediately propagates error
ex::just_stopped() // immediately signals cancellation
ex::schedule(sched) // schedules a unit of work onto sched
ex::then(sender, fn) // map: fn(values...) → new value(s)
ex::let_value(sender, fn) // flatmap: fn(values...) → new sender (async chain)
ex::let_error(sender, fn) // error recovery: fn(error) → new sender
ex::let_stopped(sender, fn) // cancellation recovery: fn() → new sender
ex::upon_error(sender, fn) // fn(error) → value (not a sender)
ex::upon_stopped(sender, fn) // fn() → value
ex::transfer(sender, sched) // resume on a different scheduler
ex::when_all(senders...) // fan-out: completes when all succeed; cancels rest on failure
ex::bulk(sender, n, fn) // calls fn(i, values...) for i in [0,n), parallelisable
ex::split(sender) // multicast: share one sender's result across consumers
ex::stopped_as_optional(sender) // stopped → nullopt; value → optional<value>
ex::into_variant(sender) // collapse multiple value completions into std::variantHeader: P2300 targets
<execution>in C++26. For use today,#include <stdexec/execution.hpp>from NVIDIA/stdexec is the reference implementation (namespace stdexec). Examples below usenamespace ex = stdexec.
Examples
Basic pipeline:
ex::static_thread_pool pool{std::thread::hardware_concurrency()};
auto sched = pool.get_scheduler();
auto task =
ex::schedule(sched)
| ex::then([] {
return std::vector<int>{1, 2, 3, 4, 5};
})
| ex::then([](std::vector<int> v) {
return std::accumulate(v.begin(), v.end(), 0);
});
auto [result] = ex::sync_wait(std::move(task)).value();
// result == 15; both lambdas ran on pool threadsError recovery with let_error:
auto task =
ex::schedule(sched)
| ex::then([] -> std::string {
throw std::runtime_error("fetch failed");
return "data";
})
| ex::let_error([](std::exception_ptr) {
// fn returns a new sender — execution continues on success path
return ex::just(std::string{"fallback"});
})
| ex::then([](std::string s) { return s + " processed"; });
auto [val] = ex::sync_wait(std::move(task)).value();
// val == "fallback processed"Parallel fan-out with when_all:
auto squared = [&](int x) {
return ex::schedule(sched) | ex::then([x] { return x * x; });
};
auto [a, b, c] = ex::sync_wait(
ex::when_all(squared(2), squared(3), squared(5))
).value();
// a==4, b==9, c==25 — three tasks ran concurrentlyScheduler transfer — CPU pool → I/O context → CPU pool:
auto pipeline =
ex::schedule(cpu_pool.get_scheduler())
| ex::then([] { return build_http_request(); })
| ex::transfer(io_sched) // hand off to I/O scheduler
| ex::then([](HttpRequest req) {
return blocking_send_receive(req); // blocks I/O thread, not CPU pool
})
| ex::transfer(cpu_pool.get_scheduler()) // back to CPU pool
| ex::then([](HttpResponse resp) {
return parse_json(resp.body);
});Bulk parallel work:
std::vector<double> data(1'000'000);
auto task =
ex::schedule(sched)
| ex::bulk(data.size(), [&](std::size_t i) {
data[i] = std::sqrt(static_cast<double>(i));
});
ex::sync_wait(std::move(task));
// data[i] == sqrt(i) for all i, filled in parallel across pool threadsCoroutine integration (stdexec-specific; the standard interface for C++26 is still being finalized):
#include <exec/task.hpp> // stdexec coroutine support
exec::task<int> async_compute(ex::scheduler auto sched) {
// co_await suspends and resumes on sched's context
int raw = co_await (
ex::schedule(sched)
| ex::then([] { return expensive_compute(); })
);
co_return raw * 2;
}
auto [result] = ex::sync_wait(async_compute(pool.get_scheduler())).value();Best Practices
Use let_value for async flatmap, not then. When a computation itself yields a sender (e.g., an async I/O call), then wraps it in another sender layer. let_value flattens it:
// Wrong — produces sender<sender<Response>>, not sender<Response>
auto bad = ex::schedule(sched) | ex::then([&] { return async_read(fd); });
// Correct — let_value flattens the returned sender
auto good = ex::schedule(sched) | ex::let_value([&] { return async_read(fd); });Constrain functions accepting senders with concepts:
// Accept any sender completing with int — prevents type-erasing with std::function
void process(ex::sender_of<int> auto s); // C++26 concept constraintUse counting_scope for dynamic task spawning. start_detached provides no way to await completion or propagate errors. P3149's counting_scope (available in stdexec as exec::counting_scope, targeting a future standard) is the structured alternative:
exec::counting_scope scope;
scope.spawn(some_work_sender | ex::on(sched));
// ... spawn more tasks ...
co_await scope.join(); // wait for all spawned work before scope destructionSignal cancellation through set_stopped, not exceptions. Throwing an exception for cancellation contaminates the error channel, forcing every error handler to distinguish "real failure" from "cancelled." Use stop tokens to cooperatively check stop_requested() and let the framework route through set_stopped.
Common Pitfalls
Senders are lazy — construction is not execution:
auto s = ex::schedule(sched) | ex::then([] { do_work(); });
// do_work() has NOT been called. s is a description.
ex::sync_wait(std::move(s)); // do_work() runs hereDestroying the operation state before completion is UB. The object returned by connect() must remain alive until one completion signal (set_value, set_error, or set_stopped) has been delivered to the receiver. This is an explicit lifetime contract — not enforced by the type system.
sync_wait inside an async chain deadlocks. sync_wait is for program entry points only. Calling it from within a sender's work blocks the thread serving the scheduler, which starves single-threaded contexts and inverts the scheduler hierarchy in thread pools.
when_all cancels on first error. If any child sender fails or is cancelled, when_all propagates cancellation to the others and delivers the first error. Partially-completed sibling results are discarded. Use when_all_with_variant or into_variant when partial results matter.
Ignoring that when_all propagates stopped. If the enclosing scope's stop token is requested while when_all is running, the stopped signal propagates inward — all children are cancelled and the outer sender completes via set_stopped, not set_value. Callers that only match the value channel will miss this case.
Coroutine Integration: co_await on Senders
Senders can be directly awaited in a coroutine using stdexec::as_awaitable. The coroutine suspends at the co_await point, and the runtime resumes it on whichever completion channel the sender fires — value, error, or stopped. This makes sender pipelines and coroutine bodies fully interoperable without glue code.
// C++26
#include <exec/task.hpp> // stdexec coroutine task type
#include <stdexec/execution.hpp>
namespace ex = stdexec;
exec::task<int> fetch_and_double(ex::scheduler auto sched) {
// co_await suspends the coroutine; resumes on sched's thread with the result
int raw = co_await (
ex::schedule(sched)
| ex::then([] { return expensive_io_call(); }) // runs on sched
);
co_return raw * 2;
}
// Drive from a synchronous entry point
ex::static_thread_pool pool{4};
auto [result] = ex::sync_wait(
fetch_and_double(pool.get_scheduler())
).value();
// result == expensive_io_call() * 2exec::task<T> is a structured-concurrency-aware coroutine type: it inherits the stop token and scheduler from the enclosing environment, so cancellation propagates automatically from parent to child without any explicit wiring.
// C++26
// Awaiting a when_all sender inside a coroutine — results unpacked directly
exec::task<void> parallel_fetch(ex::scheduler auto sched) {
auto make_req = [&](int id) {
return ex::schedule(sched) | ex::then([id] { return fetch_item(id); });
};
auto [a, b] = co_await ex::when_all(make_req(1), make_req(2));
process(a, b); // both items fetched concurrently, results in scope
}Mixing co_await on senders and ordinary co_await on other awaitables in the same coroutine body is allowed; as_awaitable makes a sender conform to the Awaitable concept expected by the compiler.
Custom Scheduler Implementation
A custom scheduler is any type satisfying the scheduler concept — implementing schedule() to return a sender and providing equality comparison. The simplest useful example is an InlineScheduler that executes work synchronously on the calling thread, which is invaluable for unit tests and single-threaded embedded contexts.
// C++26
#include <stdexec/execution.hpp>
namespace ex = stdexec;
// Forward declarations
struct InlineSender;
struct InlineScheduler;
// Operation state: holds the receiver and executes it when started
template <ex::receiver R>
struct InlineOpState {
R receiver_;
friend void tag_invoke(ex::start_t, InlineOpState& self) noexcept {
// Synchronously complete on the calling thread, right here
ex::set_value(std::move(self.receiver_));
}
};
// Sender: describes "schedule on InlineScheduler"
struct InlineSender {
using sender_concept = ex::sender_t;
using completion_signatures = ex::completion_signatures<ex::set_value_t()>;
template <ex::receiver R>
friend auto tag_invoke(ex::connect_t, InlineSender, R&& r)
-> InlineOpState<std::remove_cvref_t<R>>
{
return {std::forward<R>(r)};
}
};
// Scheduler: lightweight, copyable handle to the inline execution context
struct InlineScheduler {
friend InlineSender tag_invoke(ex::schedule_t, InlineScheduler) noexcept {
return {};
}
friend bool operator==(InlineScheduler, InlineScheduler) noexcept { return true; }
};With InlineScheduler in hand, every sender algorithm (then, let_value, when_all, etc.) composes without modification because the scheduler contract is fully satisfied.
// C++26
auto [val] = ex::sync_wait(
ex::schedule(InlineScheduler{})
| ex::then([] { return 42; })
| ex::then([](int x) { return x * 2; })
).value();
// val == 84 — all lambdas ran synchronously on the calling threadFor testing purposes, InlineScheduler eliminates thread-pool non-determinism while keeping the same pipeline structure used in production code.
Error Propagation Through Pipelines
Error types propagate statically through the sender type — each algorithm in a pipeline declares in its completion signatures which error types it can deliver, and the compiler rejects mismatched handlers at the point of connect. This is a significant improvement over runtime std::future exceptions or callback-by-convention error codes.
// C++26
#include <system_error>
#include <stdexec/execution.hpp>
namespace ex = stdexec;
// A sender that may complete with std::string or fail with std::error_code
auto load_from_db(int key) -> ex::sender_of<std::string> auto {
return ex::just(key)
| ex::then([](int k) -> std::string {
if (k < 0) throw std::system_error{make_error_code(std::errc::invalid_argument)};
return "row:" + std::to_string(k);
});
}
auto pipeline =
load_from_db(-1) // will throw → error channel
| ex::let_error([](std::exception_ptr ep) {
// Attempt fallback: return a new sender on the value channel
try { std::rethrow_exception(ep); }
catch (std::system_error& e) {
if (e.code() == std::errc::invalid_argument)
return ex::just(std::string{"<default>"});
return ex::just_error(ep); // re-raise unrecognised errors
}
})
| ex::upon_error([](std::exception_ptr) -> std::string {
// Last-resort handler: log and return a sentinel value
std::cerr << "unhandled error, using sentinel\n";
return std::string{"<error>"};
})
| ex::then([](std::string s) { return s + " [processed]"; });
auto [result] = ex::sync_wait(std::move(pipeline)).value();
// result == "<default> [processed]"When a sender has multiple value completion signatures (e.g., from let_value branches returning different types), into_variant collapses them into a std::variant so downstream handlers deal with a single type.
// C++26
// into_variant: collect all value completions into a variant
auto multi = ex::when_all(
ex::just(42),
ex::just(std::string{"hello"})
);
// Produces sender<int, std::string> — each completed separately
// Use into_variant on a sender with multiple completion signatures to unify them:
auto unified = ex::just_error(std::make_exception_ptr(std::runtime_error{"oops"}))
| ex::let_error([](std::exception_ptr) { return ex::just(0); });
// unified: sender completing with int on both paths — type is statically knownThe static nature of completion signatures means that forgetting to handle an error type is a compile error, not a silent runtime drop.
Cancellation with std::stop_source
Structured concurrency in std::execution treats cancellation as a first-class completion channel, not an out-of-band mechanism. A sender reads std::stop_token from the environment it runs in; when stop is requested, cooperative checking via stop_token.stop_requested() allows graceful early exit through set_stopped, which sync_wait surfaces as std::nullopt.
// C++26
#include <stop_token>
#include <stdexec/execution.hpp>
namespace ex = stdexec;
// A long-running sender that respects cancellation
auto cancellable_work(std::stop_token st) {
return ex::just()
| ex::then([st]() -> int {
for (int i = 0; i < 1'000'000; ++i) {
if (st.stop_requested()) {
// Cooperatively exit — will complete via set_stopped
return -1; // or throw stdexec::_P2300::stopException{}
}
do_incremental_work(i);
}
return 0;
});
}The stop token is retrieved from the receiver's environment using the get_stop_token query, so well-written senders forward it automatically.
// C++26
std::stop_source stop_src;
// Kick off the work on a thread pool
ex::static_thread_pool pool{4};
auto work = ex::schedule(pool.get_scheduler())
| ex::let_value([&] {
// get_stop_token reads from the environment propagated by the scheduler
return cancellable_work(ex::get_stop_token(ex::get_env(ex::just())));
});
// Request stop from another thread after a timeout
std::jthread watcher([&](std::stop_token) {
std::this_thread::sleep_for(std::chrono::milliseconds{50});
stop_src.request_stop();
});
// sync_wait returns nullopt if the sender completed via set_stopped
auto result = ex::sync_wait(std::move(work));
if (!result) {
std::cout << "work was cancelled\n";
}
// Structured concurrency guarantees: all work has finished before sync_wait returns,
// regardless of whether it completed, errored, or was cancelled.Because when_all propagates stop inward to all children, cancelling a composite operation automatically cancels every branch — there is no need to manually thread stop tokens through nested pipelines.
Comparing stdexec vs Asio vs Coroutines
Each async model in C++ occupies a different point in the design space; they are not interchangeable, and for complex systems they are often complementary. The table below summarises the key trade-offs:
| Feature | stdexec (P2300) | Boost.Asio | Coroutines (C++20) |
|---|---|---|---|
| Lazy execution | Yes — senders are descriptions | Partial — initiating functions post immediately | Yes — coroutine body is lazy |
| Scheduler-per-operation | Yes — starts_on / continues_on | Executor per I/O object | No standard mechanism |
| Cancellation model | set_stopped + stop token, structured | cancel() on I/O objects, best-effort | stop_token via awaitable, ad-hoc |
| Heterogeneous (GPU) | Yes — scheduler abstraction covers GPU streams | No | No |
| Heap allocation | Zero per-operation with correct senders | Per-async-op handler allocation | One allocation per coroutine frame |
| Learning curve | High — new mental model | Moderate — familiar callback/future hybrid | Moderate — familiar sequential style |
| C++ standard | C++26 (P2300R10) | Boost / TS only | C++20 |
Boost.Asio introduced an executor model with strand and io_context that influenced P2300's scheduler concept; the two are architecturally similar but Asio initiates operations eagerly, while senders remain lazy until sync_wait or start. Coroutines are complementary to senders: exec::task<T> demonstrates that coroutines make an excellent surface syntax for sender pipelines, hiding connect/start machinery while preserving structured lifetimes. The recommended path for new C++26 code is stdexec for the pipeline skeleton with coroutines for ergonomic sequential sections.
Real-World Example: HTTP Request Pipeline
A production HTTP server needs I/O and CPU work on different schedulers, parallel fan-out for independent fetches, and clean error handling — all of which map directly onto sender combinators. The pipeline below processes one HTTP request end-to-end, keeping blocking I/O off the CPU pool.
// C++26
#include <stdexec/execution.hpp>
namespace ex = stdexec;
// Assume these senders are provided by the I/O layer:
// async_read(socket) → sender<RawBytes>
// async_write(socket, bytes) → sender<void>
// And these are CPU-bound functions wrapped as senders:
// parse_request(RawBytes) → sender<HttpRequest>
// serialize_response(HttpResp) → sender<RawBytes>
// fetch_headers(HttpRequest) → sender<Headers>
// fetch_body(HttpRequest) → sender<Body>
// execute_handler(HttpRequest) → sender<HttpResponse>
auto handle_connection(Socket socket,
ex::scheduler auto io_pool,
ex::scheduler auto cpu_pool) {
return
// 1. Read raw bytes on the I/O pool
ex::starts_on(io_pool, async_read(socket))
// 2. Parse on the CPU pool
| ex::continues_on(cpu_pool)
| ex::let_value([](RawBytes bytes) {
return parse_request(std::move(bytes));
})
// 3. Fan-out: fetch headers and body in parallel (both on cpu_pool)
| ex::let_value([&](HttpRequest req) {
return ex::when_all(
fetch_headers(req),
fetch_body(req)
) | ex::then([req = std::move(req)](Headers h, Body b) mutable {
req.headers = std::move(h);
req.body = std::move(b);
return std::move(req);
});
})
// 4. Execute the application handler on the CPU pool
| ex::let_value([](HttpRequest req) {
return execute_handler(std::move(req));
})
// 5. Serialize response on the CPU pool
| ex::let_value([](HttpResponse resp) {
return serialize_response(std::move(resp));
})
// 6. Write back on the I/O pool
| ex::continues_on(io_pool)
| ex::let_value([&](RawBytes bytes) {
return async_write(socket, std::move(bytes));
})
// 7. Error recovery: send a 500 response instead of crashing
| ex::let_error([&](std::exception_ptr ep) {
RawBytes err_resp = build_500_response(ep);
return async_write(socket, std::move(err_resp));
});
}
// Entry point for each accepted connection
void serve(Socket socket,
ex::static_thread_pool& io_pool,
ex::static_thread_pool& cpu_pool) {
// sync_wait drives the pipeline to completion; returns nullopt only if stopped
ex::sync_wait(
handle_connection(socket, io_pool.get_scheduler(), cpu_pool.get_scheduler())
);
}starts_on schedules the first operation on io_pool and continues_on migrates the continuation to cpu_pool (or back) without any explicit thread management. The when_all in step 3 launches header and body fetches concurrently and joins before the handler runs, guaranteeing that all data is available without polling or manual synchronisation. If any step throws, the let_error at the end sends a 500 and the structured scope unwinds cleanly.
See Also
std::stop_token— cancellation primitive wired into the stopped channelstd::coroutines— coroutine primitives; senders integrate as awaitablesstd::jthread— C++20 thread with built-instop_source- P2300R10 — full specification adopted into C++26 working draft
- P3149 —
counting_scope— structured dynamic task spawning