Towards Senders in Interfaces

Document #: P4223R1 [Latest] [Status]
Date: 2026-10-23
Project: Programming Language C++
Audience: SG1
SG14
LEWG
Reply-to: Ian Petersen
<>

Contents

1 Abstract

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.

1.1 Questions for SG1

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:

  1. Nothing stops a domain from transforming an opaque sender like 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?
  2. Is a convenience form that computes completions from the function type (Future Directions) worth a follow-up paper?

1.2 Questions for SG14

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

1.3 Questions for LEWG

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.

2 Revision History

2.1 Changes since R0

R1 changes function’s design and rewrites the paper’s argument for it.

2.1.1 Changes to the Design

  1. Only the 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.
  2. 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).
  3. Factories are stateless, and a 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).
  4. The constructors’ declarations changed. R0 took every curried argument as 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.
  5. The implementation sees the caller’s frame allocator. The erased receiver’s environment answers 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).
  6. Declared queries accept convertible results. A query’s result need only convert to the declared type, where R0 required exactly that type, and a type may not declare the same query with the same argument types twice (Required Queries and Completion Domains).
  7. attrs declares only completion domains. R0 accepted any query, and its example declared get_domain_t (Required Queries and Completion Domains).
  8. Order doesn’t matter. Completion signatures, queries and completion domains are put in a canonical order, so types whose lists differ only in order are the same type (Completion Signatures).
  9. The design is specified as wording. Proposed Wording replaces R0’s exposition-only code with wording, a feature-test macro and synopsis entries. It specifies what R0 said only in prose: constructing a 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.

2.1.2 Changes to the Paper

2.1.3 Feedback from SG1 in Brno

3 Motivation

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.

3.1 A Key-Value Store

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?

3.2 Returning an Implementation-Specific Sender

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.

3.2.1 Composing Standard Senders

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:

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

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

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

3.2.2 Writing a Sender by Hand

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.

3.3 Returning task

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

3.4 A Third Way

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:

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.

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

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

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

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

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

  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.

    1. No allocation beyond the activation frame. Apart from what its work allocates, an operation must allocate only the frame that holds its state. Calling an asynchronous function and moving the sender it returns must allocate nothing beyond what passing its arguments by value would.
    2. The caller chooses the frame allocator. A synchronous function doesn’t choose the stack it runs on, and nothing in its signature names one. Every frame in a chain of calls comes from the stack that the outermost caller runs on, without any function in between passing the stack along. By analogy, the caller must choose the frame allocator, the choice must not require any signature to name it, and it must reach every frame that the operation allocates without the functions in between passing it on.
    3. Frame lifetimes nest along sequential paths. Along a sequential path, a frame allocated while another is live must be freed first, whatever order the calling code constructs its senders in, so that a frame allocator serving that path can be as simple as a stack. This requirement places no constraint on frames on different paths, such as those of 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.

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

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

  8. Ergonomics. Interfaces occur at every layer of a program, so the effort of declaring and implementing them adds up.

    1. Declaring an interface should be about as easy as declaring the synchronous function it parallels.
    2. Writing an implementation should be about as easy as writing the work itself.
  9. 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.

5 Designs Considered

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.

5.1 task

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

5.2 Erasing a Sender Expression

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.

5.2.1 any_sender

stdexec’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:

  1. Constructing the 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).
  2. Connecting the 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.

5.2.2 Writing It Yourself

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.

5.2.3 function

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

6 Comparison

6.1 Feature Comparison

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.

6.2 Requirements Comparison

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:

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.

6.3 Allocation Patterns

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.

6.4 Analysis

6.4.1 The activation frame is the boundary’s cost

[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:

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.

6.4.2 Fixing the frame’s size

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

6.4.3 Where frames come from

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.

6.4.4 Separation in device code

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.

6.4.5 Where task is better

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

6.5 Conclusion

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:

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.

7 Interface Discussion

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:

  1. a function type whose return type is sender_tag, describing the call;
  2. a specialization of completion_signatures, listing how the operation can complete;
  3. optionally, a specialization of queries, listing what the operation needs from the caller’s environment; and
  4. optionally, a specialization of attrs, 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.

7.1 The Call

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.

7.2 Completion Signatures

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.

7.3 Required Queries and Completion Domains

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:

  1. A forwarded query has a cost. Each one is an indirect call through the erased receiver, and one whose result must be adapted can cost more. Declaring a query is how an interface’s author chooses to pay for it.
  2. A forwarded query is a dependence. An implementation that can see a query can rely on it, which makes it something every caller must provide. The type says so, as its parameter types say what every caller must pass.
  3. The caller’s domain doesn’t reach the implementation. A query such as 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.

7.4 Member Functions

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>>);

