C++ Sender and Receiver Model Explained
The sender and receiver model is a standardized framework introduced
to modernize and unify asynchronous programming in C++. Standardized
under the std::execution proposal (P2300), this paradigm
addresses the performance and composability shortcomings of legacy
primitives like std::future, std::promise, and
raw callbacks. By decomposing asynchronous tasks into lazy, highly
composable units of work, senders and receivers provide a generic,
zero-overhead foundation for building complex asynchronous pipelines
across diverse execution targets, including thread pools, GPUs, and
event loops.
The Problem with Legacy C++ Concurrency
Prior to the sender-receiver model, C++ lacked a unified abstraction for asynchronous operations:
std::futureandstd::asyncsuffer from mandatory dynamic allocations, shared state synchronization overhead, and an inability to be chained without blocking threads (via.get()or.wait()).- Callbacks lead to inversion of control, nested structures ("callback hell"), and complex error-handling or lifetime management challenges.
- Coroutines (
co_await) provide structured syntax but require an underlying execution framework to define how tasks are scheduled, cancelled, and transferred between contexts.
The sender-receiver model provides the foundational execution framework that resolves these limitations.
Core Concepts of the Model
The framework relies on three fundamental abstractions: Senders, Receivers, and Schedulers.
1. Schedulers
A scheduler is a lightweight handle to an execution context (such as
a CPU thread pool, an I/O completion port, or a GPU stream). Its primary
responsibility is to produce a sender via a call like
schedule(scheduler). When executed, this sender transitions
the execution of downstream tasks onto the associated execution
resource.
2. Senders
A sender represents a deferred unit of asynchronous work. Unlike
std::async or traditional futures, senders are
lazy: they describe the computation rather than
executing it immediately upon creation.
A sender advertises the types of completions it can produce:
- Values (
set_value): Successful execution yielding zero or more results. - Errors (
set_error): Failure paths yielding an error object. - Stopped (
set_stopped): Cancellation signals indicating the work was prematurely terminated.
Because senders are templates evaluated at compile time, they eliminate the need for virtual function calls and dynamic heap allocations during composition.
3. Receivers
A receiver represents the consumer of the asynchronous result. It is a callback-like abstraction that implements the execution interface matching the three completion channels of a sender:
set_value(receiver, args...)set_error(receiver, error)set_stopped(receiver)
Receivers typically represent the next stage in an asynchronous pipeline, receiving the outputs of the preceding sender.
4. Operation States
The connection between a sender and a receiver forms an
operation state. When a sender and a receiver are
connected using the connect customization point:
auto op_state = std::execution::connect(sender, receiver);This allocates all state required for the operation (often entirely
on the stack). Execution begins only when explicitly triggered via
std::execution::start(op_state).
Composing Asynchronous Pipelines
Senders and receivers achieve high performance and flexibility
through pipeline composition. Higher-order algorithms transform and
combine senders using the pipe operator (|).
Common standard algorithms include:
then: Transforms the value produced by a sender using a synchronous invocable (similar tomap).let_value: Chains another asynchronous sender based on the result of the previous sender (similar toflatMapor monadic bind).transfer: Switches execution to a different scheduler mid-pipeline.when_all: Joins multiple independent senders concurrently, continuing when all have completed.
Example Pipeline Concept
auto pipeline = execution::schedule(thread_pool_scheduler)
| execution::then([] { return fetch_data(); })
| execution::transfer(gpu_scheduler)
| execution::then([](auto data) { return process_on_gpu(data); });
// Connects pipeline to a receiver and starts execution
this_thread::sync_wait(pipeline);In this pipeline, computation begins on a background thread pool, retrieves data, shifts context to a GPU scheduler to perform computations, and synchronizes the final output without intermediate dynamic memory allocations.
Structured Concurrency and Cancellation
The sender-receiver model natively integrates cancellation. Senders
monitor stop_token mechanisms tied to parent scopes. If an
operation is cancelled, the sender transitions directly to invoking
set_stopped on its receiver, bypassing remaining operations
in the pipeline. This enables structured concurrency, ensuring that
asynchronous child tasks are always completed or cancelled before their
enclosing scopes exit, preventing resource leaks and dangling
references.