Understanding libunifex: A Deep Dive into Sender/Receiver Concepts and Execution Model
This article provides a comprehensive analysis of libunifex's core concepts—including Sender, Receiver, TypedSender, OperationState, Scheduler, and Structured Concurrency—explaining how they interact to enable lazy, composable asynchronous operations in modern C++.
Introduction
This article dives into the core concepts of libunifex, focusing on the type‑level contracts that enable safe composition of asynchronous operations.
Sender/Receiver Mechanism Overview
In libunifex a Sender represents an asynchronous task. When the task completes it signals a Receiver via one of three completion‑signalling CPOs: set_value(receiver, …) – successful completion with optional values set_error(receiver, error) – failure with an error object set_done(receiver) – cancellation or early termination
The Receiver must be able to accept at least one of these signals.
Structured Concurrency and Lazy Execution
Execution is modelled as a three‑stage pipeline:
DSL → Compiler : connect(sender, receiver) builds an OperationState tree. No code runs yet.
OperationState : The tree holds all state required for the asynchronous operation.
Execute : Calling start(op) on the root traverses the tree and triggers the actual work. Because the work is only started explicitly, the model is inherently lazy.
Initiating an Asynchronous Operation
Calling connect(sender, receiver) returns a concrete OperationState object that owns the combined state of the sender and receiver. The returned object is the root of a tree of sub‑operations; each node knows how to start its own part of the computation. When start() is invoked on the root, the tree is traversed depth‑first, executing each sub‑operation in order.
Receiver Concept
Any type that can be called with one of the three completion CPOs satisfies the Receiver concept. libunifex defines three fine‑grained receiver concepts: value_receiver<Values...> – can handle
set_value(receiver, Values...) error_receiver<Error>– can handle
set_error(receiver, Error) done_receiver– can handle
set_done(receiver) namespace unifex {
// CPOs
inline constexpr unspecified set_value = unspecified;
inline constexpr unspecified set_error = unspecified;
inline constexpr unspecified set_done = unspecified;
template<typename R>
concept __receiver_common = std::move_constructible<R> && std::destructible<R>;
template<typename R>
concept done_receiver = __receiver_common<R> && requires(R&& r) { set_done((R&&)r); };
template<typename R, typename... Values>
concept value_receiver = __receiver_common<R> && requires(R&& r, Values&&... v) { set_value((R&&)r, (Values&&)v...); };
template<typename R, typename Error>
concept error_receiver = __receiver_common<R> && requires(R&& r, Error&& e) { set_error((R&&)r, (Error&&)e); };
}Sender Concept
libunifexdoes not provide a single generic Sender concept because the library cannot know at compile time which completion CPO a particular sender will invoke. Instead it uses an indirect constraint:
namespace unifex {
inline constexpr unspecified connect = unspecified;
template<typename S, typename R>
concept sender_to = requires(S&& s, R&& r) { connect((S&&)s, (R&&)r); };
}A type satisfies sender_to<S,R> if connect(s, r) is well‑formed, i.e., the sender can be bound to the receiver.
TypedSender Concept
Typed senders expose compile‑time traits that describe the values and errors they may produce. The traits are: value_types<Variant, Tuple> – a Variant of possible Tuple signatures for
set_value error_types<Variant>– a Variant of possible error types for
set_error sends_done– true if the sender may complete with
set_done struct some_typed_sender {
template<template<typename...> class Variant, template<typename...> class Tuple>
using value_types = Variant<Tuple<int>, Tuple<std::string, int>, Tuple<>>;
template<template<typename...> class Variant>
using error_types = Variant<std::exception_ptr>;
static constexpr bool sends_done = true;
};These traits allow the compiler to verify that a receiver’s set_value and set_error signatures match the sender’s capabilities, producing a diagnostic at compile time when they do not.
OperationState Concept
An OperationState object owns all runtime state required for a particular asynchronous operation. It is non‑movable and non‑copyable, and provides exactly two operations: start(op) – begins execution of the associated task tree.
Destruction – allowed only before start() or after the task has signalled completion.
namespace unifex {
inline constexpr unspecified start = unspecified;
template<typename T>
concept operation_state = std::destructible<T> && requires(T& op) { start(op); };
}Because the state lives in the OperationState, it can be allocated on the stack, avoiding heap allocation overhead for many use‑cases.
Scheduler Concept
A Scheduler is a lightweight wrapper that ultimately delegates work to an execution context (e.g., a thread or thread‑pool). The basic schedule() CPO creates a Sender that, when started, invokes set_value(void) on its receiver.
namespace unifex {
inline constexpr unspecified schedule = {};
template<typename T>
concept scheduler =
std::is_nothrow_copy_constructible_v<T> &&
std::is_nothrow_move_constructible_v<T> &&
std::destructible<T> &&
std::equality_comparable<T> &&
requires(const T cs, T s) {
schedule(cs);
schedule(s);
};
}TimeScheduler
TimeSchedulerextends Scheduler with time‑based scheduling capabilities. It adds the following CPOs:
typename TimeScheduler::time_point now(scheduler) → time_point schedule_at(scheduler, time_point) → sender<void> schedule_after(scheduler, duration) → sender<void> namespace unifex {
inline constexpr unspecified now = unspecified;
inline constexpr unspecified schedule_at = unspecified;
inline constexpr unspecified schedule_after = unspecified;
template<typename T>
concept time_scheduler =
scheduler<T> && requires(const T s) {
now(s);
schedule_at(s, now(s));
schedule_after(s, now(s) - now(s));
};
}Other Concepts
StopToken : Provides cancellation support via queries such as get_stop_token(receiver).
ManySender/ManyReceiver : Allow multiple completion signals; not covered in this article.
Stream : A lazy, many‑value sender that produces values on demand via next().
Conclusion
The essential CPOs and concepts that compose the libunifex asynchronous framework are: set_value, set_error, set_done – receiver‑side completion signals start – begins execution of an
OperationState connect– binds a sender to a receiver, producing an OperationState Typed sender traits ( value_types, error_types, sends_done) – enable compile‑time validation of value/error compatibility schedule and the time‑based extensions ( now, schedule_at, schedule_after) – scheduler side
Together these concepts provide a composable, lazily‑evaluated, and type‑safe foundation for modern C++ asynchronous programming.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Tencent Cloud Developer
Official Tencent Cloud community account that brings together developers, shares practical tech insights, and fosters an influential tech exchange community.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
