C++ Module 18 – C++26 previews: reflection, contracts and safety
C++26 std::execution: senders and receivers
Describe asynchronous work as senders, compose them with then, let_value and when_all, and run C++26 std::execution pipelines today with stdexec.
What you will learn
- Describe asynchronous work as senders, and explain connect, start and the three completion channels
- Compose senders with then, let_value and when_all, and run them with sync_wait
- Compare callbacks, std::future and senders by who keeps the work's state alive
- Reproduce a dangling callback and the error path of when_all, and measure what senders cost to build
- Build std::execution pipelines today with the stdexec reference implementation on both compilers
Before you start
What you’ll learn · Before you start
What you will learn
- Describe asynchronous work as senders, and explain connect, start and the three completion channels
- Compose senders with then, let_value and when_all, and run them with sync_wait
- Compare callbacks, std::future and senders by who keeps the work's state alive
- Reproduce a dangling callback and the error path of when_all, and measure what senders cost to build
- Build std::execution pipelines today with the stdexec reference implementation on both compilers
Before you start
On this page
In the recorded run below, pipeline built is printed before then runs: the sender just(21) | then(…) did nothing
until sync_wait started it, and only then produced 42. std::execution is C++26’s standard model for asynchronous
work, from paper P2300R10. A sender describes work that has not started yet: a value to produce, a function to
apply to it, a thread to run it on. Algorithms such as then, let_value and when_all combine senders into bigger
senders, and a consumer such as sync_wait finally starts the work and waits for it.
The work reports back to a receiver through exactly one of three channels: a value, an error or “stopped”. A scheduler says where work runs: a thread pool, an event loop or a GPU. Because everything the work needs is kept alive until the work completes, asynchronous code gets lifetimes as clear as those of a function call. Neither GCC 16’s libstdc++ nor Apple clang 21’s libc++ ships the sender algorithms yet, so the examples here build against stdexec, the reference implementation.
A sender only describes work; connecting and starting it runs the work
The diagram follows one sender through the four steps of its life:
The four steps in the life of a sender
Text description of the diagram
The diagram shows four steps from top to bottom.
- Describe. An expression such as just(21) | then(double_it) creates a sender, a value that describes work. Nothing runs yet.
- Connect. A consumer such as sync_wait connects the sender to a receiver, the object that will be told the result. The result of connecting is an operation state, which owns everything the work needs while it runs.
- Start. Starting the operation state runs the work, on whatever execution resource its scheduler names.
- Complete, exactly once. The work ends by calling one of three functions on the receiver: set_value with the result, set_error with an exception or an error code, or set_stopped when the work was cancelled.
A sender on its own does nothing. When a consumer wants the result, it connects the sender to a receiver: the
object that will be told how the work ended. Connecting produces an operation state, which holds everything the
work needs, the values and functions captured along the pipeline included, for as long as the work runs. Starting
the operation state runs the work, and the work ends by calling exactly one of set_value, set_error or
set_stopped on the receiver.
That order is the design. Building a pipeline starts no work and, for these algorithms, allocates no memory, so
senders can be passed around, stored and combined before anything happens. And because the operation state lives
until the completion call, the data the work uses cannot disappear under it, as long as the consumer keeps the
operation state alive. sync_wait does exactly that, by blocking until the completion arrives.
Key idea
A sender is a description; the operation state made by connecting it owns the work until exactly one completion call, value, error or stopped, ends it.
Checkpoint
Check: Senders, receivers and operation states
Waiting starts the pipeline, and the join keeps argument order
The program builds a sender, starts it with sync_wait, then runs two computations on a pool of two threads and
joins them:
// Senders describe work; nothing runs until a consumer such as sync_wait starts it. Built against NVIDIA's stdexec,
// whose names stdexec:: and exec:: become std::execution:: in the C++26 standard library.
#include <cstdio>
#include <utility>
#include <exec/static_thread_pool.hpp>
#include <stdexec/execution.hpp>
namespace ex = stdexec;
int main() {
// 1. Describe the work: a value, then a function applied to it. Nothing has run yet.
ex::sender auto answer = ex::just(21) | ex::then([](int x) {
std::printf("then runs\n");
return 2 * x;
});
std::printf("pipeline built\n");
// 2. Start it and wait for the result: sync_wait returns an optional tuple of the values.
auto [value] = ex::sync_wait(std::move(answer)).value();
std::printf("result %d\n", value);
// 3. Two pieces of work on a pool of two threads, joined by when_all.
exec::static_thread_pool pool(2);
ex::scheduler auto on_pool = pool.get_scheduler();
auto square = [](int x) { return x * x; };
ex::sender auto both = ex::when_all(ex::starts_on(on_pool, ex::just(3) | ex::then(square)),
ex::starts_on(on_pool, ex::just(4) | ex::then(square)));
auto [a, b] = ex::sync_wait(std::move(both)).value();
std::printf("squares %d and %d, sum %d\n", a, b, a + b);
} from session import run
# stdexec-fee4d65.tar.gz holds the headers of NVIDIA's stdexec at commit fee4d651 (Apache-2.0, see its SOURCE.txt and
# LICENSE.txt). With git, the same files come from: git clone https://github.com/NVIDIA/stdexec, then
# git -C stdexec checkout fee4d651494014610a277540f209cae56011e47f. -isystem marks them as library headers, so the
# warnings of -Wall -Wextra apply to this file only.
run("""
tar -xzf stdexec-fee4d65.tar.gz
clang++ -std=c++26 -Wall -Wextra -isystem stdexec/include first_pipeline.cpp -o first && ./first
g++-16 -std=c++26 -Wall -Wextra -isystem stdexec/include first_pipeline.cpp -o first-gcc && ./first-gcc
""") Output
$ tar -xzf stdexec-fee4d65.tar.gz $ clang++ -std=c++26 -Wall -Wextra -isystem stdexec/include first_pipeline.cpp -o first && ./first pipeline built then runs result 42 squares 9 and 16, sum 25 $ g++-16 -std=c++26 -Wall -Wextra -isystem stdexec/include first_pipeline.cpp -o first-gcc && ./first-gcc pipeline built then runs result 42 squares 9 and 16, sum 25
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 first_pipeline_session.py
Step through the run and watch where each printed line comes from:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Described, not run. main builds just(21) | then(...), a sender. The lambda in then has not run: the next line printed pipeline built, before anything else.
- sync_wait connects and starts. sync_wait connects the sender to a receiver of its own. The operation state that results holds 21 and the lambda, and starting it runs the work.
- then runs. just sends 21 on to then, whose lambda prints then runs and returns 42. Only now has the work happened.
- One value, on the value channel. The work completes exactly once, with set_value and 42. sync_wait returns an optional tuple holding 42, and main prints result 42; an empty optional would have meant stopped.
- Two squares on the pool. Next, when_all gets two senders, each started on the pool by starts_on: just(3) and just(4), each followed by then(square). sync_wait starts when_all, which starts both.
- when_all joins them. when_all completes once the second square is done, with both results in the order of its arguments, 9 and 16, whichever thread finished first. main printed squares 9 and 16, sum 25.
- The same on both compilers. Apple clang 21 and GCC 16 printed the same four lines, built against the same stdexec headers.
sync_wait returns an optional tuple of the values, here one int, which is empty if the work was stopped.
when_all started both squares and completed with both results once the second one was done, in the order of its
arguments, whichever thread finished first. The program used just, then, when_all, starts_on and sync_wait;
the rest of this list comes later:
just(v...)makes a sender that completes at once with the valuesv....then(f)appliesfto the values and sends its result on.let_value(f)callsfwith the values;freturns another sender, whose completion is passed on.when_all(s...)runs several senders and completes when all have, with all their values.starts_on(sch, s)runsson the schedulersch.continues_on(s, sch)moves the rest of a pipeline ontoschafterscompletes.upon_error(f)turns an error into a value withf;upon_stopped(f)does the same for “stopped”.sync_wait(s)startssand blocks the calling thread until it completes.spawnstarts work in a counting scope, whosejoin()completes when all of it has finished.get_parallel_scheduler()returns a scheduler for a pool that the implementation provides.
Preview: senders with stdexec on both compilers
Specified by the final C++26 draft, shipped by neither standard library yet. Apple clang 21 built every example in
C++26 mode against stdexec at commit fee4d651, and GCC 16.2 built most of them too; the headers ship beside each
session as stdexec-fee4d65.tar.gz, and its algorithms have the same names in std::execution.
Checkpoint
Check: Running and joining a pipeline
Senders own their state, where callbacks and futures leave it to you
The three ways C++ code hands work to another thread differ most in who keeps the data alive; the callbacks lesson covers the first column in detail.
| What differs | Callbacks | std::future |
Senders |
|---|---|---|---|
| Starts | at the call | at std::async, unless deferred |
when a consumer starts it |
| Chaining | nested callbacks | none: get() blocks |
then, when_all |
| Errors | as the API likes | thrown by get() |
own channel |
| Cancel | none | none | stop tokens |
| State | the caller keeps it alive | shared, on the heap | owned until completion |
| Runs on | the API’s choice | a thread, or at get() |
your scheduler |
Read the State row first: it is the one that decides whether a captured reference can outlive its data.
Version note
stdexec puts the algorithms in namespace stdexec and extras such as static_thread_pool in exec. C++26 puts
the same algorithms in std::execution, sync_wait in std::this_thread, and announces them with
__cpp_lib_senders, 202506 in the final draft. Herb Sutter’s trip report from the meeting that finished C++26
warns that the feature is harder to adopt than most, because documentation and helper libraries are still thin.
Checkpoint
Check: Callbacks, futures and senders
Practise: senders, receivers and schedulers
These questions follow a sender from its description to its single completion, then set it beside callbacks and futures.
Practice that adapts to you
Practise this lesson
10 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Errors, cancellation and lifetimes, reproduced Pro: opens with a Pro, Premium or Ultimate pass
A sender completes in exactly one of three ways, and the lifetime of everything it touches follows from that. This part sends work down each channel on both compilers, then reproduces a dangling capture with callbacks and shows why the sender version cannot dangle.
In this part:
- The three completion channels, run on both compilers
- A dangling capture with callbacks, caught by AddressSanitizer
- Structured lifetimes with sync_wait and a counting scope
- Reading a sender compile error, two failures reproduced
2 diagrams · 7 runnable examples (C++, Python) · about 10 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Build cost, a rewrite and the move to std::execution, measured Pro: opens with a Pro, Premium or Ultimate pass
Senders cost something at build time, and porting callback code to them is a skill of its own. This part measures
the build time and program size of a sender pipeline against std::async on both compilers, sets a rewrite with
sample tests, and maps stdexec’s names onto C++26.
In this part:
- What senders cost to build, measured
- From stdexec to std::execution: the names that change
- Containing the dependency until the libraries ship it
- Your turn: rewrite a callback pipeline with senders
6 runnable examples (C++, C++ (orders_callbacks.cpp), C++ (orders_senders.cpp), Python) · about 6 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
What is a sender in C++26 std::execution, and how is it different from a std::future?
A sender is a value that describes asynchronous work that has not started: what to compute, on which scheduler, and where the result goes next. It runs only when a consumer connects it to a receiver and starts it, for example with sync_wait, and it then completes exactly once, with a value, an error or "stopped". A std::future is the other end of work that is already running or deferred: you can wait for it or get its result, which blocks, and the standard gives it no continuations, no cancellation and no say in where the work runs. Senders combine before anything runs, with then, let_value and when_all, carry errors and cancellation on separate channels, and keep their state in an operation state that lives until completion instead of a shared state on the heap.
-
Mid to senior
A service starts I/O with callbacks that capture request state by reference, and crashes under load point at stack memory of functions that have returned. How would std::execution and structured concurrency change the design, and what does the change cost?
-
Mid to senior
A when_all joins three senders: a cache read, a database read and a remote call. The database read throws. Explain what happens to the other two, what sync_wait returns or throws, and how you would return the partial results instead.
-
Senior to staff
Your team wants to use std::execution now, before GCC's or Apple's standard library ships it. Would you adopt stdexec or wait? If you adopt it, how do you contain the dependency so that the move to the standard library is cheap?
Key takeaways
- A sender only describes work; nothing runs until a consumer connects it to a receiver and starts it.
- The work completes exactly once, with a value, an error or “stopped”, and the operation state owns everything it needs until then.
then,let_valueandwhen_allbuild pipelines,starts_onandcontinues_onchoose where each part runs, andsync_waitstarts a pipeline and waits for its result.- Callbacks leave the captured state to the caller and futures share it on the heap; senders own it until completion, and stdexec provides them today while neither standard library ships them.
References
- P2300R10: std::execution (ISO/IEC JTC1/SC22/WG21)
- Working Draft, Programming Languages: C++ (N5050, the C++26 final draft): [exec], [exec.sync.wait], [exec.when.all], [exec.bulk], [exec.par.scheduler] (ISO/IEC JTC1/SC22/WG21)
- stdexec: the reference implementation of std::execution (NVIDIA)
- vcpkg package: stdexec (vcpkg)
- libc++ C++26 Status (LLVM Project)
- GCC 16 Release Series, Changes, New Features, and Fixes (GNU Project)
- AddressSanitizer (stack-use-after-return) (LLVM Project)
- C++26 is done! Trip report: ISO C++ standards meeting in London Croydon, UK (Sutter's Mill)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress