C++26 adds std::execution (proposal P2300): a model for asynchronous work
built from three ideas — senders describe work, receivers get the
result, and operation states are what actually runs. It replaces
std::future-style code with something lazy, composable, cancellable and
allocation-free.
No standard library ships
std::executionyet (__cpp_lib_sendersis undefined on GCC 16 and Clang 23). So this skill builds a teaching implementation of the core —just,then,when_all,sync_wait, schedulers — in a namespaceex. The real library has exactly this shape, with more generality. Section 9 shows real P2300 code and where to run it today.
1. What's wrong with futures
std::future<int> f = std::async(std::launch::async, [] { return parse(); });
int n = f.get(); // block- Eager. The work starts the moment you call
async. You can't build a pipeline first and decide later where or whether to run it. - A heap-allocated shared state. The producer and the consumer have unrelated lifetimes, so the result lives in a reference-counted block between them. Every future means one allocation and atomic reference counts.
- Poor composition. "When this finishes, do that" needs continuations
(
.then, which never made it into the standard), and every link needs another shared state. - No cancellation, no error channel separate from exceptions, no notion of where work runs.
Senders fix all of these by separating describing work from running it.
2. The model in one table
| Piece | What it is | Key operation |
|---|---|---|
| Sender | A value describing work, e.g. "compute 6 × 7, then add 1". Does nothing by itself. | connect(receiver) → operation state |
| Receiver | Where the result goes: three completion channels plus queries | set_value(vs...), set_error(e), set_stopped(), get_stop_token() |
| Operation state | The connected, runnable thing. Immovable. | start() |
connect binds it to a receiver and returns an immovable operation state; start() runs it; it ends with exactly one of the three channels.The contract: once started, an operation completes by calling exactly one of the three channels, exactly once — possibly on another thread.
set_value(values...): success.set_error(e): failure (here,std::exception_ptr).set_stopped(): cancelled. Not an error — nobody wants the result any more.
3. A receiver and the simplest sender
just(values...) completes immediately with its values. Here is all of it,
plus a receiver that prints what happens. Notice what doesn't happen until
start():
#include <exception>
#include <print>
#include <stop_token>
#include <tuple>
#include <utility>
namespace ex {
struct immovable {
immovable() = default;
immovable(immovable&&) = delete; // operation states never move
};
template <class... Ts>
struct just_sender {
using value_tuple = std::tuple<Ts...>;
std::tuple<Ts...> values;
template <class R>
struct op : immovable {
std::tuple<Ts...> values;
R rcvr;
op(std::tuple<Ts...> v, R r) : values(std::move(v)), rcvr(std::move(r)) {}
void start() noexcept {
std::apply([this](Ts&... vs) { rcvr.set_value(std::move(vs)...); }, values);
}
};
template <class R>
op<R> connect(R r) && { return op<R>(std::move(values), std::move(r)); }
};
template <class... Ts>
just_sender<std::decay_t<Ts>...> just(Ts&&... vs) { return {{std::forward<Ts>(vs)...}}; }
} // namespace ex
struct print_receiver {
void set_value(int a, int b) noexcept { std::println("value: {} {}", a, b); }
void set_error(std::exception_ptr) noexcept { std::println("error"); }
void set_stopped() noexcept { std::println("stopped"); }
std::stop_token get_stop_token() const noexcept { return {}; }
};
int main() {
auto sender = ex::just(6, 7); // describes work
std::println("built");
auto op = std::move(sender).connect(print_receiver{}); // wires it up
std::println("connected");
op.start(); // runs it
}Three things to notice:
connectreturns the operation state by value. It's immovable, but C++17's guaranteed copy elision lets a function return an immovable prvalue. Immovability matters because completions (or other threads) may hold pointers into the operation state.connectis&&. A sender is consumed by connecting it, because its values move into the operation state.- The receiver is moved into the operation state. Everything the operation needs is now in one object.
4. Worked example: then
then(sender, f) completes with f(values...). It doesn't need an operation
state of its own: connecting a then sender just connects the inner sender to
a receiver that wraps ours.
#include <exception>
#include <functional>
#include <print>
#include <stdexcept>
#include <stop_token>
#include <tuple>
#include <type_traits>
#include <utility>
namespace ex {
struct immovable {
immovable() = default;
immovable(immovable&&) = delete;
};
template <class... Ts>
struct just_sender {
using value_tuple = std::tuple<Ts...>;
std::tuple<Ts...> values;
template <class R>
struct op : immovable {
std::tuple<Ts...> values;
R rcvr;
op(std::tuple<Ts...> v, R r) : values(std::move(v)), rcvr(std::move(r)) {}
void start() noexcept { std::apply([this](Ts&... vs) { rcvr.set_value(std::move(vs)...); }, values); }
};
template <class R>
op<R> connect(R r) && { return op<R>(std::move(values), std::move(r)); }
};
template <class... Ts>
just_sender<std::decay_t<Ts>...> just(Ts&&... vs) { return {{std::forward<Ts>(vs)...}}; }
// The receiver then connects to the inner sender: it holds f and *our* receiver.
template <class F, class R>
struct then_receiver {
F f;
R rcvr;
template <class... Ts>
void set_value(Ts&&... vs) noexcept {
try {
rcvr.set_value(std::invoke(std::move(f), std::forward<Ts>(vs)...));
} catch (...) {
rcvr.set_error(std::current_exception()); // exceptions become errors
}
}
void set_error(std::exception_ptr e) noexcept { rcvr.set_error(std::move(e)); } // pass through
void set_stopped() noexcept { rcvr.set_stopped(); } // pass through
std::stop_token get_stop_token() const noexcept { return rcvr.get_stop_token(); } // forward queries too
};
template <class S, class F>
struct then_sender {
S inner;
F f;
template <class R>
auto connect(R r) && {
return std::move(inner).connect(then_receiver<F, R>{std::move(f), std::move(r)});
}
};
template <class S, class F>
then_sender<std::decay_t<S>, std::decay_t<F>> then(S&& s, F&& f) {
return {std::forward<S>(s), std::forward<F>(f)};
}
} // namespace ex
struct print_receiver {
void set_value(int v) noexcept { std::println("value {}", v); }
void set_error(std::exception_ptr e) noexcept {
try { std::rethrow_exception(e); } catch (const std::exception& x) { std::println("error: {}", x.what()); }
}
void set_stopped() noexcept { std::println("stopped"); }
std::stop_token get_stop_token() const noexcept { return {}; }
};
int main() {
auto work = ex::then(ex::then(ex::just(6, 7), [](int a, int b) { return a * b; }),
[](int x) { return x + 1; });
auto op = std::move(work).connect(print_receiver{});
op.start(); // value 43
auto failing = ex::then(ex::just(1), [](int) -> int { throw std::runtime_error("parse failed"); });
auto op2 = std::move(failing).connect(print_receiver{});
op2.start(); // error: parse failed
}What the nesting looks like after connect:
then wraps the downstream receiver in its own receiver; just's operation state holds the values and the outermost receiver. Hover a box: everything is stored inline, nothing on the heap.set_value call. just calls its receiver's set_value(6, 7); each then_receiver applies its function and calls the next receiver. The result, 43, reaches print_receiver.One object, no allocation. The whole pipeline is a single object. Composing senders composes types, not heap nodes. This is the payoff of building the pipeline before running it.
Rules every adaptor like then follows:
- Transform the channel it cares about (values), forward the others
(
set_error,set_stopped) untouched. - Catch exceptions from user code and turn them into
set_error: the completion functions arenoexcept. - Forward queries like
get_stop_token(). Otherwise cancellation stops at the adaptor (one of this skill's bug hunts).
For a void function, then completes with no values: call f, then
rcvr.set_value(). The build task's then handles both cases.
4½. When the next step is itself async: let_value
then's function returns a value. If it returns a sender (another async
step, chosen from the previous result), you need let_value(s, f). When s
completes with values, it calls f(values...) to get the next sender,
connects that sender to the downstream receiver, and starts it. The second
operation state can only be created once the values arrive, so it sits in an
std::optional inside the first operation's state. That's still no
allocation:
#include <exception>
#include <functional>
#include <optional>
#include <print>
#include <stop_token>
#include <tuple>
#include <type_traits>
#include <utility>
namespace ex {
struct immovable {
immovable() = default;
immovable(immovable&&) = delete;
};
template <class... Ts>
struct just_sender {
using value_tuple = std::tuple<Ts...>;
std::tuple<Ts...> values;
template <class R>
struct op : immovable {
std::tuple<Ts...> values;
R rcvr;
op(std::tuple<Ts...> v, R r) : values(std::move(v)), rcvr(std::move(r)) {}
void start() noexcept { std::apply([this](Ts&... vs) { rcvr.set_value(std::move(vs)...); }, values); }
};
template <class R>
op<R> connect(R r) && { return op<R>(std::move(values), std::move(r)); }
};
template <class... Ts>
just_sender<std::decay_t<Ts>...> just(Ts&&... vs) { return {{std::forward<Ts>(vs)...}}; }
template <class S, class R>
using connect_result_t = decltype(std::declval<S>().connect(std::declval<R>()));
// Lets an immovable object be emplaced from a function returning it by value.
template <class Fn>
struct emplacer {
Fn fn;
operator std::invoke_result_t<Fn&>() { return fn(); }
};
template <class F, class Tuple> struct next_sender;
template <class F, class... Ts>
struct next_sender<F, std::tuple<Ts...>> { using type = std::invoke_result_t<F, Ts...>; };
template <class S, class F, class R>
struct let_op : immovable {
using S2 = typename next_sender<F, typename S::value_tuple>::type;
struct first_receiver {
let_op* self;
template <class... Ts>
void set_value(Ts&&... vs) noexcept {
try {
S2 next = std::invoke(std::move(self->f), std::forward<Ts>(vs)...);
self->second.emplace(emplacer{[&] { return std::move(next).connect(std::move(self->rcvr)); }});
} catch (...) {
self->rcvr.set_error(std::current_exception());
return;
}
self->second->start(); // run the step f chose
}
void set_error(std::exception_ptr e) noexcept { self->rcvr.set_error(std::move(e)); }
void set_stopped() noexcept { self->rcvr.set_stopped(); }
std::stop_token get_stop_token() const noexcept { return self->rcvr.get_stop_token(); }
};
F f;
R rcvr;
std::optional<connect_result_t<S2, R>> second; // created when the values arrive
connect_result_t<S, first_receiver> first;
let_op(S&& s, F fn, R r)
: f(std::move(fn)), rcvr(std::move(r)), first(std::move(s).connect(first_receiver{this})) {}
void start() noexcept { first.start(); }
};
template <class S, class F>
struct let_value_sender {
using value_tuple = typename next_sender<F, typename S::value_tuple>::type::value_tuple;
S inner;
F f;
template <class R>
let_op<S, F, R> connect(R r) && { return let_op<S, F, R>(std::move(inner), std::move(f), std::move(r)); }
};
template <class S, class F>
let_value_sender<std::decay_t<S>, std::decay_t<F>> let_value(S&& s, F&& f) {
return {std::forward<S>(s), std::forward<F>(f)};
}
} // namespace ex
struct print_receiver {
void set_value(int a, int b) noexcept { std::println("value: {} {}", a, b); }
void set_error(std::exception_ptr) noexcept { std::println("error"); }
void set_stopped() noexcept { std::println("stopped"); }
std::stop_token get_stop_token() const noexcept { return {}; }
};
int main() {
// The second step depends on the first result: choose a sender from it.
auto work = ex::let_value(ex::just(21), [](int x) { return ex::just(x * 2, x + 1); });
auto op = std::move(work).connect(print_receiver{});
op.start(); // value: 42 22
}The emplacer trick is how you construct an immovable object inside an
optional. optional::emplace(arg) constructs T from arg, and
emplacer converts to T by returning it, so guaranteed elision builds the
operation state in place. The real let_value also keeps the values
themselves alive in its operation state, so the next sender may refer to them.
5. Running it: sync_wait
Something has to start the work and wait. sync_wait(sender) is that edge.
It's the only blocking call in the whole model, and it's where structured
concurrency becomes concrete: its state lives on its own stack frame.
#include <condition_variable>
#include <exception>
#include <mutex>
#include <optional>
#include <print>
#include <stop_token>
#include <tuple>
#include <utility>
#include <variant>
namespace ex {
struct immovable {
immovable() = default;
immovable(immovable&&) = delete;
};
template <class... Ts>
struct just_sender {
using value_tuple = std::tuple<Ts...>;
std::tuple<Ts...> values;
template <class R>
struct op : immovable {
std::tuple<Ts...> values;
R rcvr;
op(std::tuple<Ts...> v, R r) : values(std::move(v)), rcvr(std::move(r)) {}
void start() noexcept { std::apply([this](Ts&... vs) { rcvr.set_value(std::move(vs)...); }, values); }
};
template <class R>
op<R> connect(R r) && { return op<R>(std::move(values), std::move(r)); }
};
template <class... Ts>
just_sender<std::decay_t<Ts>...> just(Ts&&... vs) { return {{std::forward<Ts>(vs)...}}; }
template <class T>
struct wait_state {
std::mutex mu;
std::condition_variable cv;
bool done = false;
std::variant<std::monostate, T, std::exception_ptr> result; // stopped / values / error
};
template <class T>
struct wait_receiver {
wait_state<T>* st;
template <class... Ts>
void set_value(Ts&&... vs) noexcept { st->result.template emplace<1>(std::forward<Ts>(vs)...); signal(); }
void set_error(std::exception_ptr e) noexcept { st->result.template emplace<2>(std::move(e)); signal(); }
void set_stopped() noexcept { signal(); }
std::stop_token get_stop_token() const noexcept { return {}; }
void signal() noexcept {
std::lock_guard lk(st->mu); // notify *while holding the lock*: see below
st->done = true;
st->cv.notify_one();
}
};
template <class S>
std::optional<typename S::value_tuple> sync_wait(S s) {
using T = typename S::value_tuple;
wait_state<T> state; // on this stack frame
auto op = std::move(s).connect(wait_receiver<T>{&state}); // so is the operation
op.start();
{
std::unique_lock lk(state.mu);
state.cv.wait(lk, [&] { return state.done; }); // always with a predicate
}
if (auto* v = std::get_if<1>(&state.result)) return std::move(*v);
if (auto* e = std::get_if<2>(&state.result)) std::rethrow_exception(*e);
return std::nullopt; // stopped
}
} // namespace ex
int main() {
auto [a, b] = ex::sync_wait(ex::just(1, 2)).value();
std::println("{} {}", a, b);
}state; the operation state lives right next to it. No heap: the frame exists exactly as long as the work it waits for.Why notify under the lock? If the completing thread released the lock first
and then called notify_one(), the waiting thread could wake (spuriously, or
from a previous check), see done == true, return, and destroy state —
condition variable included. The notify_one() would then touch freed stack
memory. Holding the lock until after notifying makes that impossible.
6. Where work runs: schedulers
A scheduler is a handle to an execution context (a thread pool, an event
loop, a GPU stream). It has one operation: schedule(), which returns a
sender that completes on that context. Everything else composes with it:
auto work = ex::then(pool.get_scheduler().schedule(), [] { return heavy_compute(); });
auto [result] = ex::sync_wait(std::move(work)).value(); // ran on a pool threadInside, schedule()'s operation state is the queue node. start() pushes
this onto the context's intrusive queue, and a worker later calls its
execute(), which completes the receiver. Queueing allocates nothing, because
the operation state, living in sync_wait's frame, is the node. The build
task gives you a run_loop (runs work on whichever thread calls run()) and
a thread_pool built this way.
7. Faded example: when_all
when_all(a, b) starts both senders and completes when both have. The
children may finish on different threads, in either order, so this is
where concurrency gets real:
template <class R>
struct op : immovable {
template <std::size_t I>
struct child_receiver { // one per child; points back at the parent
op* self;
void set_value(auto&&... vs) noexcept { /* store into results<I> */ self->arrive(); }
void set_error(std::exception_ptr e) noexcept { self->fail(e); self->arrive(); }
void set_stopped() noexcept { self->stop(); self->arrive(); }
std::stop_token get_stop_token() const noexcept { return self->rcvr.get_stop_token(); }
};
std::tuple<std::optional<values_of<S1>>, std::optional<values_of<S2>>> results;
std::atomic<int> remaining{2};
R rcvr;
connect_result_t<S1, child_receiver<0>> op1; // children's op states are members:
connect_result_t<S2, child_receiver<1>> op2; // no allocation, and `this` is stable
void start() noexcept { op1.start(); op2.start(); }
void arrive() noexcept {
if (/* ??? — am I the last child to arrive? */) return;
/* ??? — complete rcvr: both values, or the error, or stopped */
}
};fetch_sub(1, acq_rel). Whoever sees 1 was last; its acquire makes the other child's stored result visible, so it can call set_value with both.Questions to answer before you write it (in the build task):
- Two threads may call
arrive()at the same moment. What type mustremainingbe, and which operation tells you "I was last"? - How does the last thread know the other child's stored results are visible to it?
- If one child fails and the other is stopped, what should
when_allcomplete with? What if both fail?
Answers
std::atomic<int>withfetch_sub(1). It returns the old value, so whoever sees1was last. With a plainint, both threads can read 2 and write 1 (the operation hangs), or both see 0 (it completes twice).memory_order_acq_relon thatfetch_sub. Each child stores its results before its release, and the last arrival's acquire makes those stores visible.- Error beats stopped. With two errors, report the first (record it with a compare-exchange on an atomic outcome), and complete exactly once.
8. Cancellation: stop tokens and set_stopped
Receivers answer get_stop_token(). Work that can be cancelled checks the
token: schedule() checks it before running and completes with
set_stopped() instead. Adaptors forward the token unchanged, so a stop
request at the top reaches the bottom:
std::stop_source src;
auto r = ex::sync_wait(ex::then(pool.get_scheduler().schedule(), f), src.get_token());
// another thread: src.request_stop(); → r == std::nullopt if f hadn't startedset_stopped is its own channel because cancellation isn't failure. A
cancelled request shouldn't be logged as an error, retried, or reported to the
user. std::stop_callback lets long-running work react to a request
immediately, rather than polling stop_requested().
The real when_all goes further: when one child fails, it requests stop on
its other children through an internal std::inplace_stop_source, so the
work you no longer need stops early. The version here only forwards the
outer token.
9. The real thing
This is P2300 as standardised in C++26. These fragments are not compiled
here, because no installed library ships it. To run them today, use NVIDIA's
reference implementation, stdexec (header-only, on GitHub as
NVIDIA/stdexec), which also has a pool, a GPU scheduler and coroutine
support.
#include <execution>
namespace stdexec = std::execution;
stdexec::scheduler auto sch = pool.get_scheduler();
stdexec::sender auto work =
stdexec::schedule(sch)
| stdexec::then([] { return read_config(); })
| stdexec::let_value([](Config c) { // the next step is itself async
return stdexec::when_all(fetch(c.a), fetch(c.b));
})
| stdexec::upon_error([](std::exception_ptr) { return fallback(); });
auto [a, b] = std::this_thread::sync_wait(std::move(work)).value();// C++26 std::execution::task: coroutines that are senders.
std::execution::task<int> total(Scheduler sch) {
co_await stdexec::schedule(sch); // hop onto the pool
int a = co_await fetch_count("a"); // await any sender
int b = co_await fetch_count("b");
co_return a + b;
}Differences from this skill's teaching version:
Teaching ex |
Real std::execution |
|---|---|
using value_tuple = std::tuple<Ts...> |
completion_signatures<set_value_t(Ts...), set_error_t(E), set_stopped_t()> — several of each, checked at compile time |
get_stop_token() member |
get_env(receiver): an environment answering queries (stop token, scheduler, allocator) |
member connect, start, set_value |
customisation point objects (std::execution::connect, set_value, ...) |
then(s, f) |
also s | then(f) (pipeable adaptor closures) |
when_all forwards the outer token |
when_all cancels siblings on failure |
10. Why this design wins
- Lazy. Build the whole pipeline, then decide where and when it runs. Senders are values: store them, return them, pass them around.
- Allocation-free. Operation states nest by value. The whole async
operation is one object whose lifetime is a scope: structured
concurrency. Nothing outlives the
sync_wait(or the coroutine) that started it. - Three channels. Value, error and stopped are distinct, and every adaptor knows which one it handles.
- Schedulers are explicit. Where code runs is part of the pipeline, not hidden in a global pool.
- Composable.
then,when_all,let_valueand friends are generic algorithms, like the STL's, but for async work.
Summary
- A sender describes work and is lazy.
connect(receiver)gives an immovable operation state, andstart()runs it. - A receiver gets exactly one of
set_value/set_error/set_stopped, exactly once, possibly on another thread. - Adaptors transform one channel, forward the others, catch exceptions, and forward queries such as the stop token.
sync_waitis the blocking edge. Its state lives on its stack; notify under the lock.when_alluses an atomic counter (acq_rel), and the last arrival completes.- Real
std::executionis the same model with completion signatures, environments and many more algorithms. Try it withstdexec.