CodeRigor

FREE LESSON

std::execution: senders and receivers

Understand C++26's async model — lazy senders, receivers with three completion channels, operation states — by building a small standards-shaped implementation of just, then, when_all and sync_wait.

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::execution yet (__cpp_lib_senders is 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 namespace ex. 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

C++
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()
Describe, connect, start — then exactly one completion
A sender is inert. connect binds it to a receiver and returns an immovable operation state; start() runs it; it ends with exactly one of the three channels.
Describe, connect, start — then exactly one completion
A sender is inert. 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():

C++
#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:

  • connect returns 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.
  • connect is &&. 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.

C++
#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:

After connect: the whole pipeline is one nested object
Each 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.
After connect: the whole pipeline is one nested object
Each 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.
start() pushes values down the receiver chain
Each arrow is a 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.
start() pushes values down the receiver chain
Each arrow is a 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:

  1. Transform the channel it cares about (values), forward the others (set_error, set_stopped) untouched.
  2. Catch exceptions from user code and turn them into set_error: the completion functions are noexcept.
  3. 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:

C++
#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.

C++
#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);
}
sync_wait keeps everything on its own stack frame
The receiver holds a pointer to state; the operation state lives right next to it. No heap: the frame exists exactly as long as the work it waits for.
sync_wait keeps everything on its own stack frame
The receiver holds a pointer to 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:

C++
auto work = ex::then(pool.get_scheduler().schedule(), [] { return heavy_compute(); });
auto [result] = ex::sync_wait(std::move(work)).value();   // ran on a pool thread

Inside, 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:

C++
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 */
    }
};
when_all: the last child to arrive completes the parent
Each child stores its result, then 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.
when_all: the last child to arrive completes the parent
Each child stores its result, then 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 must remaining be, 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_all complete with? What if both fail?
Answers
  • std::atomic<int> with fetch_sub(1). It returns the old value, so whoever sees 1 was last. With a plain int, both threads can read 2 and write 1 (the operation hangs), or both see 0 (it completes twice).
  • memory_order_acq_rel on that fetch_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:

C++
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 started

set_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.

C++
#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++
// 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_value and 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, and start() 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_wait is the blocking edge. Its state lives on its stack; notify under the lock.
  • when_all uses an atomic counter (acq_rel), and the last arrival completes.
  • Real std::execution is the same model with completion signatures, environments and many more algorithms. Try it with stdexec.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

The design behind std::execution: senders, receivers, then/when_all/sync_wait, and why it's the iterator of async code.

Part 2: cancellation (stop_when, timeouts), schedulers, and how the algorithms are implemented. The names have shifted slightly since 2021; the model hasn't.