scope_association concept to
P3149| Document #: | P3815R1 |
| Date: | 2025-11-07 |
| Project: | Programming Language C++ |
| Audience: |
LEWG Library Evolution Working Group |
| Reply-to: |
Ian Petersen <ispeters@gmail.com> Jessica Wong <jesswong2011@gmail.com> |
[P3149R11] was approved for C++26 by
WG21. The paper introduces two scope types,
simple_counting_scope and
counting_scope, along with
several basis operations, including
associate,
spawn, and
spawn_future. In R11, the
example implementations of these facilities are expressed in terms of
the scope_token concept:
template <class Token>
concept scope_token =
copyable<Token> &&
requires(const Token token) {
{ token.try_associate() } -> same_as<bool>;
{ token.disassociate() } noexcept -> same_as<void>;
{ token.wrap(declval<test-sender>()) } -> sender_in<test-env>;
};In [P3149R7], we also
introduced the scope_association
concept as an RAII handle for managing scope associations. However, this
facility was removed in [P3149R9] due to
concerns about the unconventional behavior of its copy constructor. As
there was no implementation experience with
scope_association at the time of
R9’s approval, the implications of this removal was not fully
understood.
Later implementation efforts demonstrated that reintroducing the
scope_association concept from
[P3149R7] would yield several benefits
for sender/receiver library implementors:
These improvements can be achieved without any impact on the user-facing APIs proposed in R11.
Illustrated below are the R11 implementations of
spawn and
associate in contrast with the
scope_association concept
implementation.
Before
|
After
|
|---|---|
|
|
In the above example, a key difference when implementing
spawn-state with a
scope_association concept is
that t.try_associate() is
invoked in the constructor rather than in
run(). This change simplifies
exception handling within
spawn-state: if an
exception occurs, only the allocation needs to be explicitly cleaned up.
By contrast, if
t.try_associate() throws from
run(), both destruction and
deallocation needs to be explicitly handled.
Before
|
After
|
|---|---|
|
|
template <class Assoc>
concept scope_association =
movable<Assoc> &&
default_initializable<Assoc> &&
requires(Assoc assoc) {
{ static_cast<bool>(assoc) } noexcept;
{ assoc.try_associate() } -> same_as<Assoc>;
};A type that models
scope_association is an RAII
handle that represents a possible association between a sender and an
async scope. If the scope association contextually converts to true then
the object is “engaged” and represents an association; otherwise, the
object is “disengaged” and represents the lack of an association. Scope
associations are movable and not copyable, and expose a
try_associate member function
with semantics identical to the
try_associate member function on
a type that models
scope_token.
The following are the proposed changes to
scope_token,
associate,
spawn,
spawn_future,
simple_counting_scope, and
counting_scope with the adoption
of scope_association.
execution::scope_tokenThe primary change to
scope_token is to
try_associate, which will return
a scope_association rather than
a bool.
template <class Token>
concept scope_token =
copyable<Token> &&
requires(Token token) {
{ token.try_associate() } -> scope_association;
{ token.wrap(declval<test-sender>()) } -> sender_in<test-env>;
};The try_associate member
function on a token attempts to create a new association with the scope;
try_associate returns an engaged
association when the association is successful, and it may either return
a disengaged association or throw an exception to indicate failure.
execution::associateWith the application of the proposed changes, the copy behavior of
the associate-sender returned from
associate becomes the
following:
If the sender, snd, provided
to associate() is copyable then
the resulting associate-sender is also copyable, with the following
rules:
copying an unassociated associate-sender invariably produces a new unassociated associate-sender; and
copying an associated associate-sender requires copying the
associate-data it
contains and the
associate-data
copy-constructor proceeds as follows:
The result of invoking the source’s
association.try_associate()
will be passed to the destination
associate-data.
associate-data; the
destination associate-sender is associatedFurthermore, the
operation-state’s
destructor becomes the following:
An operation-state
with its own association must invoke the association’s destructor as the
last step of the
operation-state’s
destructor.
execution::spawnThe behavior of spawn remains
largely unchanged, with the primary difference being that
op_t now holds an
association rather than
a token. Upon
completion of the
operation-state, the
destructor of the
association is invoked,
replacing the previous mechanism of explicitly calling
token.disassociate() on
the local copy of the
token.
execution::spawn_futureThe changes to spawn_future
reflect the same changes proposed in
spawn.
execution::simple_counting_scopeThe behavior of
simple_counting_scope remains
largely unchanged, with the primary difference being that the
disassociation is handled by the destructor of the association returned
from token.try_associate().
execution::counting_scopeThe changes to counting_scope
reflect the same changes proposed in
simple_counting_scope.
<execution> synopsis
33.4
[execution.syn]To the <execution>
synopsis 33.4
[execution.syn],
make the following change:
// [exec.scope]
// [exec.scope.concepts], scope concepts
template <class Token>
concept scope_association = see below;
template <class Token>
concept scope_token = see below;execution::associateTo the subsection 33.9.12.16 [exec.associate], make the following changes:
2
Let associate-data be
the following exposition-only class template:
namespace std::execution {
template <scope_token Token, sender Sender>
struct associate-data { // exposition only
using wrap-sender = // exposition only
remove_cvref_t<decltype(declval<Token&>().wrap(declval<Sender>()))>;
using assoc-t = // exposition only
decltype(declval<Token&>().try_associate());
using sender-ref = // exposition only
unique_ptr<wrap-sender, decltype([](auto* p) noexcept { destroy_at(p); })>;
explicit associate-data(Token t, Sender&& s)
: sndr(t.wrap(std::forward<Sender>(s))),
token(t) {
assoc([&] {
sender-ref guard{addressof(sndr)};
auto assoc = t.try_associate();
if (assoc)
guard.release();
return assoc;
}()) {
if (!token.try_associate())
sndr.reset();
}
associate-data(const associate-data& other)
noexcept(is_nothrow_copy_constructible_v<wrap-sender> &&
noexcept(other.tokenassoc.try_associate()));
associate-data(associate-data&& other)
noexcept(is_nothrow_move_constructible_v<wrap-sender>);
: associate-data(std::move(other).release()) {}
~associate-data();
optional<pair<Token, wrap-sender>>
pair<assoc-t, sender-ref>
release() && noexcept(is_nothrow_move_constructible_v<wrap-sender>);
private:
optional<wrap-sender> sndr; // exposition only
Token token; // exposition only
associate-data(pair<assoc-t, sender-ref> parts); // exposition only
union {
wrap-sender sndr; // exposition only
};
assoc-t assoc; // exposition only
};
template <scope_token Token, sender Sender>
associate-data(Token, Sender&&) -> associate-data<Token, Sender>;
}3
For an associate-data
object a, a.sndr.has_value()
isbool(a.assoc)
is true if and only
if an association was successfully made and is owned by
a.
associate-data(const associate-data& other)
noexcept(is_nothrow_copy_constructible_v<wrap-sender> &&
noexcept(other.tokenassoc.try_associate()));4
Constraints: wrap-sender
models copy_constructible<wrap-sender>is
.true
5
Effects: Value-initializes
Initializes
sndr and
initializes
token with
other.token.
If
other.sndr.has_value()
is false, no
further effects; otherwise, calls
token.try_associate()
and, if that returns
true, calls
sndr.emplace(*other.sndr)
and, if that exits with an exception, calls
token.disassociate()
before propagating the exception.assoc with
other.assoc.try_associate().
If
bool(assoc)
is true initializes
sndr with
other.sndr.
associate-data(associate-data&& other)
noexcept(is_nothrow_move_constructible_v<wrap-sender>);6
Effects:
Initializes
sndr with
std::move(other.sndr)
and initializes
token with
std::move(other.token)
and then calls
other.sndr.reset().
associate-data(pair<assoc-t, sender-ref> parts);
6
Effects: Initializes
assoc with
std::move(parts.first).
If
bool(assoc)
is true initializes
sndr with
std::move(*parts.second).
~associate-data();
7
Effects: If
If
sndr.has_value()
returns false then
no effect; otherwise, invokes
sndr.reset()
before invoking
token.disassociate().bool(assoc)
is true destroys
sndr.
optional<pair<Token, wrap-sender>>
pair<assoc-t, sender-ref>
release() && noexcept(is_nothrow_move_constructible_v<wrap-sender>);
8
Effects: If
sndr.has_value()
returns false then
returns an optional
that does not contain a value; otherwise returns an
optional containing
a value of type pair<Token, wrap-sender>
as if by:
return optional(pair(token, std::move(*sndr)));
Constructs an object
u of type
sender-ref
that is initialized with
addressof(sndr)
if
bool(assoc)
is true and with
nullptr otherwise,
then returns pair{std::move(assoc), std::move(u)}.
9
Postconditions:
sndr does
not contain a value.
10 The
name associate denotes a
pipeable sender adaptor object. For subexpressions
sndr and
token, if
decltype((sndr)) does not
satisfy sender, or remove_cvref_t<decltype((token))>
does not satisfy scope_token,
then associate(sndr, token) is
ill-formed.
…
13 The
member impls-for<associate_t>::get-state
is initialized with a callable object equivalent to the following
lambda:
[]<class Sndr, class Rcvr>(Sndr&& sndr, Rcvr& rcvr) noexcept(see below) {
auto&& [_, data] = std::forward<Sndr>(sndr);
auto dataParts = std::move(data).release();
using scope_token = decltype(dataParts->first);
using wrap_sender = decltype(dataParts->second);
using associate_data_t = remove_cvref_t<decltype(data)>;
using assoc_t = associate_data_t::assoc-t;
using sender_ref_t = associate_data_t::sender-ref;
using op_t = connect_result_t<wrap_sendertypename sender_ref_t::element_type, Rcvr>;
struct op_state {
boolassoc_t associated = false; // exposition only
union {
Rcvr* rcvr; // exposition only
struct {
scope_token token; // exposition only
op_t op; // exposition only
} assoc; // exposition only
};
explicit op_state(Rcvr& r) noexcept
: rcvr(addressof(r)) {}
explicit op_state(scope_token tkn, wrap_sender&& sndr, Rcvr& r) try
: associated(true),
assoc(tkn, connect(std::move(sndr), std::move(r))) {
}
catch (…) {
tkn.disassociate();
throw;
}
explicit op_state(pair<assoc_t, sender_ref_t> parts, Rcvr& r)
: assoc(std::move(parts.first)) {
if (assoc)
::new (voidify(op)) op_t(
connect(std::move(*parts.second), std::move(r)));
else
rcvr = addressof(r);
}
explicit op_state(associate_data_t&& ad, Rcvr& r)
: op_state(std::move(ad).release(), r) {}
explicit op_state(const associate_data_t& ad, Rcvr& r)
requires copy_constructible<associate_data_t>
: op_state(associate_data_t(ad).release(), r) {}
op_state(op_state&&) = delete;
~op_state() {
if (associated) {
assoc.op.~op_t();
assoc._token_.disassociate();
assoc._token_.~scope_token();
}
}
void run() noexcept { // exposition only
if (associated)
start(assoc.op);
else
set_stopped(std::move(*rcvr));
}
};
if (dataParts)
return op_state{std::move(dataParts->first), std::move(dataParts->second), rcvr};
else
return op_state{[std::forward_like\<Sndr\>(data),\ ]{.add}@rcvr};
}14 The
expression in the noexcept
clause of impls-for<associate_t>::get-state
is
is_nothrow_constructible_v<remove_cvref_t
is_nothrow_move_constructible_v<wrap-sender> &&
(is_same_v<Sndr, remove_cvref_t
is_nothrow_constructible_v<remove_cvref_t<Sndr>, Sndr>) &&
nothrow-callable<connect_t, wrap-sender, Rcvr>
where wrap-sender is
the type remove_cvref_t<data-type<Sndr>>::wrap-sender.
execution::spawn_futureTo the subsection 33.9.12.18 [exec.spawn.future], make the following changes:
7
Let spawn-future-state
be the exposition-only class template:
namespace std::execution {
template <class Alloc, scope_token Token, sender Sender, class Env>
struct spawn-future-state // exposition only
: spawn-future-state-base<completion_signatures_of_t<future-spawned-sender<Sender, Env>>> {
using sigs-t = // exposition only
completion_signatures_of_t<future-spawned-sender<Sender, Env>>;
using receiver-t = // exposition only
spawn-future-receiver<sigs-t>;
using op-t = // exposition only
connect_result_t<future-spawned-sender<Sender, Env>, receiver-t>;
spawn-future-state(Alloc alloc, Sender&& sndr, Token token, Env env) // exposition only
: alloc(std::move(alloc)),
op(connect(
write_env(stop-when(std::forward<Sender>(sndr), ssource.get_token()), std::move(env)),
receiver-t(this))),
token(std::move(token)),
associated(token.try_associate()) {
if (associatedassoc)
start(op);
else
set_stopped(receiver-t(this));
}
void complete() noexcept override; // exposition only
void consume(receiver auto& rcvr) noexcept; // exposition only
void abandon() noexcept; // exposition only
private:
using alloc-t = // exposition only
typename allocator_traits<Alloc>::template rebind_alloc<spawn-future-state>;
using assoc-t = // exposition only
remove_cvref_t<decltype(declval<Token&>().try_associate())>;
alloc-t alloc; // exposition only
ssource-t ssource; // exposition only
op-t op; // exposition only
Tokenassoc-t tokenassoc; // exposition only
bool associated; // exposition only
void destroy() noexcept; // exposition only
};
}…
void destroy() noexcept;
12 Effects: Equivalent to:
auto token = std::move(this->token);
bool associated = this->associated;
auto assoc = std::move(this->assoc);
{
auto alloc = std::move(this->alloc);
allocator_traits<alloc-t>::destroy(alloc, this);
allocator_traits<alloc-t>::deallocate(alloc, this, 1);
}
if (associated)
token.disassociate();execution::spawnTo the subsection 33.9.13.3 [exec.spawn], make the following changes:
5 Let spawn-state be the exposition-only class template:
namespace std::execution {
template <class Alloc, scope_token Token, sender Sender>
struct spawn-state : spawn-state-base { // exposition only
using op-t = connect_result_t<Sender, spawn-receiver>; // exposition only
spawn-state(Alloc alloc, Sender&& sndr, Token token); // exposition only
void complete() noexcept override; // exposition only
void run() noexcept; // exposition only
private:
using alloc-t = // exposition only
typename allocator_traits<Alloc>::template rebind_alloc<spawn-state>;
using assoc-t = // exposition only
remove_cvref_t<decltype(declval<Token&>().try_associate())>;
alloc-t alloc; // exposition only
op-t op; // exposition only
Tokenassoc-t tokenassoc; // exposition only
void destroy() noexcept; // exposition only
};
}spawn-state(Alloc alloc, Sender&& sndr, Token token);
6
Effects: Initializes
Initializes
alloc with
alloc,
token with
token, and
op
with:
connect(std::move(sndr), spawn-receiver(this))alloc with
std::move(alloc),
op with
connect(std::move(sndr), spawn-receiver(this)),
and assoc
with
token.try_associate().
void run() noexcept;
7 Effects: Equivalent to:
if (token.try_associate()assoc)
start(op);
else
destroycomplete();void complete() noexcept override;
8 Effects: Equivalent to:
auto token = std::move(this->token);
destroy();
token.disassociate();void destroy() noexcept;
9
Effects: Equivalent to:
auto assoc = std::move(this->assoc);
auto alloc = std::move(this->alloc);
allocator_traits<alloc-t>::destroy(alloc, this);
allocator_traits<alloc-t>::deallocate(alloc, this, 1);At the beginning of subsection 33.14.1 [exec.scope.concepts], make the following changes
1
The
scope_assocation
concept defines the requirements on a type
Assoc. An object of
type Assoc is
engaged if and only if it owns an association with an async
scope, referred to as its associated scope.
namespace std::execution {
template <class Assoc>
concept scope_association =
movable<Assoc> &&
is_nothrow_move_constructible_v<Assoc> &&
is_nothrow_move_assignable_v<Assoc> &&
default_initializable<Assoc> &&
requires(const Assoc assoc) {
{ static_cast<bool>(assoc) } noexcept;
{ assoc.try_associate() } -> same_as<Assoc>;
};
}2
A type
Assoc models
scope_association
only if:
Assoc is not
engaged;assoc of type
Assoc,
static_cast<bool>(assoc)is
true if and only if
assoc is
engaged;Assoc own the
same assocation;assoc of type
Assoc, destroying
assoc releases the
assocation owned by
assoc, if
any;assoc of type
Assoc, after it is
used as the source operand of a move constructor, the
assoc is not
engaged;assoc1 and
assoc2 of type
Assoc, after
evaluating
assoc1 = std::move(assoc2),
the association owned by
assoc1, if any, is
released and assoc2
is not engaged;assoc of type
Assoc that is
engaged,
assoc.try_associate()
either returns an object that is not engaged or acquires a new
association with
assoc’s associated
scope and returns an engaged object that owns that
association;assoc of type
Assoc that is not
engaged,
assoc.try_associate()
returns an object that is not engaged.3
The scope_token concept defines
the requirements on a type Token
that can be used to create associations between senders and an async
scope. Every object of
type Token is
associated with an async scope that is referred to as its associated
scope.
4
Let test-sender and
test-env be unspecified
types such that sender_in<test-sender, test-env>
is modeled.
namespace std::execution {
template <class Token>
concept scope_token =
copyable<Token> &&
requires(const Token token) {
{ token.try_associate() } -> same_as;
{ token.disassociate() } noexcept -> same_as;
{ token.try_associate() } -> scope_association;
{ token.wrap(declval<test-sender>()) } -> sender_in<test-env>;
};
}5
A type Token models
scope_token only if:
token of type
Token,
token.try_associate()
either returns an object that is not engaged or acquires a new
association with
token’s associated
scope and returns an engaged object that owns that association;
andexecution::simple_counting_scope
and execution::counting_scopeAdd the following to the end of paragraph three of 33.14.2.1 [exec.counting.scopes.general]:
template <class Scope>
struct association-t;Add the following as paragraph five of 33.14.2.1 [exec.counting.scopes.general]:
5
association-t
is a class template, specializations of which model
scope_association
and contain an exposition-only member
scope of
type Scope*. For a
class type Scope
and an object assoc
of type
association-t<Scope>:
assoc.scope
points to its associated scope,assoc
is engaged when
assoc.scope != nullptr
is
true,assoc is engaged
then
assoc.try_associate()
is equivalent to assoc.scope->try-associate(),
andassoc is released
by invoking assoc.scope->disassociate().To the subsection 33.14.2.2.1 [exec.scope.simple.counting.general], make the following change:
namespace std::execution {
class simple_counting_scope {
public:
// [exec.simple.counting.token], token
struct token;
// [exec.simple.counting.assoc], assoc
using assoc-t = association-t<simple_counting_scope>; // exposition only
static constexpr size_t max_associations = implementation-defined;
// [exec.simple.counting.ctor], constructor and destructor
simple_counting_scope() noexcept;
simple_counting_scope(simple_counting_scope&&) = delete;
~simple_counting_scope();
// [exec.simple.counting.mem], members
token get_token() noexcept;
void close() noexcept;
sender auto join() noexcept;
private:
size_t count; // exposition only
scope-state-type state; // exposition only
boolassoc-t try-associate() noexcept; // exposition only
void disassociate() noexcept; // exposition only
template <class State>
bool start-join-sender(State& state) noexcept; // exposition only
};
}To the subsection 33.14.2.2.3 [exec.simple.counting.mem], make the following changes:
boolassoc-t try-associate() noexcept;
5
Effects: If
count is equal to
max_associations, then no
effects. Otherwise, if
state is
unused, then increments
count and changes
state to
open;open or
open-and-joining, then
increments count;6
Returns: If
true
if count
was incremented,
false
otherwise.count was
incremented, an object of type
assoc-t
that is engaged and associated with
*this, and
assoc-t()
otherwise.
To the subsection 33.14.2.2.4 [exec.simple.counting.token], make the following changes:
namespace std::execution {
struct simple_counting_scope::token {
template <sender Sender>
Sender&& wrap(Sender&& snd) const noexcept;
boolassoc-t try_associate() const noexcept;
void disassociate() const noexcept;
private:
simple_counting_scope* scope; // exposition only
};
}template <sender Sender>
Sender&& wrap(Sender&& snd) const noexcept;1
Returns:
std::forward<Sender>(snd).
boolassoc-t try_associate() const noexcept;
2
Effects: Equivalent to: return scope->try-associate();
void disassociate() const noexcept;
3
Effects: Equivalent to
scope->disassociate().
To the subsection 33.14.2.3 [exec.scope.counting], make the following changes:
namespace std::execution {
class counting_scope {
public:
using assoc-t = association-t<counting_scope>; // exposition only
struct token {
template <sender Sender>
sender auto wrap(Sender&& snd) const noexcept(see below);
boolassoc-t try_associate() const noexcept;
void disassociate() const noexcept;
private:
counting_scope* scope; // exposition only
};
static constexpr size_t max_associations = implementation-defined;
counting_scope() noexcept;
counting_scope(counting_scope&&) = delete;
~counting_scope();
token get_token() noexcept;
void close() noexcept;
sender auto join() noexcept;
void request_stop() noexcept;
private:
size_t count; // exposition only
scope-state-type state; // exposition only
inplace_stop_source s_source; // exposition only
boolassoc-t try-associate() noexcept; // exposition only
void disassociate() noexcept; // exposition only
template <class State>
bool start-join-sender(State& state) noexcept; // exposition only
};
}assoc-t try-associate() noexcept;5
Effects: If
count is
equal to
max_associations,
then no effects. Otherwise, if
state
is
unused,
then increments
count and
changes
state to
open;open
or
open-and-joining,
then increments
count;6
Returns: If
count was
incremented, an object of type
assoc-t
that is engaged and associated with
*this, and
assoc-t()
otherwise.
…
assoc-t counting_scope::token::try_associate() const noexcept;8
Effects:
Equivalent to: return scope->try-associate();