Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 20 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 8 of 20 minutes free
Free: A sender only describes work; connecting and starting it runs the work · Waiting starts the pipeline, and the join keeps argument order · Senders own their state, where callbacks and futures leave it to you · Practise: senders, receivers and schedulers. With a pass: 2 in-depth parts and 3 interview answers, about 12 minutes.

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:

A sender is described, connected to a receiver, started, then completes once with a value, an error or stopped.1. Describejust(21) | then(double_it)a sender: a value that describes work2. Connectconnect(sender, receiver)an operation state that owns everything the work needs3. Startstart(operation state)the work runs where its scheduler says4. Complete, exactly onceset_value(receiver, 42)the resultset_error(receiver, e)an exception or an error codeset_stopped(receiver)the work was cancelleda consumer such as sync_wait

The four steps in the life of a sender

Text description of the diagram

The diagram shows four steps from top to bottom.

  1. Describe. An expression such as just(21) | then(double_it) creates a sender, a value that describes work. Nothing runs yet.
  2. 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.
  3. Start. Starting the operation state runs the work, on whatever execution resource its scheduler names.
  4. 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

  1. Question 1 of 2 What has run once this line has executed: ex::sender auto answer = ex::just(21) | ex::then(double_it);?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing; the line only describes the work

    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. sync_wait connects and starts it.

  2. Question 2 of 2 Work that was connected and started is running. How can it end?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Exactly once, with one of set_value, set_error or set_stopped on the receiver

    The work ends by calling exactly one of set_value, set_error or set_stopped on the receiver, and the operation state holds everything the work needs until that call.

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:

first_pipeline.cpp C++ · first_pipeline.cpp
// 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);
}
Unpack stdexec, build with both compilers, run Python · first_pipeline_session.py
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:

Interactive · message by message The first pipeline, from description to result Step through main() of first_pipeline.cpp. Each arrow is a hand-over between main, the work it describes and the pool of two threads.
The first pipeline, from description to result, step 7 of 7. Messages so far: main to the work: connect, then start; the work to main: set_value(42); main to a pool of 2 threads: starts_on(on_pool, …) × 2; a pool of 2 threads to main: 9 and 16.mainprinted: sum 25the workprinted: then runsa pool of 2 threadssquares 3 and 4connect, then startset_value(42)starts_on(on_pool, …) × 29 and 16

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. then runs. just sends 21 on to then, whose lambda prints then runs and returns 42. Only now has the work happened.
  4. 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.
  5. 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.
  6. 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.
  7. 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 values v....
  • then(f) applies f to the values and sends its result on.
  • let_value(f) calls f with the values; f returns 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) runs s on the scheduler sch.
  • continues_on(s, sch) moves the rest of a pipeline onto sch after s completes.
  • upon_error(f) turns an error into a value with f; upon_stopped(f) does the same for “stopped”.
  • sync_wait(s) starts s and blocks the calling thread until it completes.
  • spawn starts work in a counting scope, whose join() 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

  1. Question 1 of 2 What does sync_wait return when the sender it waits for completes with "stopped"?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An empty optional

    sync_wait returns an optional tuple of the values, here one int, which is empty if the work was stopped. That is why first_pipeline.cpp calls .value() on it before unpacking the result.

  2. Question 2 of 2 In when_all(starts_on(on_pool, just(3) | then(square)), starts_on(on_pool, just(4) | then(square))), suppose the square of 4 finishes first. What does sync_wait give?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 9 and 16, in the order of when_all's arguments

    when_all started both squares and completed with both results once the second one was done, in the order of its arguments. The program printed squares 9 and 16, sum 25, on both compilers.

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

  1. Question 1 of 2 According to the table, which way of handing work to another thread offers no way to chain the next step without blocking?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: std::future, whose get() blocks

    Callbacks chain by nesting, senders with then and when_all, and std::future not at all: get() blocks until the result is ready.

  2. Question 2 of 2 A request's lambda captures a local by reference. In which design does the work itself keep everything it needs alive until it completes?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Senders, whose operation state owns the state until completion

    The State row decides whether a captured reference can outlive its data: the caller keeps it alive with callbacks, a future shares the result on the heap, and a sender's operation state owns everything until the work completes.

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.

  1. Question 1 of 10 What does connecting a sender to a receiver produce?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: An operation state, which holds everything the work needs while it runs

    Connecting produces an operation state that holds the values and functions captured along the pipeline for as long as the work runs; starting it runs the work.

  2. Question 2 of 10 What is the receiver of a sender?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: The object that is told how the work ended, through set_value, set_error or set_stopped

    The work ends by calling exactly one completion function 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.

  3. Question 3 of 10 A function builds just(4) | then(f) and stores it in a member for later. What has happened so far?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: Nothing has run, and for these algorithms nothing has been allocated

    Building a pipeline starts no work and, for these algorithms, allocates no memory. That is what makes senders cheap to pass around and combine.

  4. Question 4 of 10 While sync_wait waits, why can the data that the work uses not disappear under it?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: The operation state lives until the completion call, and sync_wait blocks until that call arrives

    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 that by blocking.

  5. Question 5 of 10 The next step must start new asynchronous work that depends on the value just produced. Which algorithm builds that?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: let_value, whose function returns another sender

    let_value calls its function with the values; the function returns another sender, whose completion is passed on, which is how work that depends on an earlier result is started.

  6. Question 6 of 10 Which algorithm moves the rest of a pipeline onto another scheduler after a step completes?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: continues_on(s, sch)

    starts_on chooses where a sender starts, and continues_on moves the rest of a pipeline onto another scheduler after a step completes.

  7. Question 7 of 10 In first_pipeline.cpp, why is pipeline built printed before then runs?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: The lambda in then runs only when sync_wait starts the work, after the print

    A sender only describes work: the lambda ran when sync_wait connected and started the pipeline, which is why the output reads pipeline built, then runs, result 42.

  8. Question 8 of 10 According to the table, when does work handed to std::async start?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: At std::async, unless it is deferred

    std::async starts the work at once unless it is deferred, in which case it runs at get(); a sender starts only when a consumer starts it.

  9. Question 9 of 10 According to the table, which of the three can cancel work that has been handed off?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: Senders, through stop tokens

    Callbacks and futures have no way to cancel; senders ask work to stop through stop tokens, and work that stops completes with set_stopped.

  10. Question 10 of 10 A service must start a request, chain two steps after it, and cancel it when the client goes away. Which design does the table support without extra machinery?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: Senders, with then for the steps and stop tokens for the cancellation

    The table's Chaining and Cancel rows decide it: senders chain with then and when_all and cancel with stop tokens, while futures block on get() and neither futures nor callbacks can cancel.

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.

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

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

  2. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

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

    Model answer, Pro. First step and follow-ups

  4. 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?

    Model answer, Pro. First step and follow-ups

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_value and when_all build pipelines, starts_on and continues_on choose where each part runs, and sync_wait starts 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.