7.5 Factories

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:

  1. State in the factory would be state the type doesn’t show. A lambda that captures 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.
  2. Every stateless factory fits on every implementation. Like R1’s, R0’s factory storage had a fixed size, so constructing a 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.

7.6 Full Control

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:

8 Frame Allocation

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.

8.1 The Query

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.

8.2 What the Wording Guarantees

For a function’s frame, the proposed wording specifies:

  1. Where the frame comes from. connect allocates the frame through what this section calls the selected frame allocator:
    • If the 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.
    • Otherwise, the selected frame allocator is an object of unspecified type that forwards every allocation and deallocation to its upstream allocator:
      • If the caller’s environment answers get_frame_allocator, the upstream allocator is the answer. That is how the caller chooses (requirement 5b).
      • Otherwise, the upstream allocator is a fallback that the standard library implementation chooses.
  2. Nothing is allocated before 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).
  3. At most one allocation. 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.
  4. The frame’s storage needn’t be allocated. A frame must generally be allocated dynamically because the operation can outlive the call that started it (requirement 5) and its size can’t be fixed in the 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.
  5. The frame is freed with the operation state. If the frame was allocated, it’s deallocated through the selected frame allocator before the operation state’s destructor returns. That ties the frame’s lifetime to the operation state’s, so frames nest wherever operation states do. Along a sequential path, operation states nest (Where frames come from), which meets requirement 5c. It also lets a caller destroy its frame allocator once the operations that use it are destroyed.
  6. A failed allocation is reported by 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.
  7. The operation state is constructed in the frame, not by the allocator. 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:

  1. What happens when the caller chooses nothing. In an environment that doesn’t answer 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.
  2. The type of the selected frame allocator. Unless the 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>.

8.3 Frame Allocators for Coroutines

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

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

9 The Exception Contract of connect

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

9.1 Who Answers for What

Four parties decide whether connecting a function can throw, and each answers for its own part:

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

9.2 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() && 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.

9.3 Choosing a Failure Policy

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:

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

9.4 Checking the noexcept Promise

The 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:

  1. It invokes the factory, which returns a sender.
  2. It passes that sender and the erased receiver’s environment to transform_sender, which lets the domains associated with the sender and the environment replace the sender (33.9.7 [exec.snd.transform]).
  3. It connects the transformed sender to the erased receiver, which stands in for the caller’s concrete receiver.

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.

9.4.1 Frame Allocation in the Check

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:

  1. Invoke the outer factory, which returns the inner greeting.
  2. Connect the inner 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:
    1. Invoke greet’s factory, which returns some sender.
    2. Allocate the inner 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.
    3. Connect the sender that 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:

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.

9.5 Reporting Allocation Failure as a Completion

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.

10 Domains and Type Erasure

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.

10.1 Transforming a function from Outside

transform_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:

  1. The completions can differ from the declared ones. get_completion_signatures reports the wrapper’s, so the exact completion signatures of Completion Signatures hold only where no domain intervenes.
  2. So can 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:

  1. A rule in 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.
  2. Reporting a completion domain whose transformation does nothing. The second stage uses the caller’s domain whatever the sender reports, so this couldn’t stop the caller’s domain at all. A domain invented to opt out would also spoil comparisons between domains, such as the common domain of 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.

10.2 Where a function Starts and Completes

A 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).

11 Future Directions

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.

11.1 A Convenience Form

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:

  1. The defaults couldn’t be changed. Once standardized, the computed completions would be part of the type of every interface declared with them, so changing a default would change those interfaces’ types, and with them every caller’s ABI. This paper’s 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.
  2. The defaults aren’t obvious. Each one makes a choice that the proposed form leaves to the interface’s author, and, as the questions below show, the arguments for each choice pull in different directions.

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:

  1. Should the stop token be forwarded by default? In R0 and the reference implementation, the convenience form, like the proposed form, forwards only the queries a type declares. A caller that gives up while 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.
  2. Should every convenience-form type complete stopped? At R0’s presentation in Brno, the question came up whether 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.
  3. Should 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.

