| Document #: | P4223R1 [Latest] [Status] |
| Date: | 2026-10-23 |
| Project: | Programming Language C++ |
| Audience: |
SG1 SG14 LEWG |
| Reply-to: |
Ian Petersen <ispeters@gmail.com> |
connect
The standard library offers two ways to declare an asynchronous
function in one place and define it in another, and each falls short.
Returning a sender whose type is specific to the function’s
implementation puts the structure of that implementation in its
declaration, so the implementation can’t change without changing the
interface, and overrides of a virtual function can’t differ in
structure.
std::execution::task
keeps the implementation out of the declaration, but it’s a coroutine
type, and coroutines don’t fit every domain. This paper argues for a
third way that avoids both shortcomings, sets out the requirements a
solution must meet, compares candidate designs against them, and
proposes
std::execution::function
as the best fit. Its main advantage is how it allocates: its only
allocation is the activation frame that separating declaration from
definition requires, made when the operation is connected, through an
allocator that the caller’s environment chooses.
This paper also proposes a minimal query,
std::execution::get_frame_allocator,
so the caller’s environment can choose how activation frames are
allocated. The query is inspired by Vinnie Falco et al.’s work
in [P4003R3] and [P4172R1]. The rest of a
frame-allocation design, ideally one shared by coroutines and senders,
is left to a later paper.
SG1 saw R0 in Brno; Changes since R0
summarizes what changed. The part of this revision that most needs SG1’s
review is The Exception
Contract of
connect.
Beyond whether to forward this paper to LEWG, this revision also asks
SG1:
function from outside, even though
such a transformation can only wrap the separately compiled
implementation. Is that worth solving? If so, is the explicit opt-out
sketched in Domains and Type
Erasure the right direction? Does it belong in this paper or its
own?This revision asks SG14 for feedback on whether
connect may
throw, who chooses what happens when a frame can’t be allocated, and
what each operation costs at scale (Feedback from SG14).
This revision asks LEWG two questions (Polls for LEWG). The first is whether
function’s
connect
should be
noexcept
exactly when nothing it does can throw, as this paper proposes, or may
throw for every function. The second
is whether a later paper should report a failed frame allocation as a
completion. Interface Discussion
argues the decisions that shape the rest of
function’s interface.
R1 changes function’s design and
rewrites the paper’s argument for it.
sender_tag form
is proposed. R0 also proposed a shorter form whose completions
were computed from an ordinary function type; it is now a future
direction (A Convenience Form). A
sender_tag call type may now be
noexcept,
which R0 didn’t allow.connect
has an exception contract. In R0,
connect was
never
noexcept. In
R1, it is
noexcept
exactly when nothing it does can throw, including allocating the frame,
and the constructors of a
noexcept
function check that promise,
including any domain’s transformation of the implementation’s sender (The Exception Contract of
connect).function can borrow an
object. R0 accepted any trivially copyable callable that fit
its factory storage, two pointers in R0’s exposition-only code,
including a lambda that captures
this. R1
accepts a pointer to function, a pointer to member, or an empty,
trivially copyable callable. A call type may be qualified
&,
const &,
&&
or
const &&,
as a member function is; the constructor then takes the object first,
and the factory receives it before the curried arguments. R0 accepted no
qualified call types (Member Functions,
Factories).T&&.
R1 takes a parameter of type T as
const T&
when T is trivially copy- and
move-constructible, so such an lvalue can be passed without
std::move
(The Call). The constructors that take a pointer
as the factory are neither
constexpr
nor
noexcept,
and have a hardened precondition that the pointer isn’t null. R0’s one
constructor was
constexpr
and conditionally
noexcept, as
R1’s others are.get_frame_allocator with an
allocator that allocates from the caller’s, whether or not the
function declares the query, so a
function used by the implementation
allocates its frame from the same place. A type that declares the query
decides the type of the allocator the frame is allocated through. The
frame is freed before the operation state’s destructor returns, and the
operation state is constructed in it without uses-allocator construction
(What the Wording
Guarantees).attrs declares only
completion domains. R0 accepted any query, and its example
declared get_domain_t (Required Queries and
Completion Domains).function allocates nothing, and
connect
allocates the frame through the caller’s frame allocator, at most once.
The wording for get_frame_allocator
keeps R0’s, with a note on why its allocator is separate from
get_allocator’s.task and a hand-written sender to
R0’s any_sender and
function.connect,
Domains and Type Erasure, Open Questions and Polls and Implementation Experience.just-family senders, which obscured
what function is for. Motivation’s lookups wait on a timer.connect
returns an operation state of unknown size. any_sender
and function
say that it returns one of fixed size that owns the frame, and that
start is the indirect call.this gave a
function a lifetime dependence that
its type didn’t show. Member Functions
and Factories make the type show it.function suggests
something that runs, and it was unclear why the interface is a function
call. A Third Way reads the declaration as
the call it parallels, and function
describes a function as a pending
call.function
and any_sender serve the same uses,
and whether one subsumes the other. Conclusion
answers.any_sender makes ABI stability
easier when types aren’t stable. Feature
Comparison concedes it.noexcept
should remove the stopped completion as well as the error completion. A Convenience Form takes it up.Declaring a function in one place and defining it in another is useful because one declaration of a virtual function can have many definitions, and any function’s definition can change without its callers being recompiled. The standard library offers two ways to separate an asynchronous function’s declaration from its definition, and each falls short. A running example shows how, and what a third way looks like.
Consider an interface to a key-value store with a virtual
get:
#include <optional>
#include <string>
struct kv_store {
virtual ~kv_store() = default;
virtual std::optional<std::string> get(std::string key) const = 0;
};Two implementations of this interface appear throughout this paper:
#include "kv_store.hpp"
class remote_store final : public kv_store {
public:
// asks a remote server, so it waits on the network
std::optional<std::string> get(std::string key) const override;
// ...
};#include "kv_store.hpp"
class cache final : public kv_store {
public:
explicit cache(kv_store const& backing);
// answers hits immediately and forwards misses to the backing store
std::optional<std::string> get(std::string key) const override;
private:
kv_store const* backing_;
// ...
};What should an asynchronous get
return so that these declarations keep their shape?
One option for the return type of an asynchronous
get is a sender whose type is
specific to the implementation. There are two ways to write one.
The simpler way is to compose standard senders. The declaration can
name the composed type with
decltype and
leave the definition in a source file, with the leaf logic in a named
function object defined out of line. Here is
remote_store written that way, with
a timer standing in for network latency and a
std::map
standing in for the server’s data. It stands alone for now; the first
consequence below explains why it can’t override an asynchronous
kv_store::get.1
#include <exec/timed_thread_scheduler.hpp>
#include <stdexec/execution.hpp>
#include <chrono>
#include <map>
#include <optional>
#include <string>
#include <utility>
namespace ex = STDEXEC;
// A function object that looks up a key. Ordinarily this would be a lambda in
// get's definition, but get_sender (below) must name its type, so it's a class
// whose call operator is defined in remote_store.cpp.
struct remote_lookup {
// returns the value stored under key, or nullopt if there isn't one
std::optional<std::string> operator()() const;
// the data to search, which belongs to the remote_store that made this lookup
std::map<std::string, std::string> const* data;
std::string key;
};
class remote_store {
public:
remote_store(exec::timed_thread_scheduler latency,
std::map<std::string, std::string> data);
// The type get returns. It spells out how get is implemented: wait for a
// timer, then call remote_lookup. Only types matter in decltype, so the
// duration is a placeholder.
using get_sender = decltype(
exec::schedule_after(std::declval<exec::timed_thread_scheduler const&>(),
std::chrono::milliseconds{}) |
ex::then(std::declval<remote_lookup>()));
// The returned sender refers to this store's data, so the store must
// outlive the lookup, but nothing in this declaration says so.
get_sender get(std::string key) const;
private:
// stand-ins for a remote server's network latency and data
exec::timed_thread_scheduler latency_;
std::map<std::string, std::string> data_;
};#include "remote_store.hpp"
#include <utility>
remote_store::remote_store(exec::timed_thread_scheduler latency,
std::map<std::string, std::string> data)
: latency_(latency), data_(std::move(data)) {}
std::optional<std::string> remote_lookup::operator()() const {
if (auto it = data->find(key); it != data->end()) {
return it->second;
}
return std::nullopt;
}
remote_store::get_sender remote_store::get(std::string key) const {
return exec::schedule_after(latency_, std::chrono::milliseconds{5}) |
ex::then(remote_lookup{&data_, std::move(key)});
}remote_lookup::operator()
can change without touching the header, but the
get_sender type names every
algorithm get uses, in order, so the
structure of get’s implementation is
in its declaration. Leaking that structure into the interface has three
consequences:
All overrides of a virtual function must have the same
structure. kv_store must
choose one return type for get, and
every override must return exactly that type, so with this approach,
kv_store chooses one
structure for every implementation, too. If it chooses
remote_store::get_sender,
every override must return a sender that waits for a timer before
calling remote_lookup, so
cache::get,
which answers hits immediately and forwards misses to the backing store,
can’t override
kv_store::get.
Choosing a more flexible return type doesn’t solve the problem.
Replacing remote_lookup with
std::function
lets only the synchronous step at the leaf vary, so every implementation
inherits remote_store’s timer, and
on a miss,
cache::get
can wait for the backing store only by blocking. A type with optional
steps, or a variant over several structures, permits only the structures
kv_store’s author anticipates. Even
if the author anticipates cache,
cache::get
forwards misses to another kv_store,
so the operation state of
kv_store::get’s
sender would have to contain another of its own type. It can hold that
nested operation state only through an allocation, and a type that
allocates its children’s operation states and dispatches among
structures is already type erasure, written by hand for a fixed list of
structures. So kv_store can’t be
written with implementation-specific senders.
Changing the structure changes the interface.
Even when get isn’t virtual,
changing an implementation detail, like adding a timeout or retrying
failures, changes get_sender, and
with it the ABI, so every caller must be recompiled.
The types are hard to write and to read. The
get_sender alias has to repeat the
definition’s expression closely enough to produce the same type, and
diagnostics name the fully composed type.2
The other way to write an implementation-specific sender is by hand.
Its type need not name any algorithms, and it can still reuse the
standard ones if its operation state connects a pipeline of standard
senders to the receiver and holds the pipeline’s operation state as a
member. However, since
connect is a
template over the receiver, the type of the operation state it returns
must be defined in the header, along with the types of its members. The
type of the pipeline’s operation state names the pipeline’s type, so
that member puts the pipeline’s type in the header, too. That is the
approach of Composing Standard
Senders again, just one level deeper.
Keeping the pipeline out of the header takes more work. It starts like this:
#include <stdexec/execution.hpp>
#include <exception>
#include <optional>
#include <string>
#include <utility>
namespace ex = STDEXEC;
class remote_store;
// the caller's receiver, type-erased
class erased_receiver {
public:
using receiver_concept = ex::receiver_t;
template <class Receiver>
explicit erased_receiver(Receiver& rcvr) noexcept : rcvr_(&rcvr) {
// ... and keep a pointer to a table of Receiver's completion functions
}
void set_value(std::optional<std::string> value) && noexcept;
void set_error(std::exception_ptr error) && noexcept;
void set_stopped() && noexcept;
// ... and get_env, which must erase the receiver's environment, too
private:
void* rcvr_;
// ... the pointer to that table
};
// the operation state's non-template part, whose member functions are defined
// in remote_store.cpp
class get_operation_base {
public:
get_operation_base(remote_store const& store, std::string key,
erased_receiver rcvr);
get_operation_base(get_operation_base&&) = delete;
void start() & noexcept;
private:
// the pipeline's operation state must live here, as one of:
// - a member, whose type names the pipeline,
// - a fixed-size buffer, whose size becomes part of the ABI, or
// - an allocation, which needs a policy for where the memory comes from
};
template <class Receiver>
struct receiver_holder {
Receiver rcvr;
};
// receiver_holder comes first so the receiver exists before
// get_operation_base's constructor can use it
template <class Receiver>
class get_operation : receiver_holder<Receiver>, public get_operation_base {
public:
using operation_state_concept = ex::operation_state_t;
get_operation(remote_store const& store, std::string key, Receiver rcvr)
: receiver_holder<Receiver>{std::move(rcvr)},
get_operation_base(store, std::move(key),
erased_receiver(this->rcvr)) {}
};
struct get_sender {
using sender_concept = ex::sender_t;
template <class Self, class... Env>
static consteval auto get_completion_signatures() noexcept {
return ex::completion_signatures<
ex::set_value_t(std::optional<std::string>),
ex::set_error_t(std::exception_ptr), ex::set_stopped_t()>();
}
template <class Receiver>
get_operation<Receiver> connect(Receiver rcvr) && {
return {*store, std::move(key), std::move(rcvr)};
}
remote_store const* store;
std::string key;
};
class remote_store {
public:
get_sender get(std::string key) const;
// ...
};get_operation_base’s comment
lists three places the pipeline’s operation state could live. A member
is the one just ruled out. A buffer limits the size of every
implementation and can’t grow without changing the ABI, so in general
the pipeline’s operation state must be allocated, which needs a policy
for where the memory comes from. If the policy comes from the receiver’s
environment, get_operation_base’s
constructor needs the receiver, which is why the receiver lives in
receiver_holder, a base class
constructed first. For the abstract
kv_store::get
to return a hand-written sender, every override must return the same
type, so get_operation_base must
also dispatch to each implementation’s pipeline. Allocating the
pipeline’s operation state, erasing the receiver and its environment,
and dispatching to each implementation is type erasure, written by hand
for one signature. Writing It
Yourself completes that type erasure, and the result is a
specialization of the type this paper proposes.
taskAnother option for the return type of an asynchronous
get is
std::execution::task,
a sender that is also a coroutine type:
#include <stdexec/execution.hpp>
#include <optional>
#include <string>
namespace ex = STDEXEC;
struct kv_store {
virtual ~kv_store() = default;
virtual ex::task<std::optional<std::string>> get(std::string key) const = 0;
};remote_store declares
get with the interface’s return type
and defines it as a coroutine:
#include "kv_store.hpp"
#include <exec/timed_thread_scheduler.hpp>
#include <map>
class remote_store final : public kv_store {
public:
remote_store(exec::timed_thread_scheduler latency,
std::map<std::string, std::string> data);
// The returned task refers to this store, so the store must outlive the
// lookup, but nothing in this declaration says so.
ex::task<std::optional<std::string>> get(std::string key) const override;
private:
exec::timed_thread_scheduler latency_;
std::map<std::string, std::string> data_;
};#include "remote_store.hpp"
#include <chrono>
#include <utility>
remote_store::remote_store(exec::timed_thread_scheduler latency,
std::map<std::string, std::string> data)
: latency_(latency), data_(std::move(data)) {}
ex::task<std::optional<std::string>> remote_store::get(std::string key) const {
co_await exec::schedule_after(latency_, std::chrono::milliseconds{5});
if (auto it = data_.find(key); it != data_.end()) {
co_return it->second;
}
co_return std::nullopt;
}task keeps the implementation out
of the declaration. remote_store and
cache can both override
kv_store::get,
and either body can change without changing the interface. Designs Considered examines
task’s shortcomings in detail, but
one is simple enough to state here:
task is a coroutine type, so it
can’t be used where coroutines aren’t available. GPU device code is the
clearest example; NVIDIA’s CUDA documentation lists coroutines among the
C++20 features that NVCC doesn’t support in device code3,
and AMD’s HIP documentation says that coroutines aren’t available in
device code4.
A third option, which this paper proposes, is a sender type that lets
the asynchronous get be declared
much like the synchronous one:
#include "async_fn.hpp"
#include <optional>
#include <string>
struct kv_store {
virtual ~kv_store() = default;
virtual async_fn<std::optional<std::string>(std::string) const &>
get(std::string key) const = 0;
};Calling
get(key)
doesn’t look anything up. Like
just(value)
or
when_all(a, b),
get curries its arguments into the
sender it returns, and the work happens only once that sender is
connected and started. async_fn’s
template argument is a function type that parallels the type of the
synchronous get, and its parts say
what the sender holds and how it completes:
std::optional<std::string>
is the type of the value the sender completes with on success, the
synchronous get’s return type.(std::string)
says that the sender curries the key.const &
says that the sender also curries a reference to the store, which must
therefore outlive the lookup (Member
Functions). get’s declarations
in Composing Standard Senders
and Returning
task leave that dependence
unstated.The other completions aren’t in the signature, just as the exceptions
that the synchronous get can throw
aren’t in its signature. The sender reports such an exception
as a
std::exception_ptr.
The lookup can also be stopped, which has no synchronous counterpart.
async_fn adds both completions.
async_fn is local to this paper
and isn’t proposed (A Convenience Form
says why). It is an alias for a specialization of the type this paper
proposes,
std::execution::function:
#include <exec/function.hpp>
#include <stdexec/execution.hpp>
#include <exception>
namespace ex = STDEXEC;
template <class Sig>
struct async_fn_for;
template <class R, class... Args>
struct async_fn_for<R(Args...) const &> {
using type = exec::function<
ex::sender_tag(Args...) const &,
ex::completion_signatures<ex::set_value_t(R),
ex::set_error_t(std::exception_ptr),
ex::set_stopped_t()>>;
};
template <class Sig>
using async_fn = async_fn_for<Sig>::type;As with task,
remote_store’s declaration repeats
the interface’s, and the structure of its implementation stays in its
source file:
#include "kv_store.hpp"
#include <exec/timed_thread_scheduler.hpp>
#include <map>
class remote_store final : public kv_store {
public:
remote_store(exec::timed_thread_scheduler latency,
std::map<std::string, std::string> data);
async_fn<std::optional<std::string>(std::string) const &>
get(std::string key) const override;
private:
exec::timed_thread_scheduler latency_;
std::map<std::string, std::string> data_;
};#include "remote_store.hpp"
#include <chrono>
#include <utility>
remote_store::remote_store(exec::timed_thread_scheduler latency,
std::map<std::string, std::string> data)
: latency_(latency), data_(std::move(data)) {}
async_fn<std::optional<std::string>(std::string) const &>
remote_store::get(std::string key) const {
return async_fn<std::optional<std::string>(std::string) const &>(
*this, std::move(key), [](remote_store const& self, std::string key) {
return exec::schedule_after(self.latency_,
std::chrono::milliseconds{5}) |
ex::then([data = &self.data_, key = std::move(key)] {
std::optional<std::string> value;
if (auto it = data->find(key); it != data->end()) {
value = it->second;
}
return value;
});
});
}The cache class is declared as in
A Key-Value Store, with its members
filled in and get’s return type
changed:
#include "cache_entries.hpp"
#include "kv_store.hpp"
class cache final : public kv_store {
public:
explicit cache(kv_store const& backing);
// answers hits immediately and forwards misses to the backing store
async_fn<std::optional<std::string>(std::string) const &>
get(std::string key) const override;
private:
kv_store const* backing_;
mutable cache_entries entries_;
};remote_store and
cache both keep the shape of their
synchronous declarations, overriding
get as before.
Keeping that shape doesn’t by itself make
async_fn the best way to declare
get. Requirements states what a solution must
provide, Designs Considered implements
cache with each candidate,
function included, and Comparison measures the candidates against those
requirements.
Many of the requirements below are properties of synchronous functions that a solution must keep. A solution either meets each one or doesn’t, except for the seventh and eighth, which are matters of degree.
Separation of interface and implementation. An asynchronous function’s declaration must not depend on its implementation any more than a synchronous function’s does. That independence lets overrides of a virtual function differ in structure and lets a function’s definition change without changing its interface.
Composable with other senders. Calling an
asynchronous function must produce a sender so that its result can be
consumed like any other sender. Its consumers include the standard
algorithms and implementations like
cache::get’s
that forward to another asynchronous function. A solution that produced
anything else would solve one problem by creating another.
Fully general. A solution must not restrict
which asynchronous functions can be declared beyond what is forced by
keeping the implementation out of the declaration. Any completion
signatures and any queries of the caller’s environment must be
declarable. Real interfaces need this; nvexec’s stream senders, for
example, report CUDA failures as
set_error_t(cudaError_t),
so a design that fixes the error type couldn’t declare a function
implemented with stream senders.
Broadly applicable. A solution must not depend
on a language feature missing from any domain that senders serve. GPU
device code has no coroutines today (Returning
task), and some domains build
without exceptions; stdexec’s own continuous integration includes builds
with
-fno-exceptions.5
Allocation like a stack frame. A synchronous call allocates only its stack frame, from memory the calling thread already owns, and frees it before the call returns. An asynchronous operation can outlive the call that started it, so its frame must generally be allocated dynamically. The rest of that model must still hold.
when_all’s
children.Falco et al. make the same points: [P4088R1] (section 7.4) counts allocations per operation, [P4003R3] (section 3.5) argues that the frame allocator must be first-class, [P4127R0] (section 7.2) shows that an allocator parameter breaks separate compilation and virtual dispatch, [P4172R1] (section 8.3) rewrites the frame allocator’s out-of-band state each time a coroutine resumes so that it reaches every frame in a chain, and [P4172R1] (section 6.3.1) observes that the frames of a chain of coroutines nest like stack frames.
A precise failure contract. Completion
signatures already state precisely how an operation can fail once it has
started. Failure can also occur before it starts, when the asynchronous
function is called or when the sender it returns is connected. A
solution must be able to state that neither step throws, as
noexcept
does for a synchronous call. An asynchronous function’s declaration can
state with
noexcept
that calling it doesn’t throw, but not that connecting the sender it
returns doesn’t. Only the sender’s type can state that, because a
generic algorithm that connects the sender sees only its type, and
decides from it whether its own
connect
throws.
Borrowed state visible in the interface. An operation can depend on objects outliving it, such as the implicit object or the referent of an argument, and the more clearly the interface says so, the better. By convention, a synchronous function stops using what it borrows before it returns, so the owners of borrowed objects need only make the objects’ lifetimes enclose the calls they’re lent to, which C++’s lexical scoping makes easy. An asynchronous function routinely breaks that convention: the sender it returns keeps borrowing what was lent to the function until the operation built from that sender completes. Its caller must keep those objects alive deliberately, so it needs to know what they are. The type of the sender returned is the clearest place to name them, because the sender, not the declaration, is what outlives the call.
Ergonomics. Interfaces occur at every layer of a program, so the effort of declaring and implementing them adds up.
Standard vocabulary. Libraries must be able to use the solution in their interfaces without agreeing on anything beyond the standard, and without writing boilerplate for each interface. Without a standard type, each library would define its own, incompatible with every other library’s.
Each design below implements
cache, the decorator from A Third Way whose
get answers hits immediately and
forwards misses to its backing store. Motivation already showed
remote_store with
task and with
function, but
cache is the harder case because its
two paths complete differently and one of them calls through the
interface it implements. Each design’s
cache.hpp
header is a copy of A Third Way’s with
get’s return type changed, so those
headers are elided. cache’s
cache_entries member, also elided,
is a map guarded by a mutex. The first design writes
cache::get
as a coroutine; the other three write it as one sender expression and
differ only in how they erase its type. This section describes how each
design works and what it costs; Comparison
evaluates them against Requirements.
taskWith
kv_store::get
declared as in Returning
task,
cache::get
is a coroutine:
#include "cache.hpp"
#include <utility>
cache::cache(kv_store const& backing) : backing_(&backing) {}
ex::task<std::optional<std::string>> cache::get(std::string key) const {
if (auto value = entries_.find(key)) {
co_return value;
}
auto value = co_await backing_->get(key);
if (value) {
entries_.insert(std::move(key), *value);
}
co_return value;
}It reads like the synchronous
cache::get
it replaces: a hit returns early, and a miss waits for the backing store
where the synchronous version would have called it.
Calling get allocates the
coroutine’s frame, which holds the promise, the parameters, the locals
that live across a suspension point, and the state of whatever the body
is awaiting, so the frame is the only allocation a call needs. However,
it is allocated when get is called,
before the returned task is
connected to a receiver, so the caller’s environment can’t choose its
frame allocator. A caller can choose the frame allocator in two
other ways, and both show up in
kv_store::get’s
declaration. It can pass an allocator as an
allocator_arg argument, which needs
a parameter that every override must accept and every caller must
supply, though the parameter has nothing to do with looking up a key.
Or, following Falco et al.’s coroutine execution model ([P4003R3], section 4.5;
[P4172R1], section 8.2), the
task type can name an environment
whose allocator type finds the allocator out of band, for example in a
thread-local variable that the caller sets before calling
get. Calls made inside the body then
see whatever that variable holds when they’re made, unless something
rewrites it each time a coroutine resumes ([P4172R1], section 8.3).
[P4007R3] (section 3)
explains why letting the environment choose instead would take a change
to the language.
[P4088R1] (section 7.9) and
[P4100R1] (section 3) argue
that coroutines are the right tool for type-erased asynchronous
boundaries in I/O libraries, because the frame erases the coroutine’s
type and the frame allocation that a boundary can’t avoid pays for the
erasure. This paper agrees that a boundary costs one frame. Both papers
also leave GPU and other heterogeneous work to senders (section 10 of
each), which is where, as Returning
task showed, coroutines aren’t
available today.
Without a coroutine,
cache::get’s
body is a sender expression, and the three designs that follow share
one. A real cache would name its backing store’s type, but each of these
designs has its own kv_store class,
so cached_lookup is a template over
that type.
#include "cache_entries.hpp"
#include <exec/variant_sender.hpp>
#include <stdexec/execution.hpp>
#include <optional>
#include <string>
#include <utility>
namespace ex = STDEXEC;
// looks key up in entries, and on a miss in backing, caching what it finds
template <class Store>
auto cached_lookup(Store const& backing, cache_entries& entries,
std::string key) {
return ex::just(std::move(key)) |
ex::let_value([&backing, &entries](std::string const& key) {
auto miss = [&] {
return backing.get(key) |
ex::then(
[&entries, &key](std::optional<std::string> value) {
if (value) {
entries.insert(key, *value);
}
return value;
});
};
// a hit and a miss are different senders, so return a variant
using lookup = exec::variant_sender<
decltype(ex::just(std::optional<std::string>())),
decltype(miss())>;
if (auto value = entries.find(key)) {
return lookup(ex::just(std::move(value)));
}
return lookup(miss());
});
}let_value runs its continuation
when just completes, so the lookup
happens when the operation starts, not when
get is called. If
get is called before an entry is
inserted and its sender is started after, the lookup finds the entry,
just as the coroutine in task does. The
continuation must return one sender type, so the hit and the miss, whose
senders have different types, go in a variant sender. stdexec provides
exec::variant_sender,
and [P4329R0] proposes
std::execution::variant_sender.
Naming the miss’s type also takes a
decltype.
The variant sender and the
decltype are
the cost of writing an implementation as a sender expression rather than
a coroutine, and the task version
pays neither. The cost has nothing to do with erasing the expression’s
type, so the three designs below pay it equally.
any_senderstdexec’s
exec::any_sender,
like libunifex’s
unifex::any_sender_of
before it, erases the type of any sender whose completions, and whose
queries of the caller’s environment, are among those named in its
type:
#include <exec/any_sender_of.hpp>
#include <stdexec/execution.hpp>
#include <exception>
#include <optional>
#include <string>
namespace ex = STDEXEC;
using any_lookup = exec::any_sender<exec::any_receiver<
ex::completion_signatures<ex::set_value_t(std::optional<std::string>),
ex::set_error_t(std::exception_ptr),
ex::set_stopped_t()>>>;
struct kv_store {
virtual ~kv_store() = default;
virtual any_lookup get(std::string key) const = 0;
};cache::get
returns the sender expression, and
any_lookup stores it:
#include "cache.hpp"
#include "cached_lookup.hpp"
#include <utility>
cache::cache(kv_store const& backing) : backing_(&backing) {}
any_lookup cache::get(std::string key) const {
return cached_lookup(*backing_, entries_, std::move(key));
}A lookup that returns an
any_sender costs two allocations in
general:
any_sender
stores the sender. Since a sender can be of any size, storing it
generally means allocating, though a small buffer can hold small
senders. As with task’s frame, this
allocation is made before the caller’s environment is known (task).any_sender
connects the stored sender to a receiver that erases the caller’s
concrete one. The resulting operation state’s type depends on the stored
sender, so it too generally needs an allocation, and
connect
returns an operation state of fixed size, which owns the erased one and
starts it through an indirect call. This allocation is made once the
caller’s environment is known, so the environment can choose its
allocator.The erased receiver’s type is fixed by the
any_sender’s type, so the
constructor, which is a template over the stored sender’s type, can name
the type of the operation state that connecting that sender produces. A
variant of any_sender could
therefore allocate once, at construction, enough storage for both the
sender and that operation state. That variant has
task’s profile: one allocation that
is made before the caller’s environment is known.
Writing a Sender by Hand
stopped before deciding how to store the pipeline’s operation state.
Completing that sketch for kv_store
starts with making its get_sender
the return type of the abstract
get:
#include "get_sender.hpp"
struct kv_store {
virtual ~kv_store() = default;
virtual get_sender get(std::string key) const = 0;
};and then finishing the type erasure it began. Here is a minimal version, as an expert might write it in C++26:
#include <stdexec/execution.hpp>
#include <exception>
#include <memory>
#include <memory_resource>
#include <optional>
#include <string>
#include <utility>
namespace ex = STDEXEC;
// what an implementation can ask of the caller's environment: a stop token
// and an allocator, of fixed types
struct erased_env {
ex::inplace_stop_token stop_token;
std::pmr::polymorphic_allocator<> allocator;
auto query(ex::get_stop_token_t) const noexcept { return stop_token; }
auto query(ex::get_allocator_t) const noexcept { return allocator; }
};
// a receiver that forwards completions to a Target it doesn't know the type of
class erased_receiver {
public:
using receiver_concept = ex::receiver_t;
template <class Target>
erased_receiver(Target& target, erased_env env) noexcept
: target_(&target), vtable_(&vtable_for<Target>), env_(env) {}
void set_value(std::optional<std::string> value) && noexcept {
vtable_->set_value(target_, std::move(value));
}
void set_error(std::exception_ptr error) && noexcept {
vtable_->set_error(target_, std::move(error));
}
void set_stopped() && noexcept { vtable_->set_stopped(target_); }
erased_env get_env() const noexcept { return env_; }
private:
struct vtable {
void (*set_value)(void*, std::optional<std::string>) noexcept;
void (*set_error)(void*, std::exception_ptr) noexcept;
void (*set_stopped)(void*) noexcept;
};
template <class Target>
static constexpr vtable vtable_for{
[](void* target, std::optional<std::string> value) noexcept {
static_cast<Target*>(target)->set_value(std::move(value));
},
[](void* target, std::exception_ptr error) noexcept {
static_cast<Target*>(target)->set_error(std::move(error));
},
[](void* target) noexcept {
static_cast<Target*>(target)->set_stopped();
}};
void* target_;
vtable const* vtable_;
erased_env env_;
};
// an implementation's pipeline, connected to an erased_receiver and allocated
class pipeline {
public:
virtual void start() noexcept = 0;
virtual void destroy(std::pmr::polymorphic_allocator<> alloc) noexcept = 0;
protected:
~pipeline() = default;
};
template <class Sender>
class pipeline_for final : public pipeline {
public:
pipeline_for(Sender sndr, erased_receiver rcvr)
: op_(ex::connect(std::move(sndr), std::move(rcvr))) {}
void start() noexcept override { ex::start(op_); }
void destroy(std::pmr::polymorphic_allocator<> alloc) noexcept override {
alloc.delete_object(this);
}
private:
ex::connect_result_t<Sender, erased_receiver> op_;
};
// makes, connects and allocates one implementation's pipeline
using connect_fn = pipeline* (*)(void const* store, std::string key,
erased_receiver rcvr,
std::pmr::polymorphic_allocator<> alloc);
// the allocation policy: the caller's allocator if it's polymorphic,
// otherwise the default memory resource
template <class Env>
std::pmr::polymorphic_allocator<> allocator_of(Env const& env) noexcept {
if constexpr (requires {
std::pmr::polymorphic_allocator<>(ex::get_allocator(env));
}) {
return std::pmr::polymorphic_allocator<>(ex::get_allocator(env));
} else {
return {};
}
}
// the operation state's non-template part
class get_operation_base {
public:
get_operation_base(connect_fn connect, void const* store, std::string key,
erased_receiver rcvr,
std::pmr::polymorphic_allocator<> alloc)
: alloc_(alloc),
pipeline_(connect(store, std::move(key), std::move(rcvr), alloc)) {}
get_operation_base(get_operation_base&&) = delete;
~get_operation_base() { pipeline_->destroy(alloc_); }
void start() & noexcept { pipeline_->start(); }
private:
std::pmr::polymorphic_allocator<> alloc_;
pipeline* pipeline_;
};
// The caller's receiver, and a callback that forwards stop requests from the
// caller's stop token to the pipeline's. The callback must be destroyed before
// the receiver completes, so the pipeline completes here, not to the receiver.
// The callback lives exactly from start to completion, so its lifetime is
// managed by hand rather than with a flag.
template <class Receiver>
class receiver_holder {
public:
void set_value(std::optional<std::string> value) noexcept {
std::destroy_at(&callback_);
ex::set_value(std::move(rcvr_), std::move(value));
}
void set_error(std::exception_ptr error) noexcept {
std::destroy_at(&callback_);
ex::set_error(std::move(rcvr_), std::move(error));
}
void set_stopped() noexcept {
std::destroy_at(&callback_);
ex::set_stopped(std::move(rcvr_));
}
protected:
explicit receiver_holder(Receiver rcvr) : rcvr_(std::move(rcvr)) {}
~receiver_holder() {}
void forward_stop_requests() noexcept {
std::construct_at(&callback_, ex::get_stop_token(ex::get_env(rcvr_)),
forward_stop{&stop_source_});
}
Receiver rcvr_;
ex::inplace_stop_source stop_source_;
private:
struct forward_stop {
ex::inplace_stop_source* source;
void operator()() const noexcept { source->request_stop(); }
};
using token = ex::stop_token_of_t<ex::env_of_t<Receiver>>;
union {
ex::stop_callback_for_t<token, forward_stop> callback_;
};
};
// receiver_holder comes first so the receiver exists before
// get_operation_base's constructor connects the pipeline
template <class Receiver>
class get_operation : receiver_holder<Receiver>, public get_operation_base {
public:
using operation_state_concept = ex::operation_state_t;
get_operation(connect_fn connect, void const* store, std::string key,
Receiver rcvr)
: receiver_holder<Receiver>(std::move(rcvr)),
get_operation_base(
connect, store, std::move(key),
erased_receiver(static_cast<receiver_holder<Receiver>&>(*this),
{this->stop_source_.get_token(), alloc()}),
alloc()) {}
void start() & noexcept {
this->forward_stop_requests();
get_operation_base::start();
}
private:
std::pmr::polymorphic_allocator<> alloc() const noexcept {
return allocator_of(ex::get_env(this->rcvr_));
}
};
class get_sender {
using signatures =
ex::completion_signatures<ex::set_value_t(std::optional<std::string>),
ex::set_error_t(std::exception_ptr),
ex::set_stopped_t()>;
public:
using sender_concept = ex::sender_t;
template <class Self, class... Env>
static consteval auto get_completion_signatures() noexcept {
return signatures();
}
// a lookup of key in store, whose pipeline Factory makes; Factory must be
// stateless, so only its type is kept
template <class Store, class Factory>
get_sender(Store const& store, std::string key, Factory)
: store_(&store),
key_(std::move(key)),
connect_(&connect_with<Store, Factory>) {}
template <ex::receiver_of<signatures> Receiver>
get_operation<Receiver> connect(Receiver rcvr) && {
return {connect_, store_, std::move(key_), std::move(rcvr)};
}
private:
template <class Store, class Factory>
static pipeline* connect_with(void const* store, std::string key,
erased_receiver rcvr,
std::pmr::polymorphic_allocator<> alloc) {
auto sndr = Factory()(*static_cast<Store const*>(store), std::move(key));
return alloc.new_object<pipeline_for<decltype(sndr)>>(std::move(sndr),
std::move(rcvr));
}
void const* store_;
std::string key_;
connect_fn connect_;
};Each piece embodies a decision that every author of such a type must make:
Piece
|
Lines
|
What it decides
|
How the example decides
|
|---|---|---|---|
erased_env |
6 | Which of the caller’s queries an implementation can see | Only a stop token and an allocator, of fixed types |
erased_receiver |
35 | How completions reach the caller | Through a pointer to the operation state and a table of its three completion functions |
pipeline,
pipeline_for |
19 | How an implementation’s operation state is erased | Behind a virtual
start and
destroy |
connect_fn,
allocator_of |
13 | Where the operation state’s memory comes from | The caller’s allocator if it’s a
std::pmr::polymorphic_allocator,
otherwise the default memory resource |
get_operation_base |
14 | When that memory is allocated and freed | Allocated at
connect,
freed when the operation state is destroyed |
receiver_holder,
get_operation |
55 | How the caller’s stop requests reach the implementation | Through a callback that forwards them to
an inplace_stop_source, destroyed
before the caller’s receiver completes |
get_sender |
33 | How each implementation supplies its pipeline | As a stateless factory, called with the
store and the key at
connect |
C++26 has no query for a frame allocator, so
allocator_of uses
get_allocator. An implementer could
define a frame-allocator query of their own, since environments accept
user-defined queries, but only callers that know of it would answer it.
The type is minimal in other ways, too: its
connect is
potentially throwing even when nothing it does can throw, and it
allocates every operation state, however small. A motivated implementer
could make
connect
conditionally
noexcept and
store small operation states inline, at the cost of more code.
With get_sender complete,
cache::get
passes it a factory that returns the sender expression:
#include "cache.hpp"
#include "cached_lookup.hpp"
#include <utility>
cache::cache(kv_store const& backing) : backing_(&backing) {}
get_sender cache::get(std::string key) const {
return get_sender(*this, std::move(key),
[](cache const& self, std::string key) {
return cached_lookup(*self.backing_, self.entries_,
std::move(key));
});
}get_sender’s constructor takes
the same arguments as async_fn’s in
function,
and this
cache::get
differs from that one only in its return type. Completing the type
erasure begun in Writing a Sender by
Hand has produced a minimal
function, specialized to
get’s signature. The 175 lines serve
that one signature, though. An interface with a different signature
needs them again, specialized to its own completions and queries. Two
libraries that each write such a type end up with two different types,
even for the same signature. A library that depends on another can wrap
the other’s senders in its own type, at the cost of a second layer of
erasure, but a program that passes senders between two libraries that
don’t depend on each other must write the adaptors itself.
A future standard might let an author write fewer of those lines.
[P4148R2] proposes
protocol, which would generate type
erasure from a struct that declares member functions, using code
injection that C++26 lacks, and [P4337R0]’s
emplace_from would let it construct
an immovable operation state in place. Together they could replace the
hand-written dispatch in
erased_receiver and
pipeline. Deciding what the erased
environment answers, where the memory comes from, and how stop requests
travel would still be the author’s job, and the result would still be
one library’s type rather than a standard one.
functionWith
kv_store::get
declared through async_fn as in A Third Way,
cache::get
returns a function built from the
arguments and the same factory:
#include "cache.hpp"
#include "cached_lookup.hpp"
#include <utility>
cache::cache(kv_store const& backing) : backing_(&backing) {}
async_fn<std::optional<std::string>(std::string) const &>
cache::get(std::string key) const {
return async_fn<std::optional<std::string>(std::string) const &>(
*this, std::move(key), [](cache const& self, std::string key) {
return cached_lookup(*self.backing_, self.entries_, std::move(key));
});
}As in A Third Way, calling
get returns a pending call. The
function stores
this and
key, and erases the type of the
factory, a captureless lambda. Connecting the
function calls the factory with the
stored arguments, connects the sender it returns to a receiver that
erases the caller’s concrete one, and puts the resulting operation state
in a frame allocated through the frame allocator provided by the
caller’s environment (Frame Allocation).
As with any_sender,
connect
returns an operation state of fixed size, which owns that frame and
starts the operation state in it through an indirect call. Since
constructing a function only stores
its arguments, the frame is the only allocation, and it is made once the
caller’s environment is known.
The factory returns the sender expression that the
any_sender version stores. The two
versions differ in when that sender is constructed, at the call or at
connect, and
so in whether that sender needs storage of its own.
Designs Considered implemented
cache four ways. Each gives
kv_store::get
one fixed return type and keeps whatever varies between implementations
hidden behind that type, reached only through indirect calls. To the
caller, the implementation’s state is in storage of unknown size. The
designs differ in when that storage is first needed: when
get is called, for
task and
any_sender, or when the sender
get returns is connected, for the
other two. That decides what each type must name (Allocation Patterns compares when and
how each allocates):
Property
|
task
|
any_sender
|
Writing It Yourself
|
function
|
|---|---|---|---|---|
| What does the storage of unknown size hold? | The coroutine frame | The sender | The operation state | The operation state |
What does
get’s return type depend on? |
The value type and the error types | Completions and queries | Whatever its author chose | Completions, queries, attributes, parameter types, and whether it borrows an object |
What does calling
get store? |
The arguments and the promise | The sender | The factory and the arguments | The factory and the arguments |
The hand-written get_sender and
function erase a factory rather than
a sender, so calling get stores only
the factory and the arguments and allocates nothing. The price is that
function erases less than
any_sender; its type names the
parameter types, and its layout depends on them because it stores the
arguments in place. An any_sender’s
type depends only on its completions and queries. Code that calls
get depends on its parameter types
with either design, as callers of a synchronous function do. Code that
doesn’t call get but stores
or forwards the senders it returns depends on them too if
get returns a
function, and not if it returns an
any_sender. Because an
any_sender imposes fewer
dependencies on such code than a
function does, ABI stability is
easier to maintain with
any_sender.
Measured against Requirements, the four
designs compare as follows. Each mark judges what a design can do, not
what today’s implementations of it do; meeting every requirement can
take a lot of work, as Writing It
Yourself showed. A design is the one Designs Considered describes, extended in
any way that doesn’t change its nature; a variant that trades one
requirement for another, such as the merged
any_sender, is discussed in the text
rather than marked. task is already
standard, so it is marked as specified; ❌→✅ marks a requirement that
task doesn’t meet as specified but
would meet after a change WG21 might make to it.
Requirement
|
task
|
any_sender
|
Writing It Yourself
|
function
|
|---|---|---|---|---|
| 1. Separation of interface and implementation | ✅ | ✅ | ✅ | ✅ |
| 2. Composable with other senders | ✅ | ✅ | ✅ | ✅ |
| 3. Fully general | ❌→✅ | ✅ | ✅ | ✅ |
| 4. Broadly applicable | ❌ | ✅ | ✅ | ✅ |
| 5a. No allocation beyond the activation frame | ✅ | ❌ | ✅ | ✅ |
| 5b. The caller chooses the frame allocator | ❌→✅ | ✅ | ✅ | ✅ |
| 5c. Frame lifetimes nest along sequential paths | ❌ | ✅ | ✅ | ✅ |
| 6. A precise failure contract | ✅ | ✅ | ✅ | ✅ |
| 7. Borrowed state visible in the interface | 🟡 | 🟡 | ✅ | ✅ |
| 8a. Declaring an interface | ✅ | 🟡 | 🟡 | 🟡 |
| 8b. Writing an implementation | ✅ | 🟡 | 🟡 | 🟡 |
| 9. Standard vocabulary | ✅ | ✅ | ❌ | ✅ |
The rows that need explaining:
3. Fully general. As specified,
task can’t declare a function that
completes with either of two value types, because it has one value
completion, or one that never completes stopped, because it always has
set_stopped_t()
(33.13.6.3
[task.members]).
Wrapping the values in a
std::variant
declares a different function, with one value type that every caller
must unpack. The other designs support any completion signatures.
WG21 could change task in both
respects. Its Environment could
choose whether it completes stopped, as it already chooses its error
completions (33.13.6.2
[task.class]), and
task could have several value
completions if as_awaitable, which
admits at most one (Completion
Signatures), changed with it, so that one
task could still await
another.
4. Broadly applicable.
task is a coroutine type, and
coroutines aren’t available in device code today (Returning
task). That is a limit of
today’s device compilers rather than of
task, so no change to
task can lift it, and
task would meet this requirement if
device compilers supported coroutines.
task itself needs nothing that a
build without exceptions lacks, but awaiting a sender turns its error
into a thrown exception (33.13.1
[exec.as.awaitable]),
so in such a build a task must turn
the errors of the senders it awaits into values first.
The other designs use no language feature that device code or a build
without exceptions lacks, though
function’s reference implementation
hasn’t yet been shown running in device code. Separation in device code asks
whether task’s absence there
matters, given what the separation costs.
5a. No allocation beyond the activation frame.
any_sender allocates when
get is called, to store the sender,
as well as allocating its frame, the operation state, at
connect.
5b. The caller chooses the frame allocator. As
specified, task fails 5b: the
caller’s choice reaches its frame only in ways that change the signature
or that don’t survive a resume (Allocation Patterns).
WG21 could make task’s default
Environment read out-of-band state
and make task restore it on each
resume, as the coroutine design in [P4172R1] (section 8.3)
does; Where frames come from
discusses the cost.
any_sender’s frame is allocated
at connect,
so the caller’s frame allocator can provide it; its allocation in
get is 5a’s failure, not this
one’s.
5c. Frame lifetimes nest along sequential paths.
task’s frame is allocated when the
coroutine is called, so the code that calls the coroutines decides the
order of their frames. In a chain in which each coroutine awaits a
callee as soon as it calls it, the frames nest, as [P4172R1] (section 6.3.1)
observes, but nothing makes the calling code follow that order: a
coroutine that calls two
task-returning functions before
awaiting either allocates both frames first, and frees the first while
the second is still live. Where frames
come from measures a similar case.
The other designs allocate their frames at
connect, so
the algorithms that connect the senders decide the order, whatever order
the senders were constructed in. Algorithms that connect each child only
when they need it, as let_value
does, keep frames nested along a sequential path.
An algorithm written by hand can break that nesting, as a coroutine
body can for task, but the two
decide the order differently. Writing an algorithm is much harder than
writing a coroutine body ([P4088R1], section 2.2,
counts about 120 lines of template machinery for a
retry sender algorithm against seven
for the coroutine equivalent), and much of the difficulty is that its
author must decide when each child is connected and destroyed, so in an
algorithm the order of frames is a deliberate choice. A coroutine body
decides the order of its callees’ frames only as a side effect of where
it calls them, and when the calls are arguments of one expression, the
compiler decides.
6. A precise failure contract. Every design can
state that neither calling get nor
connecting the sender it returns throws:
task: a coroutine declared
noexcept
doesn’t throw when it’s called, because a failed frame allocation then
terminates the program, and nothing
task’s
connect does
can throw.6any_sender:
get can be declared
noexcept,
and the constructor can check that the stored sender’s
connect
doesn’t throw, as function’s
does.get_sender could do what
function does.function:
get can be declared
noexcept,
and a
noexcept
function type says when
connect
doesn’t throw (The
Exception Contract of
connect).7. Borrowed state visible in the interface.
cache::get
borrows
*this
whichever design its return type uses. Its declaration shows that
get is a member function, as a
synchronous declaration would, but not that the borrow outlives the
call, unless the return type says so. Neither
task’s type nor
any_sender’s does, because neither
names the call: task’s names the
value type, and any_sender’s names
the completions and queries.
function’s type shows a borrowed
object through its reference qualifier and a borrowed argument through a
parameter of reference type.
The hand-written get_sender shows
whatever its author chooses (Feature
Comparison), and even an author who chooses to show nothing can’t
hide the borrow entirely. The type can’t work without a member that
refers to the store, and its definition must be in the header (Writing a Sender by Hand), so the
borrow appears in the definition of
get’s return type. That is less
visible than function’s reference
qualifier, which is part of get’s
declaration, but more visible than with
task or
any_sender, where the borrow appears
only in each override’s definition.
A task whose type named the call,
such as
task<R(Args...) const &>,
could show its borrows, and its promise could check them against the
coroutine’s parameters (9.6.4
[dcl.fct.def.coroutine]),
but its type would then name the call’s parameters rather than only its
result, the difference between
function and
any_sender that Feature Comparison describes. That would
change task’s nature, so its mark
doesn’t count it.
8a. Declaring an interface.
task’s declaration names the value
type, as the synchronous declaration names its return type.
any_sender’s type and
function’s proposed form spell out
the completion signatures, so each takes an alias, such as
any_lookup or
async_fn, to read like the
synchronous declaration; Future
Directions discusses a standard form of
async_fn. The hand-written
get_sender makes
get’s declaration easy once it
exists, but each new signature needs its own.
8b. Writing an implementation.
task writes
cache most simply, as Erasing a Sender Expression
showed. The other designs all write it with the same sender expression,
variant sender and all. Since task
is a sender, they can also wrap a
task coroutine, as Factories shows for
function, but the coroutine’s frame
then comes from task’s allocator,
not the caller’s frame allocator.
9. Standard vocabulary. The hand-written
get_sender is
function specialized to one
signature, so writing one, and saying in it what 6 and 7 ask, falls to
every author, for every signature. A standard
any_sender would meet 9, as
function would.
The merged any_sender allocates
once, when get is called, as
task does, so it meets 5a but, like
task, fails 5c. It fails 5b too: a
caller’s choice can reach a nested
get only through out-of-band state,
and a nested call can happen wherever the stored sender’s algorithms
make it, including after the operation has moved to another thread,
where only the stored sender’s own receivers run.
task, by contrast, runs its own code
each time its coroutine resumes.
Robert Leahy ([Leahy]) breaks a function call into four steps: prepare the arguments, pass them, let the function run, and collect the result. In a synchronous call, the last three happen together. Senders separate all four: constructing a sender prepares the arguments, connecting it to a receiver passes them, starting the operation state runs the function, and one of the receiver’s completion functions collects the result. A sender is the asynchronous analogue of a nullary callable, and an operation state is the analogue of an activation frame.
That decomposition locates each design’s allocations:
Design
|
Allocations
|
When
|
Storage chosen by
|
Lifetimes nest
|
|---|---|---|---|---|
| Synchronous call (reference) | 1: the stack frame | Passing the arguments | The caller, whose thread owns the stack | Yes |
task |
1‡: the coroutine frame | Preparing the arguments | The
task type§ |
No‖ |
any_sender |
2†: the sender, then the operation state | Preparing the arguments | get’s
definition§ |
No |
| Passing the arguments | The caller’s environment | Yes | ||
Merged
any_sender |
1†: the sender and its operation state | Preparing the arguments | get’s
definition§ |
No |
| Writing It Yourself | 1†: the operation state | Passing the arguments | The caller’s environment | Yes |
function |
1†: the operation state | Passing the arguments | The caller’s environment | Yes |
† A small buffer can avoid some of these
allocations.
‡ Heap allocation elision (HALO) can avoid it only where the
compiler sees which coroutine is called and its definition, which
keeping the implementation out of the declaration generally
prevents.
§ Unless the caller chooses in one of the ways described
below.
‖ Frames nest when each coroutine awaits a callee as soon as
it calls it, as [P4172R1] (section 6.3.1)
observes, but the calling code decides the order, so nothing guarantees
it (Requirements Comparison).
function matches the synchronous
call in every column, and so does the hand-written
get_sender, which is
function for one signature.7 The nesting claim is along each
sequential path; frames on parallel branches coexist (Analysis). The one difference from the reference
row is that the frame is allocated dynamically, and that difference is
forced: an asynchronous operation can outlive the call that started it,
so its frame can’t live on the caller’s stack.
In the rows marked §, the storage is allocated before any environment
exists, so a caller’s choice must arrive another way. For
task, it can arrive through an
allocator_arg parameter, which every
signature must then declare, or through out-of-band state that an
Environment’s allocator reads, which
puts the allocator’s type and how it is found in the
task type.
task doesn’t restore that state when
a coroutine resumes, so a call in its body gets whatever allocator the
state holds at the time.
any_sender’s allocation in
get can follow the same routes, and
nothing in a sender expression restores out-of-band state before a
nested call.
[P4088R1] (section 10)
describes a “design fork” in which only coroutines get type erasure,
separate compilation and ABI stability at zero per-operation cost. Each
part of that claim bears on
function:
Type erasure. [P4088R1] (section 7.1)
attributes senders’ cost to
connect
putting the caller’s receiver type into the operation state. A
function erases the receiver, as a
coroutine handle erases the caller, so the type of the operation state
it erases depends only on the implementation.
Separate compilation. Each override of
kv_store::get
that returns a function is defined
in its own source file, as A Third Way and function
show.
ABI stability. Changing an implementation
doesn’t change the function type it
returns. Code that stores the senders an interface returns, though,
depends on the interface’s parameter types if the interface returns a
function, and not if it returns a
task or an
any_sender, as Feature Comparison concedes.
Zero per-operation cost. Feature Comparison showed each design
holding the implementation’s state in storage of unknown size. A
synchronous function has the same problem: its declaration doesn’t say
how much storage its body needs, so the callee, not the caller, provides
its frame.8 Without a stack to put that frame
on, any design that keeps an asynchronous function’s implementation out
of its declaration needs one dynamically allocated frame per call,
unless the type it returns fixes the frame’s size (Fixing the frame’s size). Of the four
designs, all but any_sender allocate
exactly that one frame. [P4088R1] (section 4)
concedes the same of coroutines: every coroutine that suspends allocates
its frame.
The zero that [P4088R1] (section 7.4) measures is per read, not per call. Its type-erased stream allocates storage for one awaitable when the stream is erased and reuses it for every read, which works because the awaitable’s type doesn’t depend on the caller. A type-erased stream built on senders can do the same once it erases the receiver, since the operation state of a read then depends only on the stream. The appendix shows one, which allocates when the stream is erased and not at all for a million reads, where [P4088R1] measures one allocation per read for a type-erased sender. The zero comes from storage that the erased stream caches, which either model can do.
A function, by contrast, is
constructed for each call, so it keeps no storage between calls, and
allocates its frame on every
connect, as
a coroutine does on every call. The frame allocator can reuse that
storage. [P4172R1] (section 6.3.1)
argues that in a steady-state loop a recycling frame allocator turns
every frame allocation after the first iteration into a cache hit,
because frame sizes repeat and lifetimes nest. For
function, the caller’s environment
provides that allocator, so no pool has to be threaded through the API,
a burden that [P4088R1] (section 8)
attributes to senders in general. An implementation that keeps its state
in the I/O object, as [P4088R1] (section 7.3)
does, can need no allocation at all, if what remains of its operation
state fits a buffer like the reference implementation’s.
The allocation any_sender makes
when get is called is different in
kind from a frame allocation. It stores a sender, and a sender is a
value that can be constructed in one place, moved elsewhere, and
connected later or never, so that allocation follows no pattern a frame
allocator could exploit. Constructing a
function, by contrast, allocates
nothing; it only copies or moves its arguments, as passing them by value
to a synchronous function would.
Fixing the frame’s size means giving the type a capacity, either a buffer that falls back to allocating when an operation state doesn’t fit or, as Robert Leahy suggested in Brno, a limit beyond which the program is ill-formed. An implementation that outgrows a limit needs a new interface, as an implementation that changed structure did in Returning an Implementation-Specific Sender. An implementation that outgrows a buffer allocates again, and avoiding that means enlarging the capacity, which changes the type and its ABI. Ruslan Arutyunyan reported the same experience from Boost: types parameterized on a capacity are incompatible across capacities, and changing the capacity breaks ABI. [P4088R1] (section 8) makes a similar argument against a fixed small buffer.
A buffer whose size the standard library fixes, such as the reference
implementation’s or
std::function’s
small-object buffer, is different. Its size is part of the library’s
ABI, but no interface chooses it, so no change to an implementation
calls for changing it, and an operation state that doesn’t fit is
allocated instead. The wording permits such a buffer but doesn’t require
one (What the Wording
Guarantees).
Because function allocates its
frame at
connect, the
caller chooses the frame allocator the way it makes every other choice
that operations defer to: through its environment. Nothing in
kv_store::get’s
declaration mentions it, and no caller between the one that chooses it
and the one that uses it has to pass it along. A coroutine’s frame is
allocated before any environment exists, so in today’s language its
allocator must arrive through a parameter or out-of-band state instead
(task, Frame Allocators for
Coroutines). A task changed to
read such state by default, and to restore it whenever a coroutine
resumes, would meet requirement 5b (Requirements Comparison), at the
cost of accessing ambient state on every frame allocation and every
resume of every task, even in
programs that never choose a frame allocator.
Allocating at
connect also
preserves nesting. Constructing a
function allocates nothing, so the
algorithm that runs the operations decides the order of their frames,
not the code that constructs the senders. Along a sequential path,
algorithms such as let_value connect
their successors only as they need them, and destroy those operation
states before their own are destroyed. In the reference implementation,
a miss allocates
cache::get’s
frame and then
remote_store::get’s,
and frees them in the opposite order; two lookups in sequence through
let_value free the first lookup’s
frames before allocating the second’s. A frame allocator for a
sequential path can therefore be as simple as a stack, whereas
operations whose frames interleave need one that accepts frees in any
order, such as a recycling allocator.
In contrast to function’s,
task’s frames are ordered by the
coroutine calls that construct the
tasks. stdexec’s
exec::sequence
runs its children one after another, connecting each only after
destroying its predecessor’s operation state, so
exec::sequence(a(), b(), c())
frees the frames of three
task-returning coroutines in the
order a,
b,
c. C++ leaves the order in which the
three calls are evaluated unspecified, so the order in which those
frames are allocated depends on the compiler: in a test, GCC 13
allocated them c,
b,
a, which nests, and Clang 18
allocated them a,
b,
c, which doesn’t. With either
compiler, all three frames were live before the sequence started. When
a,
b and
c return
functions instead, each frame is
allocated when
exec::sequence
connects its child, so with either compiler each frame is freed before
the next is allocated. The frames nest, and at most one is live at a
time, as when a coroutine awaits
a(),
b() and
c() in
turn.
Parallel branches are different:
#include "kv_store.hpp"
// looks up "a" and "b" concurrently
auto lookup_both(kv_store const& store) {
return ex::when_all(store.get("a"), store.get("b"));
}With cache as the store and both
keys missing, connecting the
when_all allocates both branches’
cache::get
frames, and starting it allocates a
remote_store::get
frame for each branch. Each branch frees its frames when its own lookup
finishes, so in the reference implementation one branch’s
cache::get
frame can be freed while the other branch’s
remote_store::get
frame, allocated later, is still live. Frames on parallel branches
coexist rather than nest. Since each child’s frame allocator comes from
its environment, a parallel algorithm could give each branch its own
allocator; designing that belongs to the frame-allocation paper that Frame Allocation describes.
That task can’t be used in device
code today (Requirements
Comparison) matters only if device code would separate an interface
from its implementation at all. There, the separation can cost more than
it does in host code, whatever the design. Calling across translation
units requires relocatable device code, which gives up optimization
across files unless device link-time optimization recovers it ([NVIDIA-LTO]).
Host and device code have separate vtables, so an object with virtual
functions must be constructed on the device to be called there, and
virtual calls in parallel code are generally discouraged ([Kokkos-virtual]).
With any_sender, a hand-written
sender or function, device code that
needs the separation can have it at those costs, and code that doesn’t
need it incurs none of them. With
task, device code can’t have it at
all today.
task is bettertask writes
cache most simply, as Erasing a Sender Expression
showed. A coroutine needs no variant sender because its control flow
chooses between the hit and the miss. Ergonomics is the one requirement
on which function loses, and it
loses to a design that, as specified, fails four others. A
function’s factory can already be a
task coroutine (Factories), but the coroutine’s frame then comes
from task’s allocator rather than
the caller’s frame allocator. A language extension that let a coroutine
return a function, allocating its
frame at
connect from
the caller’s frame allocator, could close the gap without that cost;
this paper doesn’t propose one.
The use case this paper addresses is declaring an asynchronous
function whose definition lives elsewhere, including a virtual function
with many definitions. For that use,
function meets every requirement
except ergonomics, a matter of degree on which it matches
any_sender and trails
task.
task is the most ergonomic design
but, as specified, fails four requirements. Changes to
task could fix two of them. It could
be allowed to declare any completion signatures, with a matching change
to as_awaitable, and its default
environment could find the caller’s frame allocator in out-of-band
state, if task restored that state
on every resume. The other two aren’t fixable that way: it can’t be used
where coroutines aren’t available today, and because its frame is
allocated when the coroutine is called, nothing makes frame lifetimes
nest.
This paper proposes function but
takes no position on whether the standard should also have an
any_sender, since neither subsumes
the other:
any_sender’s type names only its
completions and queries, so it suits a sender whose origin its holder
needn’t know, such as one passed between components and stored. It can
serve as an interface’s return type, at the cost of an extra allocation
and without its type saying what the call borrows, as Requirements Comparison shows.function’s type names the call:
what it takes and what it borrows. It allocates nothing until it’s
connected. It can erase a sender that already exists only by borrowing
it, as the implicit object argument of a reference-qualified
function. Owning the sender would
take a curried argument of the sender’s type, which
function’s type would name rather
than erase.function’s purpose, declaring
asynchronous functions, also settles how it should allocate. The frame
is the one allocation the boundary requires, and its size can’t be fixed
in the type, so a buffer that avoids allocating small frames is a matter
of quality of implementation. That buffer has a cost at scale. In the
reference implementation, a
function’s operation state includes
a 64-byte inline buffer, which holds operation states of up to 56 bytes
and goes unused whenever the frame doesn’t fit, as
cache::get’s
295-byte frame doesn’t. A hundred thousand outstanding operations of
that kind leave 6.4 MB of buffers unused. The proposed wording requires
at most one allocation but doesn’t require a buffer, so an
implementation can omit it.
In summary, this paper proposes
std::execution::function,
a class template whose specializations are type-erasing senders for
declaring asynchronous functions, and
std::execution::get_frame_allocator,
an environment query whose result, when present, allocates their frames.
Interface Discussion describes
function’s interface, and Frame Allocation the query.
The examples so far declare
functions through
async_fn, the paper-local alias from
A Third Way. This section describes
function itself. A specialization of
function takes up to four template
arguments, in this order:
sender_tag, describing the
call;completion_signatures, listing how
the operation can complete;queries, listing what the operation
needs from the caller’s environment; andattrs, listing where the operation
completes.async_fn<R(Args...) const &>
names
function<sender_tag(Args...) const &, completion_signatures<set_value_t(R), set_error_t(exception_ptr), set_stopped_t()>>.
The subsections below take the arguments in turn, then the factory that
defines each implementation, and end with the running example’s
interface written without the alias.
The first argument, the call type, is the type of the call
that a function represents. Its
parameter types are the types of the curried arguments, which the
constructor stores and
connect
passes to the factory. Its return type is
sender_tag, because calling the
function produces a sender, and a sender can complete in more ways than
a return type can describe; the second argument lists them. Other return
types are left free for a form, not proposed here, that computes the
completions from a value type (Future
Directions), and for tag types derived from
sender_tag, such as stdexec’s
exec::sequence_sender_tag,
with which a later paper could declare other kinds of sender.
For a parameter that the call type declares by value, as
T, the constructor takes an rvalue
reference,
T&&.
A
std::string
lvalue must therefore be moved, or copied explicitly with
auto(s),
so a costly copy is never made silently. This departs from the
synchronous analogy, where a by-value parameter copies any lvalue. For a
type such as
int, though,
moving is copying, and requiring
std::move
would only add noise. So when T is
trivially copy- and move-constructible, the constructor takes
T const&
instead, and accepts an lvalue as it is. Both traits are required
because binding to
T const&
copies even an rvalue, which is wrong for a type whose move constructor
isn’t trivial. No trait says whether a copy is cheap, so the rule errs
toward asking for a
std::move.
The parallel between the call type and a synchronous function’s type
breaks down in one more place. A
function is a sender, and senders
must be move-constructible, so every curried parameter of object type
must be too, while a synchronous function can take an immovable type by
value.
A
noexcept
call type, such as
sender_tag(std::string) const & noexcept,
makes a promise about connecting the
function. The Exception Contract of
connect
explains what it promises, how the constructor checks it, and how it
relates to the caller’s frame allocator. Reference-qualified call types
are the subject of Member Functions.
The second argument lists every way the operation can complete, and a
function adds nothing to the list.
For example, a function whose
signatures leave out
set_error_t(exception_ptr)
can’t report an exception, so its constructor rejects a factory whose
sender might complete that way. An implementation may complete in fewer
ways than the type declares.
The order of the signatures doesn’t matter. Specializations of
function whose lists differ only in
order denote the same type, so an interface’s type doesn’t depend on the
order in which its author wrote them. The same holds for
queries and
attrs.
Since the type fixes no completions in advance, a
function can declare any
asynchronous function that a sender can represent (requirement 3), such
as ones with several value completions, value completions with several
arguments, error types other than
exception_ptr, or no
set_stopped_t().
For example, a read from a network connection can fail after filling
part of its buffer, and that partial success is a value of its own:
#include <exec/function.hpp>
#include <stdexec/execution.hpp>
#include <cstddef>
#include <span>
#include <system_error>
namespace ex = STDEXEC;
// A read into a buffer from a connection that the read borrows. It completes
// with the filled buffer, with an error and the part of the buffer filled
// before it, or with an error alone if it read nothing. A read cancelled after
// filling part of the buffer completes with an error and that part; one
// cancelled before reading anything completes as stopped.
using pending_read = exec::function<
ex::sender_tag(std::span<std::byte>) &,
ex::completion_signatures<
ex::set_value_t(std::span<std::byte>),
ex::set_value_t(std::error_code, std::span<std::byte>),
ex::set_error_t(std::error_code),
ex::set_stopped_t()>>;A task can’t declare this read,
because it has one value completion, so both successes would have to
share a value type. A task can’t
await it, either. A task awaits a
sender through as_awaitable
(33.13.6.5
[task.promise]),
which requires the sender to have at most one value completion
(33.13.1
[exec.as.awaitable]),
and turns an error_code on its error
channel into a thrown system_error
(33.1
[exec.general]).
Another coroutine type could await the read differently, say as a
variant, but in any coroutine,
co_await
either produces one value or throws an exception. A coroutine that
avoids exceptions therefore receives the full read, the partial read and
the error merged into one value, such as an
error_code and a buffer, which
auto [ec, buffer] = co_await ...
decomposes. One value is how such a coroutine must receive those
outcomes, not the only way to declare them. A
function can declare the merged
form, or keep the outcomes apart, and the interface’s author
chooses.
pending_read separates its
completions by which data exist, not by how serious the failure is,
which is for the caller to judge. Putting a partial result on the value
channel follows [P2300R10] (section 4.14),
and [P2430R0] gives the reason:
an error completion accepts one argument, so a partial result must be a
value completion.
A function’s implementation is
connected to a receiver that erases the caller’s receiver. That
receiver’s environment always answers
get_frame_allocator, with an
allocator that allocates from the caller’s frame allocator (What the Wording Guarantees),
and the third argument declares the other queries that it answers.
A declared query is a function type,
Return(Query, Args...),
optionally
noexcept,
and a type may not declare the same query with the same argument types
twice. When asked Query with
arguments of types
Args..., the
caller’s environment must answer with a result convertible to
Return, and if the declared type is
noexcept,
neither the answer nor its conversion to
Return may be potentially throwing.
A function can’t be connected in an
environment that doesn’t meet these requirements.
A declared query is a requirement on the caller’s environment, not an
environment type, so every caller whose environment answers it can call
the function, whatever type that
environment has. [P4089R1] (section 9)
recommends constraints of this kind over a type parameter that names the
environment.
Since the environment always answers
get_frame_allocator, a caller can
choose where frames come from without the interface’s author declaring
the query. Declaring it is for an implementation that needs one
allocator type, and every caller must then provide that type.
The erased receiver’s type depends only on the
function’s type, so its environment
can answer only queries whose result types the
function’s type names, and the third
argument is where it names them. The remaining choice is whether a
function whose type has no third
argument should still answer some queries besides
get_frame_allocator. This paper’s
answer is no:
get_scheduler would let the caller’s
scheduler, and with it the caller’s domain, choose how the
implementation’s sender is transformed. Keeping it out unless declared
is part of what Domains and Type
Erasure discusses.Whether a convenience form should forward the stop token by default is one of the open questions in Future Directions.
The fourth argument declares where the operation completes, as
completion domains.
attrs<D(get_completion_domain_t<set_value_t>) noexcept>
says that the implementation’s sender completes successfully in domain
D; the constructor rejects a factory
whose sender doesn’t, and the
function reports
D to the algorithms it’s passed to.
Without attrs, a
function makes no claim about where
it completes. Domains and Type
Erasure discusses what a
function should report.
R0 accepted any trivially copyable callable small enough to fit the
factory’s storage, whose size was up to the implementation. A lambda
that captures
this
qualified, so a function could
borrow an object without its type saying so. R1 makes the borrow visible
by accepting reference-qualified call types. Only a member function’s
type can be reference-qualified, so the qualifier says that the call
borrows an object, as a call to a member function borrows its implicit
object.
A function whose call type is
sender_tag(Args...) const &
represents a call to a const member function. The constructor and the
factory both take the implicit object first, followed by the curried
arguments. The constructor takes the object as an lvalue and stores a
pointer to it, and
connect
passes the object to the factory as a const reference. function’s
cache::get
passes
*this
that way, and its factory takes self
as a
cache const&.
get declares one parameter, the key,
and its factory two, because the factory receives explicitly the object
that get receives implicitly.
sender_tag(Args...) &
likewise represents a call to a non-const member function. As with a
synchronous member function, a
const &
function’s constructor also accepts
a non-const lvalue.
A function whose call type is
reference-qualified says that the call borrows an object, but not the
object’s class, which the constructor erases along with the factory.
Every override of
kv_store::get
therefore returns the same type, while each override’s factory receives
the object as its own class, such as
cache const&,
so it can access private members without a cast.
sender_tag(Args...) &&
and
sender_tag(Args...) const &&
represent calls to rvalue-qualified member functions. The constructor
takes the implicit object as an rvalue, and
connect
passes it to the factory as one, so the factory can move from it, as it
can from a curried argument of rvalue reference type. The object is
still borrowed, and must outlive the operation, as with the other
reference qualifiers.
This paper’s author considered rejecting rvalue-qualified call types,
so that a function couldn’t borrow
an expiring object. As Arthur O’Dwyer shows,9
though, value category is not lifetime. Rejecting
&&
wouldn’t stop
auto pending = make_store().get(key);
from leaving pending referring to a
destroyed store, because a
const &-qualified
get binds a temporary too.
Meanwhile,
std::move(store).get(key)
is fine whenever store outlives the
operation. So a function accepts
lvalue- and rvalue-qualified call types,
const or
not.
Two kinds of qualifier are rejected. Without a reference qualifier, a
function type represents a call that borrows nothing, so a
const
qualifier without one is rejected rather than read as
const &.
volatile
forms are rejected as well.
Explicit object parameters need no special support:
#include <exec/function.hpp>
#include <stdexec/execution.hpp>
#include <concepts>
#include <exception>
#include <utility>
namespace ex = STDEXEC;
using sent = ex::completion_signatures<ex::set_value_t(),
ex::set_error_t(std::exception_ptr),
ex::set_stopped_t()>;
struct base {
// A reference: the call borrows the object, as with a const & qualifier.
exec::function<ex::sender_tag() const &, sent> borrowed(
this base const& self) {
return exec::function<ex::sender_tag() const &, sent>(
self, [](base const&) { return ex::just(); });
}
// By value: the call owns a copy, curried like any by-value parameter.
exec::function<ex::sender_tag(base), sent> copied(this base self) {
return exec::function<ex::sender_tag(base), sent>(
std::move(self), [](base) { return ex::just(); });
}
// Deduced, by value: the call owns a copy of the object as its own class,
// curried like any by-value parameter, so the function's type depends on
// that class.
template <class Self>
exec::function<ex::sender_tag(Self), sent> copied_as_self(this Self self) {
return exec::function<ex::sender_tag(Self), sent>(
std::move(self), [](Self) { return ex::just(); });
}
// Deduced, forwarding: the call borrows an lvalue through a curried
// parameter of reference type, and owns an rvalue moved into it.
template <class Self>
exec::function<ex::sender_tag(Self), sent> forwarded(this Self&& self) {
return exec::function<ex::sender_tag(Self), sent>(
std::forward<Self>(self), [](Self) { return ex::just(); });
}
// An explicit object member function can't be virtual, so virtual
// interfaces such as kv_store use reference-qualified call types.
};
struct derived : base {};
static_assert(std::same_as<decltype(derived().copied()),
exec::function<ex::sender_tag(base), sent>>);
static_assert(std::same_as<decltype(derived().copied_as_self()),
exec::function<ex::sender_tag(derived), sent>>);
static_assert(std::same_as<decltype(std::declval<derived&>().forwarded()),
exec::function<ex::sender_tag(derived&), sent>>);
static_assert(std::same_as<decltype(derived().forwarded()),
exec::function<ex::sender_tag(derived), sent>>);connect
invokes the factory with the implicit object, if any, and the curried
arguments, and connects the resulting sender. The factory must be
stateless; it can be a pointer to a function, a pointer to a member, or
an empty, trivially copyable callable, such as a lambda with no
captures. A constructor given a pointer isn’t
constexpr,
and, because it has a precondition, isn’t
noexcept
either. The factory is invoked as a const lvalue, and for a call type
sender_tag(Args...),
it receives the curried arguments as
Args&&....
Statelessness matters for two reasons:
this is the
hidden borrow that Member Functions
removes. With a stateless factory, the object and the curried arguments
are the only state a function holds,
and its type names both, so the reference qualifier is a guarantee
rather than an annotation.function
allocated nothing of its own, but whether a given capturing lambda fit
depended on the implementation’s choice of size. Storage as large as the
largest pointer to a member holds any stateless factory, so a factory
that one implementation accepts, every implementation accepts.A factory can return any sender, so an implementation can be a
coroutine even though the interface isn’t one.
task is a sender, so task’s
cache::get
can sit behind A Third Way’s interface:
#include "cache.hpp"
#include <utility>
// starts the coroutine wherever it's started, so the interface needn't declare
// a scheduler query
struct start_inline {
using start_scheduler_type = ex::inline_scheduler;
};
cache::cache(kv_store const& backing) : backing_(&backing) {}
async_fn<std::optional<std::string>(std::string) const &>
cache::get(std::string key) const {
return async_fn<std::optional<std::string>(std::string) const &>(
*this, std::move(key),
[](cache const& self, std::string key)
-> ex::task<std::optional<std::string>, start_inline> {
if (auto value = self.entries_.find(key)) {
co_return value;
}
auto value = co_await self.backing_->get(key);
if (value) {
self.entries_.insert(std::move(key), *value);
}
co_return value;
});
}Like task’s
version, this one reads like the synchronous
cache::get,
but it allocates more. The coroutine’s frame comes from
task’s allocator rather than the
caller’s frame allocator. The coroutine connects the backing store’s
function in its own environment,
which doesn’t answer
get_frame_allocator, so that frame
doesn’t come from the caller’s frame allocator either; in the reference
implementation, it comes from task’s
allocator too. There, a hit allocates twice and a miss three times, and
only the frame of the function that
cache::get
returns comes from the caller’s frame allocator. That trade is the
implementation’s to make; callers of
kv_store::get
can’t tell which version they’re calling.
async_fn fixes the error type to
exception_ptr and declares no
queries. Here is kv_store written
without the alias, as an interface for code that responds to stop
requests and reports failure with error codes might be:
#include <exec/function.hpp>
#include <stdexec/execution.hpp>
#include <optional>
#include <string>
#include <system_error>
namespace ex = STDEXEC;
// A lookup in a borrowed store. Neither calling get nor connecting the lookup
// throws, the lookup reports failure as an error code, and a caller must
// provide an inplace_stop_token, through which it can stop the lookup.
using lookup = exec::function<
ex::sender_tag(std::string) const & noexcept,
ex::completion_signatures<ex::set_value_t(std::optional<std::string>),
ex::set_error_t(std::error_code),
ex::set_stopped_t()>,
exec::queries<ex::inplace_stop_token(ex::get_stop_token_t) noexcept>>;
struct kv_store {
virtual ~kv_store() = default;
virtual lookup get(std::string key) const noexcept = 0;
};get is
noexcept as
well, since constructing a lookup
only stores a pointer to the store and moves the key. Every
implementation must now report failure as a
std::error_code,
so the two implementations share an adaptor that turns their exceptions
into error codes:
#include <stdexec/execution.hpp>
#include <concepts>
#include <exception>
#include <new>
#include <system_error>
namespace ex = STDEXEC;
// the error code for an exception these implementations can throw
inline std::error_code error_code_from(std::exception_ptr error) noexcept {
try {
std::rethrow_exception(error);
} catch (std::bad_alloc const&) {
return std::make_error_code(std::errc::not_enough_memory);
} catch (std::system_error const& e) {
return e.code();
}
// anything else escapes this noexcept function and terminates the program
}
// an adaptor that reports exceptions as error codes and passes error codes on
inline auto report_errors_as_error_codes() noexcept {
return ex::let_error([]<class Error>(Error const& error) noexcept {
if constexpr (std::same_as<Error, std::exception_ptr>) {
return ex::just_error(error_code_from(error));
} else {
return ex::just_error(error);
}
});
}The headers of remote_store and
cache differ from A Third Way’s only in
get’s declaration. Their definitions
use the adaptor last:
#include "error_codes.hpp"
#include "remote_store.hpp"
#include <chrono>
#include <utility>
remote_store::remote_store(exec::timed_thread_scheduler latency,
std::map<std::string, std::string> data)
: latency_(latency), data_(std::move(data)) {}
lookup remote_store::get(std::string key) const noexcept {
return lookup(
*this, std::move(key),
[](remote_store const& self, std::string key) noexcept {
return exec::schedule_after(self.latency_,
std::chrono::milliseconds{5}) |
ex::then([data = &self.data_, key = std::move(key)] {
std::optional<std::string> value;
if (auto it = data->find(key); it != data->end()) {
value = it->second; // copying the value can throw
}
return value;
}) |
report_errors_as_error_codes();
});
}#include "cache.hpp"
#include "error_codes.hpp"
#include <exec/variant_sender.hpp>
#include <utility>
cache::cache(kv_store const& backing) : backing_(&backing) {}
lookup cache::get(std::string key) const noexcept {
return lookup(
*this, std::move(key), [](cache const& self, std::string key) noexcept {
return ex::just(std::move(key)) |
ex::let_value([&self](std::string const& key) {
auto miss = [&] {
return self.backing_->get(key) |
ex::then([&self, &key](
std::optional<std::string> value) noexcept {
if (value) {
try {
self.entries_.insert(key, *value);
} catch (...) {
// caching is best-effort; the lookup succeeds
}
}
return value;
});
};
using result = exec::variant_sender<
decltype(ex::just(std::optional<std::string>())),
decltype(miss())>;
// a hit copies the value and a miss copies the key, so either
// can throw
if (auto value = self.entries_.find(key)) {
return result(ex::just(std::move(value)));
}
return result(miss());
}) |
report_errors_as_error_codes();
});
}The stop token that the interface declares reaches
remote_store’s timer, even through
cache, so a caller that stops a
lookup stops the wait for the network. A caller whose environment has no
such token, such as sync_wait’s,
must provide one, for example with
write_env. Three other things in
these implementations follow from the interface rather than from what
the implementations do:
noexcept
describes connecting, not the operation. A hit copies the
cached value after the operation starts, and if the copy fails, the
failure arrives as set_error, like
any other asynchronous failure.cache must also handle a failure
to allocate the backing store’s frame.
let_value connects the backing
store’s function after the operation
has started, so it reports that failure as an
exception_ptr, which the adaptor
turns into
errc::not_enough_memory.A function’s only allocation is
its frame, and Where frames come
from argued that the caller’s environment should choose where that
frame comes from. This paper proposes one query for making that choice,
get_frame_allocator, and specifies
how function uses its answer. The
rest of a frame-allocation design is left to a later paper.
get_frame_allocator asks a
queryable object, such as a receiver’s environment, for the allocator
that activation frames should come from. As with
get_allocator, its result must be a
simple-allocator,
and it is a forwarding query, so an adaptor that forwards its receiver’s
environment passes the choice on to its children. A caller chooses a
frame allocator through its receiver’s environment, for example by
wrapping the call in write_env:
#include "kv_store.hpp"
#include <exec/get_frame_allocator.hpp>
#include <memory_resource>
#include <string>
#include <utility>
// looks up key, allocating the lookup's frames from frames
auto lookup_in(kv_store const& store, std::string key,
std::pmr::memory_resource& frames) {
return store.get(std::move(key))
| ex::write_env(ex::prop(exec::get_frame_allocator,
std::pmr::polymorphic_allocator<>(&frames)));
}Neither
kv_store::get’s
declaration nor
cache::get’s
implementation mentions the allocator. When
store is a
cache,
cache::get’s
frame comes from the memory resource passed to
lookup_in, and so, on a miss, does
remote_store::get’s,
because the environment that
cache::get’s
implementation sees answers
get_frame_allocator (Required Queries and
Completion Domains).
The query is separate from
get_allocator because its answer is
used only for frames, and frames are allocated in a pattern that other
allocations needn’t follow. Along a sequential path, a
function’s frames nest (Where frames come from), and a
frame-specific allocator can exploit that. Nothing promises the same of
get_allocator’s answer, which serves
whatever an operation allocates.
spawn, for example, allocates its
operation state with it (33.9.13.3
[exec.spawn] paragraph
9), and that operation state outlives the code that spawned it. If
get_allocator answered with a
frame-specific allocator, spawn
would break the nesting that allocator relies on, and its allocation
could outlive the allocator. A separate query lets a program allocate
its frames from an allocator that can rely on the allocation pattern
frames follow, without auditing every other use of
get_allocator. [P4172R1] (section 6.3)
draws the same line for coroutines, whose frame allocator is used for
nothing but frames.
For a function’s frame, the
proposed wording specifies:
connect
allocates the frame through what this section calls the selected
frame allocator:
function’s type declares
get_frame_allocator (Required Queries and
Completion Domains), every caller’s environment must answer the
query with a value convertible to the declared type. The selected frame
allocator is the result of that conversion, and it allocates frames
itself, without forwarding to another allocator.get_frame_allocator, the upstream
allocator is the answer. That is how the caller chooses (requirement
5b).connect.
Constructing, copying or moving a
function allocates nothing of its
own, so constructing a pending call costs what passing its arguments by
value would (requirement 5a).connect
allocates the frame through the selected frame allocator at most once,
and neither
connect nor
the operation state allocates anything else of its own (requirement 5a).
The sender that the factory returns allocates as it likes, as the
task in Factories did.function’s type. An
implementation that can provide the storage another way needn’t allocate
it, much as an implementation can elide the allocation of a coroutine’s
state, which 9.6.4
[dcl.fct.def.coroutine]
says it only may need. The reference implementation, for
example, holds operation states of up to 56 bytes in a buffer inside the
function’s operation state. Whether
that buffer is worth its cost at scale (Conclusion) is a question of quality of
implementation.connect.
If allocating the frame throws,
connect
exits with that exception, so whether connecting can throw depends on
the selected frame allocator as well as on the
function’s type. The Exception Contract of
connect
explains the rule.connect
allocates raw storage and constructs the implementation’s operation
state in it directly, without uses-allocator construction.
Uses-allocator construction would offer the frame allocator to the
operation state for its own allocations, mixing frames with the
allocations that get_allocator
serves.It leaves unspecified:
get_frame_allocator, the reference
implementation’s fallback is
get_allocator and then
std::allocator.
Leaving the fallback open lets the frame-allocation paper choose a
default without changing function,
and a program that cares where its frames come from can say so in its
environment.function’s type declares the
query, and so picks one type that every caller’s allocator must convert
to, the selected frame allocator’s type is unspecified. Its upstream
allocator can be whatever allocator each caller chooses, but the
environment that the implementation sees, which answers
get_frame_allocator with it, has a
type that can’t depend on the caller (Required Queries and
Completion Domains). In the reference implementation, it’s a
std::pmr::polymorphic_allocator<std::byte>.get_frame_allocator is inspired
by the frame allocator that Vinnie Falco et al. designed for
coroutines, and [P4172R1] (section 11.1)
notes that
exec::function,
this paper’s reference implementation, adopted the pattern. [P4003R3] (section 3.5)
argues that the frame allocator must be a first-class part of an
execution model. In that design, the frame allocator has a default that
the application can replace, and it reaches every frame in a chain of
calls without any signature mentioning it. [P4172R1] (section 6.3)
observes that a frame allocator can be fast because frame sizes repeat
and lifetimes nest, and section 6.3.1 leaves the allocation strategy to
implementations. get_frame_allocator
shares those aims: one allocator for frames, chosen by the caller,
reaching every frame without appearing in signatures, with the strategy
left open.
In the coroutine design, a chain of coroutines starts at a launch
function, called in two phases:
run_async(ex)(my_coroutine())
([P4003R3], section 3.4). The
first call binds an executor and, optionally, a stop token and a frame
allocator, so they are in place before the second call allocates the
first frame ([P4172R1], section 7.1). The
three are kept together in an
io_env, which every coroutine in the
chain borrows ([P4003R3], section 4.1). A
chain launched without a frame allocator uses its execution context’s
default, which the application can replace ([P4003R3], section 4.4).
Within the chain, each coroutine writes its frame allocator to an
out-of-band slot whenever it resumes, and the
operator new
of the next coroutine it calls reads the slot ([P4172R1], section 8.3).
The frame allocator that [P4172R1] (section 6.3.1)
describes, without proposing it, is a recycling allocator. It keeps
recently freed blocks in free lists keyed by size, so once a
steady-state loop has run, each frame is allocated from a list. [P4003R3] (section 3.5)
reports a benchmark in which it ran 3.1 times as fast as
std::allocator
and 1.28 times as fast as mimalloc with MSVC, and 1.55 times as fast as
std::allocator
with Apple Clang.
The coroutine design and this paper’s design differ in when a frame
is allocated, and that decides how the allocator is delivered. A
coroutine’s frame is allocated by its promise’s
operator new
before the coroutine can see any environment, which [P4127R0] (section 2) calls
the frame allocator timing problem, so the coroutine design delivers the
allocator before the call, as an
allocator_arg argument or through
state that
operator new
reads out of band (task). A
function’s frame is allocated at
connect,
when the receiver’s environment is known, so the environment answers the
query directly and the call needs no state outside it.
[P4127R0] (section 6) shows
that the parameter list and ambient state are the only ways to deliver
an allocator to a coroutine’s
operator new,
and for coroutines in today’s language that conclusion is correct. A
language change could alter it, though [P4127R0] (section 10) notes
that only one that changed
operator new’s
interface would escape the two paths. A
function needs neither path, because
it allocates later. Every function
in a chain looks the allocator up in its own receiver’s environment when
it’s connected, so the caller’s choice reaches every
function’s frame, not only the
first. A wrapper that reads the allocator from the environment and
passes it to the first coroutine as an
allocator_arg argument reaches only
that coroutine’s frame, as [P4127R0] (section 5.8)
shows, because each coroutine must forward the allocator by hand to the
coroutines it calls. [P4172R1] (section 11.1)
counts the lack of a frame-allocator propagation mechanism among the
reasons sender/receiver suits I/O less well;
get_frame_allocator, looked up at
connect, is
that mechanism.
The designs also differ in type. [P4003R3] (section 3.5)
allows a frame allocator to be either an allocator or a
std::pmr::memory_resource*,
but its protocol carries a
std::pmr::memory_resource*
(section 4.1), while
get_frame_allocator returns an
allocator, as get_allocator
does.
Each design could adopt some of the other’s choices.
get_frame_allocator leaves room for
the coroutine design’s: a recycling allocator is a strategy, which the
query leaves open; a default held by an execution context could be the
fallback that What the Wording
Guarantees leaves unspecified; and a
std::pmr::memory_resource*
result is a question for Left to a
Later Paper. In today’s language, the coroutine design can’t adopt
function’s mechanism, looking the
allocator up when the frame is allocated, because a coroutine’s frame
exists before the coroutine sees any environment. What it could adopt is
the query itself (Left to a Later
Paper).
The frame-allocation paper would cover what this one leaves out: how
a parallel algorithm gives each branch its own frame allocator (Where frames come from), which
allocation strategies suit frames, what types the query may return, what
function falls back to when the
caller chooses nothing, and, ideally, a frame-allocation design shared
by coroutines and senders.
Such a shared design could start with how an allocator is found.
[P4003R3]’s
io_env (section 4.1) is a struct
with a fixed set of members. If it were standardized instead as a
queryable object that answers
get_frame_allocator, coroutines and
senders would find the frame allocator the same way. Each would still
deliver it to its frames as it must (Frame Allocators for
Coroutines).
While working out how to state requirement 5c, this paper’s author came to suspect that it bears on the principle behind [P3373R4]’s reduction of operation-state lifetimes. A future paper may explore whether that principle can be refined.
connectRequirement 6 asks that an interface be able to state that neither
calling an asynchronous function nor connecting the resulting sender
throws. Calling
kv_store::get
only constructs a function, which
moves its arguments, so get can be
declared
noexcept, as
it is in Full Control. Connecting is harder,
and only the function’s type can
describe it. This section says what declaring a
function with a
noexcept
call type promises about connecting that
function, when
connect is
noexcept,
who chooses what happens when a frame can’t be allocated, and how the
constructor checks the promise.
Four parties decide whether connecting a
function can throw, and each answers
for its own part:
function’s type. A
noexcept
call type promises two things:
noexcept
Promise describes.
get_frame_allocator (Required Queries and
Completion Domains) withdraws that allowance (Frame Allocation in the
Check).noexcept
Promise).noexcept
Promise).Whoever chooses the type of the allocator that frames come from
decides whether allocating a frame can fail, and that appears in
connect’s
exception specification, not in the call type’s promise (The Rule).
Each of function’s two
connect
overloads is
noexcept
exactly when the call type’s promise and the caller’s choices together
rule out an exception:
connect() &&
is noexcept
when the call type is
noexcept and
allocating through the selected frame allocator (What the Wording Guarantees)
can’t throw, which is determined by the
allocate of:
function’s type declares
get_frame_allocator;connect() const &
copies the function and connects the
copy as
connect() &&
does, so it’s
noexcept
when
connect() &&
is and the curried arguments are nothrow copy-constructible.connect() &&
forwards the curried arguments to the factory, so any move or copy they
need happens as part of invoking the factory, which the constructor
checks. Neither overload depends on the receiver itself, which is
nothrow move-constructible (33.7.1
[exec.recv.concepts]);
33.9.10
[exec.connect] lets
connect’s
exception specification depend only on the type of the receiver’s
environment.
Full Control’s
lookup is
noexcept,
but connecting one through lookup_in
can throw, because
std::pmr::polymorphic_allocator’s
allocate reports failure by
throwing. A caller that treats running out of memory for frames as
fatal, as running out of stack is, can say so with an allocator of its
own:
#include "kv_store.hpp"
#include <exec/get_frame_allocator.hpp>
#include <cstddef>
#include <memory_resource>
#include <string>
#include <utility>
// An allocator for frames that can't report failure. allocate is noexcept, so
// if the resource throws, the program terminates.
template <class T>
struct fatal_allocator {
using value_type = T;
explicit fatal_allocator(std::pmr::memory_resource& frames) noexcept
: frames(&frames) {}
template <class U>
fatal_allocator(fatal_allocator<U> const& other) noexcept
: frames(other.frames) {}
T* allocate(std::size_t n) noexcept {
return static_cast<T*>(frames->allocate(n * sizeof(T), alignof(T)));
}
void deallocate(T* p, std::size_t n) noexcept {
frames->deallocate(p, n * sizeof(T), alignof(T));
}
friend bool operator==(fatal_allocator, fatal_allocator) = default;
std::pmr::memory_resource* frames;
};
// looks up key, allocating the lookup's frames from frames, and terminates if
// they can't be allocated
auto lookup_or_terminate(kv_store const& store, std::string key,
std::pmr::memory_resource& frames) {
return store.get(std::move(key))
| ex::write_env(ex::prop(exec::get_frame_allocator,
fatal_allocator<std::byte>(frames)));
}Connecting the sender that
lookup_or_terminate returns is
noexcept
when the sender is an rvalue. Connecting it as an lvalue isn’t, because
the lookup inside it is then
connected as an lvalue too, and copies its key first, which can
throw.
A caller that chooses no frame allocator leaves the choice to the
fallback, which What the Wording
Guarantees leaves to the standard library implementation. That
implementation also decides whether allocating from its fallback can
throw. The reference implementation’s fallback ends with
std::allocator,
whose allocate throws, so there, in
an environment that answers neither
get_frame_allocator nor
get_allocator,
connect can
throw.
A frame allocated after the operation starts is a different matter,
and the interface’s author has already settled it. Full Control’s
cache connects the backing store’s
lookup inside
let_value, after its own operation
has started, so a failure to allocate the backing store’s frame arrives
as a completion, which cache reports
as
errc::not_enough_memory.
The simplest alternative to the proposed rule is a
connect that
is potentially throwing for every
function. No
function could then promise a
non-throwing
connect,
whatever its type and whatever the caller’s allocator, so requirement 6
would go unmet, and code that can’t let an exception escape could
connect a function only by treating
any exception as fatal. Open
Questions and Polls asks LEWG to choose between the two.
Frame allocation is the one failure that a
noexcept
call type can’t rule out on its own. When it fails, a program might want
one of three things:
connect
throw, as a server that sheds load when memory runs short does,
so that it can catch the exception where it accepts work.This paper leaves the choice between the first two to the allocator
that frames come from (Who Answers for
What). Unless the function’s
type declares get_frame_allocator,
that allocator is the selected frame allocator’s upstream allocator,
which the caller chooses or leaves to the fallback. The caller knows
which policy it wants where the interface’s author doesn’t, so one
interface can serve both kinds of caller.
[P0709R4] (section 4.3)
proposes the same division for allocation failure in general: each
allocator decides whether to fail fast or report the failure, in a way
that conditional
noexcept can
test. It records LEWG’s guidance, by unanimous consent in Cologne
(2019), to make library functions conditionally
noexcept
based on how the relevant allocator reports running out of memory.
A simpler policy was considered. A program can’t recover from running
out of stack; [P0709R4] (section 1.1)
calls stack exhaustion an abstract machine corruption that a function
can’t guard against. If frame allocation followed the same policy, a
failure would always terminate, and
connect
would be
noexcept for
every
noexcept
call type. That policy makes every
noexcept
function easy to compose, but it
decides for every caller of every interface, so this paper doesn’t
propose it. A similar policy for the default allocator was polled in EWG
in Cologne: [P0709R4] (section 4.3)
records a poll on making
std::allocator
and global
operator new
terminate on failure, 0-17-6-10-5, without consensus. A caller that
wants the policy gets it from an allocator like
fatal_allocator.
A codebase that never throws and uses one failure policy everywhere
can fix the policy in its interfaces instead. A
function type that declares the
query get_frame_allocator fixes the
selected frame allocator’s type, so if that type’s
allocate is
noexcept,
frame allocation can’t make any caller’s
connect
potentially throwing. However, every caller must then provide a frame
allocator convertible to that type (Required Queries and
Completion Domains).
noexcept
PromiseThe function constructor checks a
noexcept
call type’s promise against the factory it’s given, so the check must
cover each step that connecting a
function takes.
Apart from allocating the frame, a
function’s
connect
takes three steps:
transform_sender, which lets the
domains associated with the sender and the environment replace the
sender (33.9.7
[exec.snd.transform]).The last two steps are how
std::execution::connect
connects any sender to any receiver (33.9.10
[exec.connect]).
For a
noexcept
call type, none of the three steps may throw, unless the frame allocator
provided by the function fails to
allocate a frame during the third, as can happen when a nested
function is connected. Frame Allocation in the Check
explains when that failure is allowed.
The constructor checks step 2 before any caller exists, which is
sound only if no caller can change the transformations that step 2
applies, and none can. The domains that can transform the factory’s
sender depend only on that sender and on the erased receiver’s
environment, which answers only the declared queries and
get_frame_allocator (Required Queries and
Completion Domains). The caller’s domain reaches the implementation
only through a declared query, such as
get_domain or
get_scheduler, and then only as the
type that the query names.
Every check is a Constraint on the constructor, as
move_only_function’s are, so
is_constructible_v answers
truthfully and generic code can ask whether a factory suits a
noexcept
function type. A Recommended
practice paragraph asks implementations to say which requirement a
rejected factory doesn’t meet.
Ideally, a
noexcept
function can forward to another. A
decorator that forwards every call to a backing store’s
noexcept
interface has a factory that returns a
noexcept
function, and if the constructor
rejected that factory,
noexcept
functions wouldn’t compose, against
requirement 2. Achieving it is possible but subtle, so this section
works through the simplest case:
#include <exec/function.hpp>
#include <stdexec/execution.hpp>
#include <string>
#include <utility>
namespace ex = STDEXEC;
// A greeting declares no queries, so it can be connected in any environment,
// whatever frame allocator the environment provides.
using greeting =
exec::function<ex::sender_tag(std::string) noexcept,
ex::completion_signatures<ex::set_value_t(std::string)>>;
greeting greet(std::string name) noexcept;
// Forwards to greet. The factory returns a greeting, so the outer greeting's
// constructor must decide whether connecting the inner one can throw.
greeting greet_again(std::string name) noexcept {
return greeting(std::move(name), [](std::string name) noexcept {
return greet(std::move(name));
});
}Apart from allocating its own frame, connecting the outer
greeting takes the three steps that
Checking the
noexcept
Promise lists. Assume that no domain replaces either
greeting’s sender, so that the
transformation step changes nothing. The steps that remain are:
greeting.greeting to
the outer one’s erased receiver. Connecting a
function takes the same steps, but
this time the frame allocation is part of what’s checked:
greet’s factory, which
returns some sender.greeting’s
frame through its selected frame allocator (What the Wording Guarantees).
That allocator forwards to the frame allocator in the erased receiver’s
environment, which is the outer
greeting’s selected frame
allocator.greet’s
factory returned.The outer greeting’s call type is
noexcept, so
its constructor checks that neither step 1 nor step 2 throws. Step 1
can’t, because the factory is declared
noexcept.
Within step 2, the inner greeting’s
own noexcept
call type guarantees that step 2a can’t throw, and that step 2c can
throw only by failing to allocate a frame through the same allocator as
step 2b. Everything therefore turns on whether step 2b can throw.
greeting declares no queries, so the
outer greeting can be connected in
any environment. The outer selected frame allocator’s type can’t depend
on the environment, but the allocator must forward to whatever frame
allocator the environment provides, including one whose
allocate can throw. So the outer
selected frame allocator’s allocate
can’t be
noexcept,
step 2b can throw, and the inner
greeting’s
connect
can’t be
noexcept (The Rule). The constructor’s check would fail
against the erased receiver as it is, and
noexcept
functions wouldn’t compose.
That rejection would be needlessly strict. Connecting the inner
greeting can throw only by failing
to allocate a frame, and every such frame comes from the outer selected
frame allocator’s upstream allocator, which also decides whether the
outer
connect is
noexcept (The Rule). There are two cases:
allocate can throw, the outer
connect
isn’t
noexcept, so
an exception from connecting the inner
greeting reaches the outer
connect’s
caller.allocate can’t throw, the outer
connect is
noexcept,
and so, in practice, is connecting the inner
greeting. Its
connect
isn’t declared
noexcept,
but every frame it allocates comes from an allocator that doesn’t
throw.In neither case does connecting the inner
greeting make the outer
connect call
std::terminate,
which is why the outer call type’s promise allows nested frame
allocation to fail (Who Answers for
What).
That argument holds only if connecting the inner
greeting can’t throw for any reason
other than failing to allocate a frame, so the constructor asks whether
connecting the inner greeting could
throw if the frame allocator in its receiver’s environment couldn’t. It
asks by testing whether connecting the factory’s sender is
noexcept
with a stand-in receiver type, which is the erased receiver’s type in
every respect but one: the frame allocator in the stand-in’s environment
declares allocate
noexcept.
The stand-in exists only as a type in that test; nothing is ever
connected to it at run time, so the inner
greeting is never connected through
an allocator that falsely claims not to throw.
A function type that declares
get_frame_allocator needs no
stand-in. The erased receiver’s frame allocator is then of the declared
type, so the constructor sees whether that type’s
allocate is
noexcept. If
it is, the check against the erased receiver passes as it stands; if it
isn’t, the check fails, because declaring the query withdraws the
allowance (Who Answers for What).
Either way, connecting the sender of a factory that passes can’t throw
at all.
Substituting the stand-in for the erased receiver is sound as long as
a sender’s
connect
depends on its frame allocator’s exception behaviour only through calls
to allocate, because the constructor
tests with the stand-in’s type while the sender, at run time, sees the
real one. Adaptors that forward
get_frame_allocator to their
children and compute their own
connect’s
exception specification from their children’s meet that condition, so a
nested
noexcept
function is accepted through them.
An adaptor that hides
get_frame_allocator from its
children leaves the inner function
to its fallback, which the stand-in doesn’t replace, so the constructor
rejects the factory unless the fallback’s
allocate is
noexcept. An
adaptor that connects a child after the operation starts, as
let_value does, reports the child’s
failure as a completion, which the type must declare, as Full Control’s
cache does.
Two alternatives were rejected. Trusting a nested
function would make an exception for
one type that a user’s own type-erasing sender couldn’t share. Checking
against the erased receiver as it is would, for a type that doesn’t
declare get_frame_allocator, keep
noexcept
functions from composing.
A failed frame allocation could also be reported asynchronously, as
an error completion. A function
whose completion signatures declared an error such as
set_error_t(frame_allocation_error)
could catch the failure in
connect and
complete with it when started, so its
connect
would be
noexcept
whatever the caller’s allocator, and the interface’s author would have
opted in by declaring the error. nvexec has implementation experience
with the pattern: its operation states allocate device memory when
constructed, catch the failure, and report it from
start as
set_error(cudaError_t).
This paper doesn’t propose such a channel; Open Questions and Polls asks LEWG
whether to pursue one.
An asynchronous frame allocator, whose
allocate returns a sender, would
lead to the same channel by another route. A
function couldn’t allocate its frame
in connect
from such an allocator. It would allocate once the operation starts and
connect the implementation’s sender when the allocation completes, as
let_value connects its continuation,
so a failed allocation could only be a completion. In exchange for an
extra asynchronous step in every call, such an allocator could make a
caller wait for a frame rather than fail, as a pool sized for a bounded
number of operations might, and it would suit memory that is itself
allocated asynchronously, such as CUDA’s stream-ordered
cudaMallocAsync. Its failures would
need a declared error, so a function
would admit it through the same opt-in. Designing it belongs to the
frame-allocation paper that Left to a
Later Paper describes.
Checking the
noexcept
Promise described how connecting a sender first passes it to
transform_sender, and why the
domains that can transform a
function’s implementation are fixed
when the function is constructed. A
function is itself a sender, though,
so it also meets domains from outside the boundary. A domain can
transform it, and the algorithms it’s passed to ask it where it
completes. This section describes both, and asks SG1 about the
first.
function from Outsidetransform_sender works in two
stages (33.9.7
[exec.snd.transform]).
First, the domain in which the sender completes may transform it. Then
the starting domain, which
get_domain reads from the receiver’s
environment, may transform the result. Both
connect and
get_completion_signatures see the
transformed sender (33.9.10
[exec.connect],
33.9.9
[exec.getcomplsigs]).
For a function, the first stage uses
the completion domain that the
function reports (Required Queries and
Completion Domains), and the second uses the caller’s domain,
whatever the function reports.
Domains transform senders to replace the algorithms they’re built
from, which a transformation that reaches a
function can’t do. The
implementation is compiled separately, and its sender has already been
transformed by the domains that apply to it, so a domain outside the
boundary can’t see the algorithms inside to replace them. It can only
wrap the function, or replace it
with something else, and either way nothing guarantees that the caller
still gets what the type describes:
get_completion_signatures reports
the wrapper’s, so the exact completion signatures of Completion Signatures hold only where
no domain intervenes.connect’s
exception specification.
std::execution::connect
is noexcept
only if the transformation is, and Checking the
noexcept
Promise checks only the transformations inside the boundary, so The Rule describes
connect only
where no domain intervenes.Nothing prevents domains from transforming
functions today.
default_domain leaves a
function alone, because a
function isn’t the sender of a
standard algorithm, and nvexec’s
stream_domain does too, because its
transformation is constrained to the algorithms it knows. No rule
requires a domain to leave a
function alone, and a domain whose
transform_sender accepts any sender
wraps every function it meets. The
working draft constrains a domain’s replacement for a standard
algorithm’s sender, which may add only error and stopped completions
(33.9.1
[exec.snd.general]),
but it says nothing about a sender that no standard algorithm
produced.
This paper’s author thinks domains shouldn’t transform a
function, so that its type keeps
describing what the caller gets. The direction this paper suggests is an
opt-out that a sender declares in its attributes and that
transform_sender checks before
either stage, returning such a sender unchanged whatever the domains.
Because
connect and
get_completion_signatures both go
through transform_sender, that one
check covers both. A domain would keep one power over such a sender: it
could reject it, making connecting it ill-formed, as a domain needs to
when its execution resource can’t run the implementation. A rejection
tells the caller at compile time what a wrapper would hide until run
time.
With such an opt-out, the boundary would be domain-neutral in both
directions. The caller’s domain already reaches the implementation only
through a declared query (Required Queries and
Completion Domains), and it would no longer act on the
function from outside either.
function would opt out, and so could
any type-erasing sender a user writes.
task is a natural candidate too,
since a coroutine’s body is equally out of a domain’s reach, and a
separate paper could take it up.
Two alternatives were rejected:
connect that
skips the transformation for
function. It would miss
get_completion_signatures, which
transforms too, and it would make an exception for one type that a
user’s own type-erasing sender couldn’t share.when_all’s children
(COMMON-DOMAIN,
33.9.2
[exec.snd.expos]).This paper proposes no wording for the opt-out; the first of the Questions for SG1 asks about it.
function Starts and CompletesA receiver’s environment tells a sender, through
get_domain, the domain in which its
operation is started (33.5.6
[exec.get.domain]),
and a sender that completes inline, such as
just(),
reports that domain as the one it completes in. A
function’s implementation sees the
erased receiver’s environment, which doesn’t forward
get_domain unless the type declares
it (Required Queries
and Completion Domains), so
get_domain falls back to
default_domain there. So for a
caller started in any other domain, the erased receiver tells the
implementation that it was started somewhere it wasn’t. Every caller on
parallel_scheduler is such a caller,
because that scheduler has its own domain (33.15
[exec.par.scheduler]).
Whether that matters depends on where the implementation completes. This caller looks up a document and checksums its blocks in parallel:
#include "kv_store.hpp"
#include <cstddef>
#include <cstdint>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <utility>
// the checksum of doc's i'th block
std::uint32_t block_checksum(std::string_view doc, std::size_t i);
// looks up key and checksums each block of the document in parallel
void checksum_blocks(kv_store const& store, std::string key,
std::span<std::uint32_t> sums) {
ex::sync_wait(ex::starts_on(
// the caller's environment says the work starts in this scheduler's
// domain
ex::get_parallel_scheduler(),
// the erased receiver says default_domain; a cache completes a hit
// inline, on a parallel_scheduler thread, and a miss on remote_store's
// timer thread, and reports default_domain either way
store.get(std::move(key))
// bulk is customized for its predecessor's completion domain, so
// default_domain's bulk runs, serially, even after a hit
| ex::bulk(ex::par, sums.size(),
[sums](std::size_t i,
std::optional<std::string> const& doc) {
sums[i] = doc ? block_checksum(*doc, i) : 0;
})));
}parallel_scheduler’s domain
customizes bulk, through the
bulk_chunked and
bulk_unchunked senders it’s lowered
to, only in the first stage of
transform_sender, the one that uses
the completion domain of the sender being transformed (33.9.7
[exec.snd.transform]),
and a bulk completes where its
predecessor does. In stdexec,
just() | bulk(…)
started on parallel_scheduler runs
on several threads, while cache
followed by the same bulk runs on
one, after a hit as after a miss. After a miss, that is right:
remote_store completes on its
timer’s thread, which is in
default_domain. After a hit it is
wrong, and not only slow. cache
completes on a parallel_scheduler
thread while reporting
default_domain, and a reported
completion domain is a promise; if a completion happens on an execution
agent of a resource in another domain, the behaviour is undefined
(33.5.12
[exec.get.compl.domain]).
An implementation can ignore the start domain it’s told only if it never
completes on the agent that started it.
Neither of the places that check a
function can tell a hit from a miss.
The constructor checks the factory’s sender against the erased receiver,
where a sender that completes inline looks as if it completes in
default_domain.
connect sees
the caller’s start domain, but by then the implementation’s type has
been erased. Each answer below therefore treats every implementation of
get alike:
Answer
|
Started on
parallel_scheduler, after a
cache hit
|
Started on
parallel_scheduler, after a miss or
from remote_store
|
|---|---|---|
default_domain,
as stdexec does today |
serial
bulk; the report is false |
serial
bulk, correctly |
| No completion domain | serial
bulk; nothing is reported |
as above |
COMMON-DOMAIN
of the start domain and the declared one |
ill-formed | ill-formed |
Reject at
connect
every caller whose start domain differs from the erased receiver’s |
ill-formed | ill-formed |
Making no claim changes nothing for
bulk, because a sender that reports
no completion domain is transformed as if it completed in
default_domain (33.9.7
[exec.snd.transform]).
It also leaves an algorithm that combines children to guess.
when_all reports the common domain
of the agents on which its completions can happen (33.9.1
[exec.snd.general])
and completes from within whichever child completes last
(33.9.12.12
[exec.when.all]),
but a child’s type says where its completions happen only through its
attributes. The exposition-only
COMPL-DOMAIN
(33.9.2
[exec.snd.expos]),
which nothing in the working draft yet uses, would guess that a child
making no claim completes where its siblings do.
The other two answers keep every report true, at the price of
rejecting the example. Because the implementation sees nothing of the
caller beyond the declared queries, it completes either where its own
sender says or where it was started, so a
function could report the common
domain of the two,
COMMON-DOMAIN(get_domain(env), D)
(33.9.2
[exec.snd.expos]),
where D is the domain its type
declares. That is exact for every caller in
D. On
parallel_scheduler it is an
indeterminate_domain naming both,
whose transform_sender requires each
domain it names to transform the sender to the type that
default_domain would, or not at all
(33.9.5
[exec.domain.indeterminate]).
The two customize bulk differently,
so the example becomes ill-formed. Rejecting at
connect
every caller whose start domain differs from the erased receiver’s is
simpler and stricter, since it rejects even a caller with no
bulk to customize, and a type that
avoids it by declaring get_domain
admits callers in only that domain (Required Queries and
Completion Domains).
A type could instead declare that its operation never completes on
the agent that starts it, which the constructor could check by asking
the factory’s sender for its completion domain under a second start
domain.
kv_store::get
couldn’t declare that, because cache
completes hits inline.
None of this is peculiar to erasure. Without a
function, the pipeline of Erasing a Sender Expression is
in the same position, because its type can’t tell a hit from a miss
either: the variant sender holds both. A true report would again be the
common domain of
parallel_scheduler’s domain and
default_domain (33.9.1
[exec.snd.general]),
and the example ill-formed. stdexec reports
parallel_scheduler’s domain for the
unerased pipeline, so bulk runs on
several threads after a hit and after a miss, and the report is false
after a miss, the mirror image of the erased case. For this example,
erasure changes only which case a guess gets wrong.
Proposed Wording makes no claim, which is never false, and proposes none of the other answers. Which cost matters more is left open (A Question Left Open).
Earlier sections leave several questions to later papers: the rest of
a frame-allocation design (Left to a
Later Paper), a typed channel for frame-allocation failure (Reporting
Allocation Failure as a Completion), a way for opaque senders to opt
out of transformation from outside (Transforming a
function from Outside), and the
completion domain a function should
report (Where a
function Starts and Completes).
Where
task is better also mentions a
language extension that would let a coroutine return a
function. This section adds one
more, whether the standard should also offer a shorter way to declare
common interfaces. This paper deliberately doesn’t propose one.
Every interface in this paper is declared through
async_fn, because the proposed form
of function spells out its
completion signatures. R0 also proposed a shorter form, whose first
template argument is an ordinary function type and whose completion
signatures are computed from that type. The reference implementation
still provides it as an extension, so
kv_store could be declared like
this:
#include <exec/function.hpp>
#include <optional>
#include <string>
struct kv_store {
virtual ~kv_store() = default;
virtual exec::function<std::optional<std::string>(std::string) const &>
get(std::string key) const = 0;
};The reference implementation computes the completions as R0 specified
them. A return type R gives
set_value_t(R),
or
set_value_t()
if R is
void. Every
convenience-form type also gets
set_stopped_t(),
and it gets
set_error_t(exception_ptr)
unless its call type is
noexcept.
Those are the three completions that
async_fn spells out (A Third Way), so the return type of
kv_store::get
above isn’t just equivalent to
async_fn<std::optional<std::string>(std::string) const &>,
but the same type. Since the order of completion signatures doesn’t
matter (Completion Signatures), any
alias that spells out the same completions names that type too. An
interface declared through one codebase’s alias can be overridden
through another’s, or through a standard form adopted later.
This paper proposes only the form whose return type is
sender_tag, for two reasons:
function accepts no return type but
sender_tag (The
Call), so a later paper can give other return types a meaning
without changing the meaning of any valid program.Until a later paper settles the defaults, code that wants a short spelling can write an alias, as this paper does.
Like the proposed form, the convenience form accepts
queries and
attrs arguments. Because its
completions are computed, though, it can’t express more than one value
completion, a value completion with more than one argument, or an error
other than exception_ptr, so it
can’t declare Full Control’s
kv_store, whose errors are
std::error_codes.
A follow-up paper would have to answer at least three questions:
cache::get
waits on a miss, as when_all does
when a sibling fails, requests a stop that never reaches
remote_store, so the
when_all completes only once
remote_store’s timer fires. The
result is correct, only late, so nothing tells the interface’s author
that every caller has lost cancellation. Stop requests are how
std::execution
cancels work, so someone declaring an interface in the short form would
reasonably expect a stop request to reach its implementation. Against
that, a forwarded query is a dependence (Required Queries and
Completion Domains). If the default declared an
inplace_stop_token, every caller
would have to provide one, so a caller whose environment has another
kind of stop token couldn’t connect such a
function, and neither could
sync_wait, whose environment has
none. A default would also make an explicit
queries argument ambiguous. If it
replaced the default, an interface that declared one more query would
silently lose its stop token. If it added to the default, an interface
couldn’t opt out.noexcept,
which removes the error completion, should remove the stopped completion
too. A stopped completion isn’t an exception, so
noexcept is
the wrong way to remove it, but the question stands. An implementation
can complete stopped without a forwarded stop token, for example when it
uses associate with a scope that has
closed, or when any sender it runs completes stopped. The answer to the
first question therefore doesn’t settle this one. The costs differ, too.
Forwarding a stop token adds work to every operation at run time. A
declared stopped completion costs nothing at run time, but every
caller’s types must allow for it.noexcept
remove the error completion? In the proposed form, a
noexcept
call type promises that invoking the implementer’s factory and
connecting its sender don’t throw (Who
Answers for What), and the completion signatures say separately
whether the operation can complete with an exception. The convenience
form computes its completion signatures from the call type, so in R0 and
the reference implementation,
noexcept
says both. That follows the synchronous analogy, in which no exception
escapes a call to a
noexcept
function, but it leaves no short spelling for an interface whose call
type is
noexcept but
whose operation can still fail with an exception, or the reverse. Under
this paper’s meaning of
noexcept,
removing the error completion would be a convention of the convenience
form, not a consequence of
noexcept.Poll 2 in Polls for SG1 asks whether SG1 encourages that follow-up paper.
Each poll below points to the section that argues it and says what this paper proposes, if anything.
Polls 1 and 2 ask the two Questions for SG1, in the same order. The first question has three parts, each meaningful only if the one before it has consensus, so it takes three polls.
Poll 1a. SG1 wants a way for an opaque sender, such
as function, to keep domains outside
it from transforming it.
Poll 1b. The opt-out sketched in Transforming a
function from Outside, declared
in the sender’s attributes and checked by
transform_sender before either stage
of transformation, is the right direction.
Poll 1c. Pursue the opt-out in this paper rather than in a separate one.
Transforming a
function from Outside argues for
the direction in poll 1b but proposes no wording. This paper’s author
leans slightly towards pursuing it here. Like
get_frame_allocator, which this
paper proposes rather than leaving to the frame-allocation paper, the
opt-out is machinery that other senders could use, but it is close to
intrinsic to function. Without it, a
function’s type describes its
completions and the exception specification of its
connect only
where no domain intervenes. If poll 1c has consensus, a later revision
would add wording to 33.9.7
[exec.snd.transform]
and have function declare the
opt-out. Otherwise, this paper proposes
function as it stands, and the
separate paper would add the declaration to
function, as it could to
task.
Poll 2. SG1 encourages a follow-up paper proposing a
convenience form of function, whose
completion signatures are computed from an ordinary function type.
This paper’s author thinks a convenience form is important, but doesn’t yet know how it should answer the questions that A Convenience Form sets out, so this paper proposes no answers. Either outcome leaves this paper’s proposal unchanged.
Poll 3. Forward P4223R1 to LEWG.
Forwarding would send LEWG the exception contract that The Exception Contract of
connect
sets out, together with the polls below.
This revision is also addressed to SG14, whose view would help on three points:
connect may
throw. Poll 4 asks LEWG whether
connect
should be
noexcept
when nothing it does can throw, or may throw for every
function (The
Rule). SG14’s experience with code that can’t let an exception
escape bears directly on that choice.fatal_allocator shows (The Rule), or leaves it to the standard library’s
fallback. An interface’s author can fix that allocator’s type instead by
declaring get_frame_allocator, but
every caller must then provide a frame allocator convertible to the
declared type. Does that default suit low-latency and embedded
code?function’s
operation state, and this paper leaves the buffer to the quality of
implementation (Conclusion, What the Wording Guarantees). Is
that enough, or should the wording say more?The Exception Contract
of
connect
leaves two questions to LEWG.
Poll 4. For the exception specification of
function’s
connect,
LEWG prefers:
noexcept
exactly when nothing it does can throw, as The
Rule specifies;function.This paper proposes A, and The Rule argues
against B. Under B, the
noexcept
that The Call allows in a call type would
promise nothing a caller could rely on, so B would most simply return to
R0’s ban on it and drop the check in Checking the
noexcept
Promise.
Under B, Proposed Wording would change as follows:
Sig without
noexcept(N),
so a call type declared
noexcept is
ill-formed, and paragraph 6 no longer defines
N.check-receiver,
is removed.noexcept(see below)
is removed from the declarations of both
connect
overloads, along with paragraph 5’s definitions of
FA and
FB, the note in paragraph 7, the
first sentence of paragraph 8, and paragraph 10. Both overloads are then
potentially throwing.Everything else, including
connect’s
Effects and the rest of its Throws paragraph, is
unchanged.
Poll 5. LEWG would like a future paper to propose a
way for a function to report a
failed frame allocation as a completion, through an error that its
completion signatures declare.
Reporting
Allocation Failure as a Completion describes such a channel and a
second route to it: frame allocators whose
allocate is asynchronous. This paper
takes no position and proposes neither. Because a
function would opt in by declaring
the error, either could be added later without changing the meaning of a
function that doesn’t.
This revision proposes no poll on what completion domain a
function should report. Where a
function Starts and Completes
compares the answers: a claim of
default_domain can be false, a claim
that is always true makes the running example’s caller ill-formed, and
making no claim, as Proposed Wording
does, is never false but leaves the caller’s
bulk serial after a
cache hit. The costs are a false
report, a serial bulk and a rejected
caller, and SG1’s view on which matters most would guide a later
revision. Without erasure, the pipeline of Erasing a Sender Expression
faces the same choice, so the question isn’t specific to
function.
function and
get_frame_allocator are implemented
in stdexec, NVIDIA’s implementation of
std::execution,
as
exec::function
and
exec::get_frame_allocator.
R0’s design reached stdexec’s
main branch in May 2026, shortly
before R0 was published.10 This revision’s design
replaced it there in October 2026,11 so the reference
implementation that this paper cites throughout is
main at the merge commit, dfc8c957.
Proposed Wording is written to match it,
apart from the departures described in Departures from the Wording.
stdexec’s continuous integration builds and runs its tests, including
those
for function, with GCC 12 to 15,
Clang 16 and 22, Apple’s Clang on macOS, MSVC 14.43 and 14.50, and
NVIDIA’s nvc++ 26.5 and 26.9. Its configurations cover C++20 and C++23,
64-bit and 32-bit x86, address, thread and leak sanitizers, and builds
with exceptions disabled. Every configuration passed on the commit that
was merged, b118d667.
The implementation hasn’t yet been shown running in device code. In
R0’s design, a function that wraps a
sender on one of nvexec’s CUDA stream schedulers doesn’t compile,
because nvexec completes the type-erased receiver in a kernel that
requires a trivially copyable receiver. This revision’s design hasn’t
been tried there.
Every example in this paper that has a file name is compiled against
the reference implementation. All of them compile except Full Control‘s final
cache and Member Functions’
base, because of the first and
eighth departures listed in Departures from the Wording.
The implementation provides more than this paper proposes, in ways that earlier sections mention:
std::pmr::memory_resource*
as the result of get_frame_allocator
(Left to a Later Paper).get_frame_allocator: the
environment’s get_allocator, and
otherwise
std::allocator
(What the Wording
Guarantees).The first two are extensions that would need wording of their own. The last two fill in what the wording leaves to implementations.
The implementation departs from what this paper specifies in eleven
ways. All were found while writing this paper: compiling its examples
and stating its claims exactly exposed bugs in the implementation, and
probing the implementation exposed errors in earlier drafts of the
design, such as a narrower construction-time check than the one Checking the
noexcept
Promise now describes. Each has a small fix, which this paper’s
author expects to make before the Búzios meeting.
function whose type declares a
get_stop_token query can’t be
connected, because the state that bridges the caller’s stop token to the
implementation’s inplace_stop_token
isn’t movable, and the type-erased receiver that holds it is required to
be. Separately, start never
registers the callback that forwards a stop request, so a request
wouldn’t arrive even if the function
could be connected.
exec::any_sender,
which shares the type-erasure machinery, does both correctly. R0’s
design had both bugs too. Full Control’s
final cache declares the query,
which is why it doesn’t compile against the reference implementation.
With both bugs fixed locally, it runs as Full
Control describes. The implementation would also admit a caller that
the wording rejects: one with no stop token, for which it gives the
implementation an inplace_stop_token
that never stops.get_frame_allocator. The
implementation substitutes it for every type, so in a type that declares
the query, the stand-in takes the declared allocator’s place. As a
result, a
noexcept
function whose factory returns
another function of a type that
declares the query is rejected, even when the declared allocator’s
allocate is
noexcept.
Nesting
noexcept
functions whose types don’t declare
the query, as the running example does, is accepted.connect
constructs the operation state in raw storage. The implementation shares
its type-erasure machinery with
exec::any_sender,
which constructs through
allocator_traits, so when a type
declares get_frame_allocator, that
allocator’s own construct and
destroy members, if it has any, are
called on a wrapper around the operation state. The operation state
itself is never offered the allocator, so the departure is visible only
to a frame allocator that customizes
construct or
destroy.function reports
default_domain where the wording
makes no claim. For each kind of completion whose domain the
type doesn’t declare, the implementation reports
default_domain, and its constructor
rejects a factory whose sender completes in another domain. Proposed Wording makes no claim, and checks
nothing, for such a completion. Where a
function Starts and Completes
shows how a claim of default_domain
can be false.sender_tag(std::string&&)
and constructs a function of that
type, but connecting it doesn’t compile.sender_tag(Args...) &&
or
sender_tag(Args...) const &&,
which Member Functions describes.constexpr,
but they copy the factory’s bytes into storage with
memcpy, which can’t be evaluated in
a constant expression. An empty callable has no bytes to copy: the
implementation can recreate it with
std::bit_cast
where it’s invoked, for any literal type.function can’t be
named while a parameter type is incomplete. The implementation
computes the types with which the constructor takes the curried
arguments, which depend on traits of the parameter types, when the
function’s type is named, rather
than when it’s instantiated. So base
in Member Functions can’t declare
copied, whose call type takes
base by value.noexcept
Promise checks a domain’s transformation of the factory’s sender,
and the transformed sender’s
connect. The
implementation checks
connect only
for a sender that no domain transforms, so it accepts factories that the
wording rejects, including one whose sender is passed to
bulk. Checking the transformation
also needs stdexec’s
transform_sender to report a
potentially-throwing transformation as such when the transformed sender
is transformed again, which it doesn’t yet.noexcept
query’s answer may convert to the declared type by throwing.
For a query that the type declares
noexcept,
the implementation checks that the caller’s environment answers without
throwing, but not that the answer converts to the declared result type
without throwing, so it accepts a caller that the wording rejects. If
such a conversion throws, the erased query, which is
noexcept,
calls
std::terminate.
exec::any_sender
shares the check and has the same gap.attrs accepts more than
completion domains. The implementation accepts any query
signature as a template argument of
attrs, including a completion-domain
attribute that isn’t
noexcept,
and ignores every one that doesn’t declare a completion domain. So it
accepts types that Proposed Wording
makes ill-formed.exec::function
has been on stdexec’s main branch
since May 2026, in R0’s design until this revision’s replaced it in
October. It has at least one user outside stdexec. asio-ice, an
open-source implementation of Interactive Connectivity Establishment,
declares its type-erased transport as a virtual interface whose members
return
exec::function,
overridden by a class template for each transport, as
kv_store is by
remote_store.12
Its author found and fixed a bug in R0’s implementation, which looked
for the frame allocator in the receiver instead of the receiver’s
environment, so it always fell back to
std::allocator
until September 2026.13 In October, asio-ice moved to this
revision’s design. The move replaced factories that captured
this with
call types qualified
&, so
each function’s type now says that
it borrows the transport, as Member
Functions describes.14 asio-ice also declares
some members in the convenience form (A
Convenience Form). That is one project, so the experience reported
here comes mostly from implementing
function rather than from using
it.
The wording below is relative to the working draft at commit 47974a2
of
cplusplus/draft.
It proposes get_frame_allocator and
the sender_tag form of
function (Interface Discussion), and nothing from
Future Directions. Where it leaves a
choice to implementations, What
the Wording Guarantees says why.
[ Editor's note: Update: Rebase the wording on the working draft that follows the Búzios meeting, and cite it by its N-number. ]
Add to 17.3.2 [version.syn], keeping the list in alphabetical order:
#define __cpp_lib_execution_function 20XXXXL // also in <execution>In 33.4
[execution.syn],
add to the second block of declarations in namespace
std::execution,
after
get_await_completion_adaptor_t:
struct get_frame_allocator_t { unspecified };and after
get_await_completion_adaptor:
inline constexpr get_frame_allocator_t get_frame_allocator{};After the declarations of spawn_t
and spawn, add:
// [exec.function], type-erasing sender
template<class QS>
concept query-signature = see below; // exposition only
template<class A>
concept completion-domain-attribute = see below; // exposition only
template<query-signature... Queries>
struct queries;
template<completion-domain-attribute... Attrs>
struct attrs;
template<class Sig, class... Ts>
using function = see below;std::execution::get_frame_allocatorAdd a new subclause to the end of 33.5 [exec.queries]:
33.5.?
execution::get_frame_allocator
[exec.get.frame.allocator]
1
get_frame_allocator asks a
queryable object for the allocator from which the storage of operation
states connected in it should be allocated.
2
The name get_frame_allocator
denotes a query object. For a subexpression
env,
get_frame_allocator(env) is
expression-equivalent to
MANDATE-NOTHROW(AS-CONST(env).query(get_frame_allocator)).
Mandates: If the expression above is well-formed, its type
satisfies
simple-allocator
(16.4.4.6.1
[allocator.requirements.general]).
3
forwarding_query(get_frame_allocator)
is a core constant expression and has value
true.
4
[ Note: Unlike the allocator
that get_allocator provides,
the allocator that
get_frame_allocator provides
is used only for storage whose lifetime is that of an operation state,
so along a sequential path of operations its allocations and
deallocations nest. — end note ]
std::execution::functionAdd a new subclause to the end of 33.9 [exec.snd]:
33.9.? Type-erasing sender [exec.function]
33.9.?.1 General [exec.function.general]
1
A specialization of function
is a sender type whose objects represent a pending call to an
asynchronous function. The type names the call’s parameters, the ways in
which the call can complete, the queries that the implementation may
make of the environment in which it is connected, and the completion
domains it reports, but not the sender that implements it. An object of
the type holds the arguments of the call, a stateless callable object,
the factory, that returns that sender when invoked with them,
and, for a call to a member function, a pointer to the object on which
it is made.
2
[ Note: A specialization of
function can be the return
type of a virtual function whose overrides return different
senders. — end note ]
3
In [exec.function], the frame of an operation state of a
function is the storage for
the operation state of the sender that its factory returns.
33.9.?.2 Declaring a
function
[exec.function.decl]
1
Let
env-archetype<R(Q, As...) noexcept(N)>
be an exposition-only class type with one member function,
R query(Q, As&&...) const noexcept(N);.
A type QS satisfies
query-signature if
and only if it is a function type of the form
R(Q, As...) or
R(Q, As...) noexcept, where
R is an object or reference
type, Q is a class type, and
As is a pack of types, such
that
is_invocable_v<Q, const env-archetype<QS>&, As...>
is true and, if
QS is of the second form,
is_nothrow_invocable_v<Q, const env-archetype<QS>&, As...>
is true.
QS declares the
query Q with result type
R.
2
A type A satisfies
completion-domain-attribute
if and only if it is a function type of the form
D(get_completion_domain_t<Tag>) noexcept,
where D is a class type that
models default_initializable
and Tag is one of
void,
set_value_t,
set_error_t or
set_stopped_t.
A declares the
domain D for
set_value_t if
Tag is
void, and for
Tag otherwise.
namespace std::execution {
template<query-signature... Queries>
struct queries {};
template<completion-domain-attribute... Attrs>
struct attrs {};
}3
queries<Queries...>
lists the queries that the environment in which a
function is connected must
answer, as Queries declare
them. attrs<Attrs...>
lists the completion domains that a
function reports, as
Attrs declare them.
4
For template arguments Sig
and Ts... of
function, the program is
ill-formed unless:
Sig is a function type of
one of the forms
sender_tag(Args...) noexcept(N),
sender_tag(Args...) & noexcept(N),
sender_tag(Args...) const & noexcept(N),
sender_tag(Args...) && noexcept(N)
or
sender_tag(Args...) const && noexcept(N),
where Args is a pack of
types and N is
true or
false;Ts is
Cs,
Cs, Qs,
Cs, As or
Cs, Qs, As, where
Cs is a specialization of
completion_signatures,
Qs is a specialization of
queries, and
As is a specialization of
attrs;As
declare different domains for the same completion tag;Tag for
which a template argument of
As declares a domain,
Cs().count-of(Tag())
is greater than zero; andQs
declare the same query with the same argument types.5
function<Sig, Ts...>
denotes
func<Sig, CANON(Cs), CANON(Qs), CANON(As)>
([exec.function.class]), where
Qs is
queries<> and
As is
attrs<> if
Ts doesn’t contain them, and
CANON(L<Xs...>)
denotes L<Ys...>,
where Ys is the pack of the
distinct types in Xs, after
each type of the form
D(get_completion_domain_t<void>) noexcept
is replaced by
D(get_completion_domain_t<set_value_t>) noexcept,
ordered so that
type_order_v<Yi, Yj>
is strong_ordering::less for
every i less than
j. [ Note: Specializations of
function whose template
arguments list the same completion signatures, queries and completion
domains, in any order, denote the same type. — end note
]
6
Let Args be the parameter
types of Sig,
N be
true if
Sig is
noexcept and
false otherwise, and
Cs,
Qs and
As be the canonicalized
template arguments of
func. A
function whose
Sig has a
ref-qualifier represents a call to a member function; for such
a function, let
cv be
const if
Sig is
const-qualified and empty
otherwise, and let
ref be the
ref-qualifier of
Sig. The curried
arguments of a function
are its arguments of types
Args.
33.9.?.3 Class template
func
[exec.function.class]
namespace std::execution {
template<class T>
using curried-param-t = see below; // exposition only
template<class Sig, class Cs, class Qs, class As>
class func { // exposition only
public:
using sender_concept = sender_tag;
template<class Factory>
constexpr explicit func(curried-param-t<Args>... args, Factory factory)
noexcept(see below);
template<class Obj, class Factory>
constexpr explicit func(Obj&& obj, curried-param-t<Args>... args, Factory factory)
noexcept(see below);
template<class Factory>
explicit func(curried-param-t<Args>... args, Factory factory);
template<class Obj, class Factory>
explicit func(Obj&& obj, curried-param-t<Args>... args, Factory factory);
template<class Self, class... Env>
static consteval auto get_completion_signatures();
constexpr see below get_env() const noexcept;
template<receiver Rcvr>
constexpr state<Rcvr> connect(Rcvr rcvr) && noexcept(see below);
template<receiver Rcvr>
constexpr state<Rcvr> connect(Rcvr rcvr) const &
noexcept(see below) requires copy_constructible<func>;
private:
cv void* obj; // exposition only
erased-factory factory; // exposition only
tuple<Args...> args; // exposition only
};
}1
The first and third constructors are declared only if
Sig has no
ref-qualifier, and the second and fourth only if it has one.
The member obj is
present only if Sig has a
ref-qualifier.
2
factory holds a
copy of the factory that the constructor was given, as an object of an
unspecified type that doesn’t depend on the factory’s type. Its size and
alignment are no greater than those needed to store any pointer to
function or pointer to member.
3
curried-param-t<T>
denotes const T& if
T is not a reference type
and both
is_trivially_copy_constructible_v<T>
and
is_trivially_move_constructible_v<T>
are true, and
T&& otherwise.
4
Constructing, copying or moving a
func object doesn’t
allocate storage, other than any storage that initializing, copying or
moving its curried arguments allocates.
5
If the definition of
func<Sig, Cs, Qs, As>
is instantiated, the program is ill-formed unless each type in
Args is a reference type or
a type that models
move_constructible. [ Note: A specialization of
function can be named while
a type in Args is
incomplete, for example as the return type of a member function of that
type declared in its class definition. — end note
]
33.9.?.4 The erased receiver [exec.function.rcvr]
1
erased-receiver is
an exposition-only class type that models
receiver and whose type
depends only on Cs and
Qs. An object
er of type
erased-receiver
refers to an operation state
st of type
state<Rcvr>
([exec.function.state]), whose member
rcvr is the
caller’s receiver.
2
For a completion tag Tag and
a pack of subexpressions vs,
Tag()(std::move(er), vs...)
is well-formed if and only if overload resolution selects a unique
signature among those template arguments of
Cs whose tag is
Tag, treating each
Tag(Vs...) as a function
with parameter types
Vs&&... and
vs as the arguments. Its
effects are those of
st.complete(Tag(), std::forward<Vs>(ps)...),
where Vs are the parameter
types of the selected signature and
ps are the parameters,
initialized from vs. [ Note:
er completes only as
Cs declares, after any
implicit conversion of the arguments. — end note
]
3
get_env(er) returns an
object e of an unspecified
type that models
queryable, such
that, for a pack of subexpressions
as:
Qs
declares the query Q with
result type R,
e.query(Q(), as...) has type
R, and is
noexcept if that template
argument is noexcept. It
returns
Q()(get_env(st.rcvr), as...),
converted to R.Q is
get_frame_allocator_t,
e.query(Q()) is
noexcept and returns an
object of an unspecified type that satisfies
simple-allocator,
that compares equal to every copy of it, and that allocates from, and
deallocates to, the caller’s frame allocator of
st
([exec.function.members]). Its
allocate member function
exits via an exception only by propagating one thrown by allocating from
the caller’s frame allocator.e.query(Q(), as...) is
ill-formed.4
[ Note: Of the caller’s
environment, only the queries that
Qs declares reach the sender
that the factory returns. In particular, unless
Qs declares them, that
sender sees neither the caller’s stop token nor its
get_domain and
get_scheduler. —
end note ]
5
For an environment type Env
and a template argument QS
of Qs that declares the
query Q with result type
R and argument types
As, let
E be the expression
Q()(declval<const Env&>(), declval<As>()...).
Env satisfies
QS if
E is well-formed, has a type
convertible to R, and, if
QS is
noexcept, is not
potentially-throwing and
is_nothrow_convertible_v<decltype(E), R>
is true.
6
Let check-receiver
be erased-receiver
if a template argument of Qs
declares the query
get_frame_allocator_t.
Otherwise, it is an exposition-only receiver type that is identical to
erased-receiver,
except that
get_frame_allocator on its
environment returns an object of an unspecified type that satisfies
simple-allocator
and whose allocate member
function is noexcept. No
object of type
check-receiver is
created.
33.9.?.5 Constructors [exec.function.ctor]
template<class Factory>
constexpr explicit func(curried-param-t<Args>... args, Factory factory)
noexcept(see below);
template<class Obj, class Factory>
constexpr explicit func(Obj&& obj, curried-param-t<Args>... args, Factory factory)
noexcept(see below);
template<class Factory>
explicit func(curried-param-t<Args>... args, Factory factory);
template<class Obj, class Factory>
explicit func(Obj&& obj, curried-param-t<Args>... args, Factory factory);1
For the first and third constructors, let
FArgs be
Args&&.... For the
second and fourth, let Self
be
cv remove_cvref_t<Obj> ref,
and FArgs be
Self, Args&&.... Let
S be
invoke_result_t<const Factory&, FArgs...>.
2 Constraints:
same_as<remove_cvref_t<Factory>, func>
is false;Obj is an lvalue reference
type if ref is
& and is not a reference
type otherwise,
remove_reference_t<Obj>
is not volatile-qualified,
and, if cv is
empty,
remove_reference_t<Obj>
is not const-qualified;Factory is a class type
F for which
is_empty_v<F> and
is_trivially_copyable_v<F>
are both true, and for the
last two, Factory is a
pointer to function or a pointer to member;is_invocable_v<const Factory&, FArgs...>
is true;sender-to<S, erased-receiver>
is true;Tag for
which a template argument of
As declares a domain
D,
get_completion_domain<Tag>(get_env(declval<S>()), declval<env_of_t<erased-receiver>>())
is well-formed and has type
D; andN is
true,
is_nothrow_invocable_v<const Factory&, FArgs...>
and
noexcept(connect(declval<S>(), declval<check-receiver>()))
are both true.Hardened preconditions: For the last two constructors,
factory is not a null
pointer value.
3
Effects: For the second and fourth constructors, initializes
obj with
addressof(obj). Initializes
factory with a copy
of factory, and
args with
std::forward<curried-param-t<Args>>(args)....
4
Remarks: The exception specification of the first two
constructors is equivalent to
(is_nothrow_constructible_v<Args, curried-param-t<Args>> && ...).
5 Recommended practice: When a constructor is not viable because a constraint above isn’t satisfied, implementations should issue a diagnostic that says which one.
6
[ Note: Constraint 2.7 checks
that invoking the factory and connecting the sender it returns,
including any transformation of that sender (33.9.10
[exec.connect]),
don’t throw, except by failing to allocate a frame that connecting the
sender needs, such as a nested
function’s. — end
note ]
33.9.?.6 Members [exec.function.members]
template<class Self, class... Env>
static consteval auto get_completion_signatures();1
Returns: Cs(), if
sizeof...(Env) is 1 and
Env satisfies each template
argument of Qs
([exec.function.rcvr]), or if
sizeof...(Env) is 0 and
Qs is
queries<>.
2
Throws: An exception of type
dependent_sender_error if
sizeof...(Env) is 0 and
Qs isn’t
queries<>. Otherwise,
an exception of type
unspecified-exception
(33.9.1
[exec.snd.general])
if Env doesn’t satisfy each
template argument of Qs.
constexpr see below get_env() const noexcept;3
Returns: An object
a of an unspecified empty
class type that models
queryable, such
that, for each completion tag
Tag and pack of
subexpressions envs,
a.query(get_completion_domain<Tag>, envs...)
is noexcept and returns
D() if a template argument
of As declares the domain
D for
Tag, and is ill-formed
otherwise. For any other query object
q,
a.query(q, envs...) is
ill-formed.
4
[ Note: A
function whose type declares
no domain for a completion tag makes no claim about where it completes
with that tag, because where its implementation completes can depend on
where it was started. — end note ]
template<receiver Rcvr>
constexpr state<Rcvr> connect(Rcvr rcvr) && noexcept(see below);5
Let env be
get_env(rcvr). The
caller’s frame allocator of the operation state
st that
connect returns is a copy of
get_frame_allocator(env) if
that expression is well-formed, and otherwise a copy of an allocator of
an unspecified type that satisfies
simple-allocator.
The frame allocator of
st is
get_frame_allocator(get_env(er)),
where er is an
erased-receiver
that refers to st. [ Note: If a template argument of
Qs declares the query
get_frame_allocator_t, the
frame allocator is the caller’s frame allocator converted to the
declared result type ([exec.function.rcvr]). — end note
] Let FA be
the type of the frame allocator if a template argument of
Qs declares the query
get_frame_allocator_t, and
the type of the caller’s frame allocator otherwise, and let
FB be
allocator_traits<FA>::rebind_alloc<byte>.
6
Effects: Returns an object
st of type
state<Rcvr>
whose member rcvr
is direct-non-list-initialized with
std::move(rcvr). While
initializing st:
static_cast<Self>(o),
where o is an lvalue that
denotes the object that
obj points to and
Self is the type with which
the factory was checked ([exec.function.ctor]), if
obj is present,
followed by
std::forward<Args>(get<Is>(args))...,
where Is is the pack
0,
1, …,
sizeof...(Args) - 1, to
obtain a sender sndr;O, which is
connect_result_t<decltype(sndr), erased-receiver>,
either by allocating it from the frame allocator or by a means that
doesn’t allocate storage dynamically; andO in that storage,
initialized with
connect(std::move(sndr), er),
where er is an
erased-receiver
that refers to st.
Uses-allocator construction (20.2.8
[allocator.uses])
is not used.If an exception is thrown after the storage is obtained, the storage is released before the exception propagates.
7
Throws: Any exception thrown by allocating from the frame
allocator, invoking the factory, or connecting
sndr. [ Note: If
N is
true, constraint 2.7 of
[exec.function.ctor] rules out the last two, except by failing to
allocate a frame that connecting
sndr needs. — end
note ]
8
Remarks: The exception specification is equivalent to
N && noexcept(declval<FB&>().allocate(size_t())).
Apart from the frame’s storage (6.2), neither this function nor the
operation state it returns allocates storage dynamically, except within
invocations of the factory, of
connect on
sndr, of member functions of
the object it returns, or of member functions of
rcvr or of its environment.
[ Note: 6.2 allows the storage
to be obtained without allocating it dynamically, for example when
O is small. — end
note ]
template<receiver Rcvr>
constexpr state<Rcvr> connect(Rcvr rcvr) const &
noexcept(see below) requires copy_constructible<func>;9
Effects: Equivalent to:
return func(*this).connect(std::move(rcvr));
10
Remarks: The exception specification is equivalent to
noexcept(declval<func>().connect(declval<Rcvr>())) && (is_nothrow_copy_constructible_v<Args> && ...).
33.9.?.7 Operation state [exec.function.state]
namespace std::execution {
template<class Rcvr>
class state { // exposition only
public:
using operation_state_concept = operation_state_tag;
state(state&&) = delete;
~state();
void start() & noexcept;
template<class Tag, class... Vs>
void complete(Tag, Vs&&... vs) noexcept; // exposition only
Rcvr rcvr; // exposition only
};
}1
An object of type
state<Rcvr>
owns the operation state of type
O constructed by
connect
([exec.function.members]), and its storage.
void start() & noexcept;2
Effects: Calls
start on the owned operation
state.
template<class Tag, class... Vs>
void complete(Tag, Vs&&... vs) noexcept;3
Effects: Evaluates
Tag()(std::move(rcvr), std::forward<Vs>(vs)...).
~state();4 Effects: Destroys the owned operation state. If its storage was allocated from the frame allocator, deallocates it to the frame allocator before returning.
[ Editor's note: Expand: Give
erased-receiver and
state as
exposition-only code, as 33.13.6.4
[task.state] does for
task, in place of the prose
in [exec.function.rcvr] and [exec.function.state]. Add a table mapping
each claim in Interface Discussion,
What the Wording Guarantees
and The Exception Contract
of connect to the
paragraph that makes it true. ]
[ Editor's note: Expand: Add examples to
[exec.function.general]: the running example’s
kv_store with every argument
spelled out, and a call to a member function. ]
[P4088R1] (section 7.4)
erases a read stream by caching storage for one awaitable and reusing it
for every read. The type below does the same with senders. Its erased
receiver hides the caller, as
coroutine_handle<>
does, so the operation state of a concrete stream’s read depends only on
the stream, and the erased stream can allocate storage for it once. Like
[P4088R1]’s stream, it
allows one read at a time.
#include <stdexec/execution.hpp>
#include <cstddef>
#include <new>
#include <optional>
#include <span>
#include <system_error>
#include <utility>
namespace ex = STDEXEC;
// what a read can ask of the caller: a stop token of one fixed type
struct erased_env {
ex::inplace_stop_token stop_token;
auto query(ex::get_stop_token_t) const noexcept { return stop_token; }
};
// a receiver of one fixed type, whoever is waiting, as coroutine_handle<>
// is one fixed type, whoever is suspended
struct erased_receiver {
using receiver_concept = ex::receiver_t;
struct vtable {
void (*value)(void*, std::size_t) noexcept;
void (*error)(void*, std::error_code) noexcept;
void (*stopped)(void*) noexcept;
};
void* target;
vtable const* vt;
ex::inplace_stop_token stop_token;
void set_value(std::size_t n) && noexcept { vt->value(target, n); }
void set_error(std::error_code ec) && noexcept { vt->error(target, ec); }
void set_stopped() && noexcept { vt->stopped(target); }
erased_env get_env() const noexcept { return {stop_token}; }
};
// A read stream of any type whose read_some returns a sender of std::size_t.
// One read may be outstanding at a time.
class any_read_stream {
// the operation state of Stream's read, which depends only on Stream
// because the receiver is erased
template <class Stream>
using inner_op_t =
ex::connect_result_t<decltype(std::declval<Stream&>().read_some(
std::span<std::byte>())),
erased_receiver>;
struct vtable {
void (*connect_into)(void* slot, void* stream, std::span<std::byte>,
erased_receiver);
void (*start)(void* slot) noexcept;
void (*destroy)(void* slot) noexcept;
};
template <class Stream>
static constexpr vtable vtable_for{
[](void* slot, void* s, std::span<std::byte> buf, erased_receiver r) {
::new (slot) inner_op_t<Stream>(ex::connect(
static_cast<Stream*>(s)->read_some(buf), std::move(r)));
},
[](void* slot) noexcept {
ex::start(*static_cast<inner_op_t<Stream>*>(slot));
},
[](void* slot) noexcept {
static_cast<inner_op_t<Stream>*>(slot)->~inner_op_t<Stream>();
}};
void* stream_;
vtable const* vt_;
void* slot_; // storage for one read's operation state, reused by every read
public:
// inner_op_t<Stream>'s size is known here, so the storage is allocated once
template <class Stream>
explicit any_read_stream(Stream& s)
: stream_(&s),
vt_(&vtable_for<Stream>),
slot_(::operator new(sizeof(inner_op_t<Stream>))) {
static_assert(alignof(inner_op_t<Stream>) <=
__STDCPP_DEFAULT_NEW_ALIGNMENT__);
}
any_read_stream(any_read_stream&&) = delete;
~any_read_stream() { ::operator delete(slot_); }
// what read_some returns: a sender whose operation state holds the caller's
// receiver and keeps the read's own operation state in the stream's storage
struct read_sender {
using sender_concept = ex::sender_t;
template <class Self, class... Env>
static consteval auto get_completion_signatures() noexcept {
return ex::completion_signatures<ex::set_value_t(std::size_t),
ex::set_error_t(std::error_code),
ex::set_stopped_t()>();
}
template <class Receiver>
struct operation {
using operation_state_concept = ex::operation_state_t;
static constexpr erased_receiver::vtable completions{
[](void* t, std::size_t n) noexcept {
auto& self = *static_cast<operation*>(t);
self.callback.reset();
ex::set_value(std::move(self.rcvr), n);
},
[](void* t, std::error_code ec) noexcept {
auto& self = *static_cast<operation*>(t);
self.callback.reset();
ex::set_error(std::move(self.rcvr), ec);
},
[](void* t) noexcept {
auto& self = *static_cast<operation*>(t);
self.callback.reset();
ex::set_stopped(std::move(self.rcvr));
}};
// forwards the caller's stop requests to the read
struct forward_stop {
ex::inplace_stop_source* source;
void operator()() const noexcept { source->request_stop(); }
};
using caller_token = ex::stop_token_of_t<ex::env_of_t<Receiver>>;
operation(Receiver r, any_read_stream* s, std::span<std::byte> buf)
: rcvr(std::move(r)), stream(s) {
stream->vt_->connect_into(
stream->slot_, stream->stream_, buf,
erased_receiver{this, &completions, source.get_token()});
}
operation(operation&&) = delete;
~operation() { stream->vt_->destroy(stream->slot_); }
void start() & noexcept {
callback.emplace(ex::get_stop_token(ex::get_env(rcvr)),
forward_stop{&source});
stream->vt_->start(stream->slot_);
}
Receiver rcvr;
any_read_stream* stream;
ex::inplace_stop_source source;
std::optional<ex::stop_callback_for_t<caller_token, forward_stop>>
callback;
};
template <class Receiver>
operation<Receiver> connect(Receiver rcvr) && {
return {std::move(rcvr), stream, buf};
}
any_read_stream* stream;
std::span<std::byte> buf;
};
read_sender read_some(std::span<std::byte> buf) { return {this, buf}; }
};The operation that
read_some’s sender connects to the
caller’s receiver is concrete, so it lives in the caller’s operation
state, as an awaiter lives in the caller’s coroutine frame. So do the
stop source and the callback that forward the caller’s stop requests to
the read. Only the read’s own operation state needs storage of unknown
size, and the erased stream provides it.
This program counts allocations while it erases a stream and reads from it a million times:
#include "any_read_stream.hpp"
#include <atomic>
#include <cstdio>
#include <cstdlib>
std::atomic<int> allocations{0};
void* operator new(std::size_t n) {
++allocations;
if (void* p = std::malloc(n)) {
return p;
}
throw std::bad_alloc();
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
// a stream whose reads complete at once, filling the whole buffer
struct null_stream {
auto read_some(std::span<std::byte> buf) { return ex::just(buf.size()); }
};
int main() {
std::byte buf[64];
null_stream concrete;
int before = allocations;
any_read_stream stream(concrete);
std::printf("erasing the stream: %d allocation(s)\n", allocations - before);
before = allocations;
for (int i = 0; i < 1'000'000; ++i) {
ex::sync_wait(stream.read_some(buf));
}
std::printf("1,000,000 reads: %d allocation(s)\n", allocations - before);
}On Compiler Explorer,
built with GCC 16.2 and with Clang 23.1 at
-O2 under
AddressSanitizer, it reports one allocation when the stream is erased
and none for the reads. It requests no stops, but forwarding one would
allocate nothing either, since the stop source and the callback are
members of operation.
Thank you to Vinnie Falco and the team at C++ Alliance for your
efforts to explain the shortcomings of Senders and Receivers as shipped
in C++26 when applied to network IO problems. This proposal is a
synthesis of my understanding of the problems you’ve articulated and of
the Sender/Receiver architecture; it wouldn’t have happened without your
prompting and explanations. Your frame allocator for coroutines also
inspired get_frame_allocator.
Thank you to Robert Leahy for repeatedly articulating so clearly and in fine detail how senders project regular old functions into the asynchronous domain. Your talks have changed how I think about senders, and your chosen vocabulary has made it easier for me to explain and reason about async C++. Thanks also for early feedback on this paper.
Thank you to the members of SG1 who reviewed R0 in Brno; your
questions shaped this revision. Frank Birbacher asked why
function is declared like a function
rather than as an erased sender factory. Ruslan Arutyunyan asked how
function compares with
any_sender, and shared Boost’s
experience with fixed-capacity buffers. Robert Leahy suggested bounding
the size of the erased operation state, and Michael Wong asked what each
operation costs at scale.
Thank you to Mark Hoemmen for explaining that GPUs are vastly more complex than I had ever guessed, and how that complexity might affect the performance of virtual functions and other indirect calls. His explanation informed Separation in device code.
Finally, thanks to Eric Niebler, Ville Voutilainen, and the rest of
the folks in the
std::execution
Discord who helped me refine the design of
function both before and after I
started writing a prototype.
I prepared this revision with Claude. Claude wrote the first drafts of most sections from outlines I approved, proposed edits that I accepted, rejected or rewrote, wrote the examples and compiled them against the reference implementation, ran the experiments behind the paper’s measurements, and checked its citations against the papers cited. Claude is also credited as co-author of the reference implementation’s changes for this revision (Implementation Experience). I reviewed every change before submitting this paper. Every design decision is mine, and so is responsibility for any errors.
The examples use stdexec’s spellings so that they
compile against the reference implementation:
STDEXEC names stdexec’s
implementation of
std::execution,
which is intended to conform to the standard, and
exec names its extensions, including
the function this paper proposes.↩︎
While this example was being written, a
decltype
that named the scheduler as an rvalue, where the definition used an
lvalue, produced a different type in stdexec, because its
schedule_after has a lambda in its
type. That is a property of one implementation, not of the standard, but
it illustrates how closely the declaration is tied to the definition.↩︎
https://docs.nvidia.com/cuda/cuda-programming-guide/05-appendices/cpp-language-support.html, table “C++20 Language Features Supported by NVCC for device code”.↩︎
https://rocm.docs.amd.com/projects/HIP/en/develop/how-to/kernel_language_cpp_support.html.↩︎
https://github.com/NVIDIA/stdexec/blob/main/.github/workflows/ci.cpu.yml↩︎
task’s
connect
isn’t declared
noexcept
(33.13.6.3
[task.members]), but
with the default Environment nothing
it does can throw; stdexec’s task
declares it
noexcept.↩︎
The synchronous row is a model of what the abstract machine provides, not a claim about any implementation. An optimizer may inline a call and allocate nothing; an asynchronous frame, likewise, may fit in storage the caller already has.↩︎
This analogy also explains the leak of implementation into interface that Returning an Implementation-Specific Sender showed. Today’s concrete senders are like inline functions: the compiler must see the whole implementation in order to, among other things, reserve space in the caller’s frame for the callee’s.↩︎