Skip to content
C++
Language
since C++26
Expert

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++26

A 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:

ApproachProblems
std::future / std::asyncBlocking destructors, no cancellation, no composition, hidden thread creation
Raw callbacksUnstructured lifetimes, error propagation by convention only
Coroutines aloneNo 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:

cpp
std::execution::set_value(receiver, values...);  // success
std::execution::set_error(receiver, error);       // failure (typically std::exception_ptr)
std::execution::set_stopped(receiver);            // cancellation

Namespace note: std::execution::par, seq, par_unseq — the C++17 parallel algorithm execution policies — share the std::execution namespace but are entirely unrelated to P2300's sender/receiver machinery. They cannot compose with schedule(), then(), or any P2300 algorithm.

Syntax

The mechanical substrate is connect → operation state → start. Pipe syntax is ergonomic sugar over it:

cpp
// 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 fires

sync_wait wraps this for top-level use, blocking the calling thread:

cpp
// 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 == 42

Core sender algorithms (all are customization point objects, CPOs):

cpp
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::variant

Header: P2300 targets <execution> in C++26. For use today, #include <stdexec/execution.hpp> from NVIDIA/stdexec is the reference implementation (namespace stdexec). Examples below use namespace ex = stdexec.

Examples

Basic pipeline:

cpp
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 threads

Error recovery with let_error:

cpp
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:

cpp
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 concurrently

Scheduler transfer — CPU pool → I/O context → CPU pool:

cpp
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:

cpp
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 threads

Coroutine integration (stdexec-specific; the standard interface for C++26 is still being finalized):

cpp
#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:

cpp
// 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:

cpp
// Accept any sender completing with int — prevents type-erasing with std::function
void process(ex::sender_of<int> auto s);  // C++26 concept constraint

Use 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:

cpp
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 destruction

Signal 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:

cpp
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 here

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

cpp
// 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() * 2

exec::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.

cpp
// 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.

cpp
// 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.

cpp
// 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 thread

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

cpp
// 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.

cpp
// 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 known

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

cpp
// 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.

cpp
// 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:

Featurestdexec (P2300)Boost.AsioCoroutines (C++20)
Lazy executionYes — senders are descriptionsPartial — initiating functions post immediatelyYes — coroutine body is lazy
Scheduler-per-operationYes — starts_on / continues_onExecutor per I/O objectNo standard mechanism
Cancellation modelset_stopped + stop token, structuredcancel() on I/O objects, best-effortstop_token via awaitable, ad-hoc
Heterogeneous (GPU)Yes — scheduler abstraction covers GPU streamsNoNo
Heap allocationZero per-operation with correct sendersPer-async-op handler allocationOne allocation per coroutine frame
Learning curveHigh — new mental modelModerate — familiar callback/future hybridModerate — familiar sequential style
C++ standardC++26 (P2300R10)Boost / TS onlyC++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.

cpp
// 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