12 Open Questions and Polls

Each poll below points to the section that argues it and says what this paper proposes, if anything.

12.1 Polls for SG1

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.

12.2 Feedback from SG14

This revision is also addressed to SG14, whose view would help on three points:

  1. Whether 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.
  2. Who chooses the failure policy. Whether a failed frame allocation throws or terminates depends on the allocator that frames come from (Choosing a Failure Policy). By default the caller chooses that allocator through its receiver’s environment, as 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?
  3. What each operation costs at scale. At R0’s presentation in Brno, Michael Wong asked how much memory each operation costs when many are in flight. The reference implementation’s inline buffer adds to every 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?

12.3 Polls for LEWG

The Exception Contract of connect leaves two questions to LEWG.

Poll 4. For the exception specification of function’s connect, LEWG prefers:

  1. noexcept exactly when nothing it does can throw, as The Rule specifies;
  2. potentially throwing for every 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:

  1. In [exec.function.decl], paragraph 4.1 lists the forms of Sig without noexcept(N), so a call type declared noexcept is ill-formed, and paragraph 6 no longer defines N.
  2. In [exec.function.rcvr], paragraph 6, which defines check-receiver, is removed.
  3. In [exec.function.ctor], constraint 2.7 and the note in paragraph 6 are removed. The constructors’ own exception specifications, which depend only on the curried arguments, are unchanged.
  4. In [exec.function.members], 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.

12.4 A Question Left Open

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.

13 Implementation Experience

function and get_frame_allocator are implemented in stdexec, NVIDIA’s implementation of std::execution, as exec::function and exec::get_frame_allocator.

13.1 Where It’s Implemented

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.

13.2 Beyond the Wording

The implementation provides more than this paper proposes, in ways that earlier sections mention:

  1. The convenience form, whose completion signatures are computed from an ordinary function type (A Convenience Form).
  2. A std::pmr::memory_resource* as the result of get_frame_allocator (Left to a Later Paper).
  3. A fallback for an environment that doesn’t answer get_frame_allocator: the environment’s get_allocator, and otherwise std::allocator (What the Wording Guarantees).
  4. An inline buffer that holds operation states of up to 56 bytes without allocating a frame (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.

13.3 Departures from the Wording

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.

  1. Stop requests don’t reach the implementation. A 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.
  2. The construction-time check ignores a declared frame allocator. Frame Allocation in the Check substitutes a non-throwing stand-in for the frame allocator only when the type doesn’t declare 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.
  3. A declared frame allocator can see the operation state’s construction. What the Wording Guarantees says that 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.
  4. A 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.
  5. A curried parameter of rvalue reference type can’t be connected. The implementation accepts a call type such as sender_tag(std::string&&) and constructs a function of that type, but connecting it doesn’t compile.
  6. Rvalue-qualified call types are rejected. The implementation doesn’t accept sender_tag(Args...) && or sender_tag(Args...) const &&, which Member Functions describes.
  7. No constructor can be evaluated at compile time. The constructors that take an empty callable are declared 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.
  8. A 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.
  9. The construction-time check trusts transformations. Checking the 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.
  10. A 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.
  11. 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.

13.4 Deployment

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.

14 Proposed Wording

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

14.1 Feature-Test Macro

Add to 17.3.2 [version.syn], keeping the list in alphabetical order:

#define __cpp_lib_execution_function 20XXXXL // also in <execution>

14.2 Header Synopsis

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;

14.3 std::execution::get_frame_allocator

Add 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 ]

14.4 std::execution::function

Add 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:

  • (4.1) 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;
  • (4.2) 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;
  • (4.3) no two template arguments of As declare different domains for the same completion tag;
  • (4.4) for each completion tag Tag for which a template argument of As declares a domain, Cs().count-of(Tag()) is greater than zero; and
  • (4.5) no two template arguments of Qs 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:

  • (3.1) If a template argument of 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.
  • (3.2) Otherwise, if 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.
  • (3.3) Otherwise, 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:

  • (2.1) same_as<remove_cvref_t<Factory>, func> is false;
  • (2.2) for the second and fourth constructors, 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;
  • (2.3) for the first two constructors, 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;
  • (2.4) is_invocable_v<const Factory&, FArgs...> is true;
  • (2.5) sender-to<S, erased-receiver> is true;
  • (2.6) for each completion tag 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; and
  • (2.7) if N 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:

  • (6.1) invokes the factory as a const lvalue with 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;
  • (6.2) obtains storage for an object of type 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; and
  • (6.3) constructs an object of type O 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. ]

