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::future and std::async suffer 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 to map).
  • let_value: Chains another asynchronous sender based on the result of the previous sender (similar to flatMap or 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.