15 Appendix: An Erased Stream Built on Senders

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

16 Acknowledgements

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.

16.1 Disclosure of AI Use

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.

17 References

[Kokkos-virtual] Kokkos Project. Kokkos and Virtual Functions.
https://kokkos.org/kokkos-core-wiki/ProgrammingGuide/Kokkos-and-Virtual-Functions.html
[Leahy] Robert Leahy. Evolving C++ Networking with Senders & Receivers [part 1].
https://www.youtube.com/watch?v=S5v7f8rjSVQ
[NVIDIA-LTO] Mike Murphy and Arthy Sundaram. 2021. Improving GPU Application Performance with CUDA 11.2 Device Link Time Optimization.
https://developer.nvidia.com/blog/improving-gpu-app-performance-with-cuda-11-2-device-lto/
[P0709R4] Herb Sutter. 2019-08-04. Zero-overhead deterministic exceptions: Throwing values.
https://wg21.link/p0709r4
[P2300R10] Eric Niebler, Michał Dominiak, Georgy Evtushenko, Lewis Baker, Lucian Radu Teodorescu, Lee Howes, Kirk Shoop, Michael Garland, Bryce Adelstein Lelbach. 2024-06-28. `std::execution`.
https://wg21.link/p2300r10
[P2430R0] Christopher Kohlhoff. 2021-08-23. Slides: Partial success scenarios with P2300.
https://wg21.link/p2430r0
[P3373R4] Robert Leahy. 2026-03-27. Of Operation States and Their Lifetimes.
https://wg21.link/p3373r4
[P4003R3] Vinnie Falco, Mungo Gill, Steve Gerbino. 2026-05-12. Ask: A Minimal Coroutine Execution Model.
https://wg21.link/p4003r3
[P4007R3] Vinnie Falco, Mungo Gill. 2026-05-12. Info: Open Issues in `std::execution::task`.
https://wg21.link/p4007r3
[P4088R1] Vinnie Falco. 2026-05-12. Info: What C++20 Coroutines Already Buy The Standard.
https://wg21.link/p4088r1
[P4089R1] Vinnie Falco. 2026-05-12. Info: On the Diversity of Coroutine Task Types.
https://wg21.link/p4089r1
[P4100R1] Vinnie Falco, Steve Gerbino, Michael Vandeberg, Mungo Gill, Mohammad Nejati. 2026-05-12. Info: Coroutine-Native I/O for C++29 (The Network Endeavor).
https://wg21.link/p4100r1
[P4127R0] Vinnie Falco. 2026-05-12. Info: The Coroutine Frame Allocator Timing Problem.
https://wg21.link/p4127r0
[P4148R2] Jonathan B. Coe, Hana Dusikova, Antony Peacock, Philip Craig, Neelofer Banglawala. 2026-06-11. protocol: Structural Subtyping for C++.
https://wg21.link/p4148r2
[P4172R1] Vinnie Falco, Steve Gerbino. 2026-05-12. Info: IoAwaitable for Coroutine-Native Byte-Oriented I/O.
https://wg21.link/p4172r1
[P4329R0] Robert Leahy. 2026-07-29. variant_sender.
https://wg21.link/p4329r0
[P4337R0] Robert Leahy. 2026-08-03. emplace_from.
https://wg21.link/p4337r0

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

  2. 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.↩︎

  3. 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”.↩︎

  4. https://rocm.docs.amd.com/projects/HIP/en/develop/how-to/kernel_language_cpp_support.html.↩︎

  5. https://github.com/NVIDIA/stdexec/blob/main/.github/workflows/ci.cpu.yml↩︎

  6. 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.↩︎

  7. 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.↩︎

  8. 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.↩︎

  9. Arthur O’Dwyer, “Value category is not lifetime”↩︎

  10. NVIDIA/stdexec#2040↩︎

  11. NVIDIA/stdexec#2321↩︎

  12. https://github.com/SamGaaWaa/asio-ice↩︎

  13. NVIDIA/stdexec#2249↩︎

  14. SamGaaWaa/asio-ice#3↩︎