Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/modules/ROOT/nav.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
** xref:9.design/9n.WhyNotCobalt.adoc[Why Not Cobalt?]
** xref:9.design/9o.WhyNotCobaltConcepts.adoc[Why Not Cobalt Concepts?]
** xref:9.design/9p.WhyNotTMC.adoc[Why Not TooManyCooks?]
** xref:9.design/9q.WhyNotStdExecution.adoc[Why Not std::execution?]
* xref:A.specification-methods/A.intro.adoc[Methods of API Description]
** xref:A.specification-methods/Ab.cancellation.adoc[Cancellation]
** xref:A.specification-methods/Ac.contingencies.adoc[Contingencies]
Expand Down
2 changes: 2 additions & 0 deletions doc/modules/ROOT/pages/4.coroutines/4e.context-awaitable.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ Forward propagation offers several advantages:

This design enables Capy's type-erased wrappers (cpp:any_stream[], etc.) to work without knowing the concrete executor type.

xref:9.design/9q.WhyNotStdExecution.adoc[Why Not std::execution?] contrasts forward propagation with the backward queries of senders and receivers.

== A Vocabulary for Coroutine Interop

Because the protocol is just a two-argument `await_suspend`, cpp:ContextAwaitable[] is more than an internal mechanism—it is a _vocabulary type_ for interoperation. Any coroutine library that speaks the protocol can propagate execution environment across a `co_await` boundary without knowing the other side's concrete task type.
Expand Down
1 change: 1 addition & 0 deletions doc/modules/ROOT/pages/8.examples/8o.sender-bridge.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ result: 1764

* xref:4.coroutines/4e.context-awaitable.adoc#bridging-a-foreign-awaitable[Bridging a Foreign Awaitable] — the general pattern and why there is no universal bridge
* xref:8.examples/8p.asio-use-capy.adoc[Calling Asio from a Capy Coroutine] — the same technique for Asio operations
* xref:9.design/9q.WhyNotStdExecution.adoc[Why Not std::execution?] — why Capy carries context forward instead of building on senders
2 changes: 2 additions & 0 deletions doc/modules/ROOT/pages/9.design/9.intro.adoc
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//
// Copyright (c) 2025 Vinnie Falco (vinnie.falco@gmail.com)
// Copyright (c) 2026 Michael Vandeberg
//
// Distributed under the Boost Software License, Version 1.0. (See accompanying
// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
Expand Down Expand Up @@ -27,3 +28,4 @@ Each page examines one concept or facility in depth: the rationale behind its de
* xref:9.design/9n.WhyNotCobalt.adoc[Why Not Cobalt?] -- How Capy's foundation differs from Boost.Cobalt's Asio-based design.
* xref:9.design/9o.WhyNotCobaltConcepts.adoc[Why Not Cobalt Concepts?] -- A side-by-side look at type-erased write-stream algorithms in both libraries.
* xref:9.design/9p.WhyNotTMC.adoc[Why Not TooManyCooks?] -- Choosing between Capy and TooManyCooks by workload: network I/O versus compute.
* xref:9.design/9q.WhyNotStdExecution.adoc[Why Not std::execution?] -- Why Capy carries context forward instead of building on P2300 senders, and how to use both.
16 changes: 2 additions & 14 deletions doc/modules/ROOT/pages/9.design/9k.Executor.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -271,21 +271,9 @@ NOTE: For the TLS save/restore protocol required around `.resume()` calls (cpp:s

== Why Not `std::execution` (P2300)

https://wg21.link/P2300[P2300] defines a sender/receiver model where execution context flows _backward_ from receiver to sender via queries after `connect()`:
Capy's launchers, cpp:run_async[] and cpp:run[], bind the executor before the task runs. The task's promise stores the executor and propagates it to nested awaitables via `await_transform`. Context flows from caller to callee at every level, never backward.

[source,cpp,role=external]
----
task<int> async_work(); // Frame allocated NOW
auto sndr = async_work();
auto op = connect(sndr, receiver); // Allocator available NOW
start(op); // -- too late
----

For coroutines, this ordering is fatal. Coroutine frame allocation happens _before_ the coroutine body executes. The compiler calls `operator new` first, then constructs the promise, then begins execution. Any mechanism that provides the allocator _after_ the coroutine call -- receiver queries, `await_transform`, explicit method calls -- arrives after the frame is already allocated with the wrong (or default) allocator.

Capy's model flows context _forward_ from launcher to task. The cpp:run_async[]`(ex, alloc)(my_task())` two-phase invocation sets the thread-local allocator _before_ the task expression is evaluated, so `operator new` reads it in time. This is described in detail in xref:9.design/9l.RunApi.adoc[Run API].

The same forward-flowing model applies to executors. The launcher binds the executor before the task runs. The task's promise stores the executor and propagates it to nested awaitables via `await_transform`. Context flows from caller to callee at every level, never backward.
A P2300 sender takes the opposite direction: it queries its receiver for a scheduler after `connect()`. xref:9.design/9q.WhyNotStdExecution.adoc[Why Not std::execution?] compares the two models, including why the direction decides whether a coroutine frame's allocator is known before the frame is allocated.

== Conforming Signatures

Expand Down
10 changes: 1 addition & 9 deletions doc/modules/ROOT/pages/9.design/9l.RunApi.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -254,15 +254,7 @@ The solution is to store a _copy_ of the allocator in the awaitable returned by

=== Comparison with `std::execution`

In `std::execution` (P2300), context flows _backward_ from receiver to sender via queries _after_ `connect()`:

[source,cpp,role=external]
----
task<int> async_work(); // Frame allocated NOW
auto sndr = async_work();
auto op = connect(sndr, receiver); // Allocator available NOW -- too late
start(op);
----
In `std::execution` (P2300), an operation queries the receiver for its context after `connect()`, when a coroutine's frame already exists. xref:9.design/9q.WhyNotStdExecution.adoc#frame-allocation[Why Not std::execution?] works through that ordering.

In the _ContextAwaitable_ model, context flows _forward_ from launcher to task:

Expand Down
8 changes: 4 additions & 4 deletions doc/modules/ROOT/pages/9.design/9m.WhyNotAsio.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -126,19 +126,19 @@ When you write a coroutine, three questions arise immediately. Where does it run

*How do you cancel it?* A user clicks Cancel. A timeout expires. The server is shutting down. Your coroutine needs to stop—gracefully, without leaking resources. {cpp}20 gives us `std::stop_token`, a beautiful one-shot notification mechanism. But how does a nested coroutine receive the token? Pass it explicitly? More API clutter. And what about pending I/O operations—can they be cancelled at the OS level, or do you wait for them to complete naturally?

*How is its frame allocated?* Coroutine frames live on the heap by default. For high-throughput servers handling thousands of concurrent operations, allocation overhead matters. You want to reuse frames. You want custom allocators. But here's the catch: the frame is allocated before the coroutine body runs. The allocator can't be a parameter—parameters live in the frame.
*How is its frame allocated?* Coroutine frames live on the heap by default. For high-throughput servers handling thousands of concurrent operations, allocation overhead matters. You want to reuse frames. You want custom allocators. But here's the catch: the frame is allocated before the coroutine body runs. The allocator must be known before the frame exists. Either every coroutine in the chain takes it as a parameter, or it arrives some other way.

Asio has answers to these questions, but they're constrained. Asio must support callbacks and futures alongside coroutines. It cannot build an execution model optimized for coroutines alone. And it bundles everything together—execution model, networking, timers, platform abstractions—in one monolithic library.

The standard has `std::execution` (P2300), the sender/receiver model. It's powerful and general. It's also complex, academic, and not designed for coroutines first. It has the "late binding problem"—allocators flow backward, determined at the point of connection rather than at the point of creation. Ergonomic allocator control is difficult. P3552R3 proposes a `task` type, but it's built on sender/receiver and inherits its limitations.
The {cpp}26 working draft has `std::execution` (P2300), the sender/receiver model. It's powerful and general. It's also complex, academic, and not designed for coroutines first. Its context flows backward, from receiver to sender, so it arrives after a coroutine frame is already allocated. P3552R3 adds a `task` type to it, but it's built on sender/receiver and inherits its limitations. xref:9.design/9q.WhyNotStdExecution.adoc[Why Not std::execution?] compares the two models in full.

Capy builds an execution model purpose-built for coroutines and I/O.

The *ContextAwaitable protocol* solves context propagation. When you `co_await`, the caller passes its executor and stop token to the child through an extended `await_suspend` signature. No explicit parameters. No promise coupling. Context flows forward, naturally.

*Stop tokens propagate automatically.* Cancel at the top of your coroutine tree, and every nested operation receives the signal. Capy integrates with OS-level cancellation—`CancelIoEx` on Windows, `IORING_OP_ASYNC_CANCEL` on Linux. Pending I/O operations cancel immediately.

*Frame allocation uses forward flow.* The two-call syntax of cpp:run_async[]`(executor)(my_task())` sets a thread-local allocator before the task is evaluated. The task's `operator new` reads it. No late binding. No backward flow. Ergonomic control over where every frame is allocated.
*Frame allocation uses forward flow.* The two-call syntax of cpp:run_async[]`(executor)(my_task())` sets a thread-local allocator before the task is evaluated. The task's `operator new` reads it. No backward flow. Ergonomic control over where every frame is allocated.

And Capy *separates execution from platform*. The execution model—executors, cancellation, allocation—lives in Capy. Platform abstractions—sockets, `io_uring`, IOCP—live in Corosio. Clean boundaries. Testable components. You can use Capy's execution model with a different I/O backend if you choose.

Expand Down Expand Up @@ -229,7 +229,7 @@ cpp:strand[], cpp:thread_pool[] and cpp:execution_context[] carry the same names

*Asio's are not extensible, no concept taxonomy

**P3552R3 is sender/receiver based, has allocator timing issue
**P3552R3 is sender/receiver based, has allocator timing issue; see xref:9.design/9q.WhyNotStdExecution.adoc#frame-allocation[Why Not std::execution?]

***std has the token but no automatic propagation

Expand Down
134 changes: 134 additions & 0 deletions doc/modules/ROOT/pages/9.design/9q.WhyNotStdExecution.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//
// Copyright (c) 2026 Michael Vandeberg
//
// Distributed under the Boost Software License, Version 1.0. (See accompanying
// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
//
// Official repository: https://github.com/cppalliance/capy
//

= Capy and std::execution: A Comparison
:page-mode: explanation

The {cpp}26 working draft includes `std::execution` (https://wg21.link/P2300[P2300]), the sender/receiver model, and `std::execution::task` (https://wg21.link/P3552[P3552]), a coroutine type built on it. Readers who know senders ask first why Capy defines its own protocol instead.

The short answer is direction. Both models carry the same three things to an operation: where to resume, how to stop, and how to allocate. `std::execution` moves them from the consumer back to the producer, after the two are connected. Capy moves them from the caller forward to the callee: the frame allocator before the call, the executor and stop token at the `co_await`. The allocator's timing matters most, because calling a coroutine allocates its frame.

== Which Way Context Flows

In the sender/receiver model, an operation learns its context by querying the receiver's environment. The query happens after `connect()` has joined sender and receiver:

[source,cpp,role=external]
----
template <receiver R>
void state<R>::start() & noexcept {
auto sched = get_scheduler(get_env(rcvr)); // query the receiver
auto token = get_stop_token(get_env(rcvr)); // query the receiver
// ...
}
----

P2300R10 states the principle directly: contextual information "is most naturally propagated from the consumer to the producer." That suits a work graph that is built first, connected to a receiver that knows the target context, and started last.

Capy reverses the direction. When a coroutine awaits a child, the caller hands its environment to the child through a second `await_suspend` parameter:

[source,cpp]
----
include::example$snippets/4e_context_awaitable.cpp[tag=two_arg_await_suspend,indent=0]
----

The cpp:await_context[] carries the caller's executor, stop token, and frame allocator. The child's own frame already exists by then. The child installs that allocator as the thread-local allocator when its body resumes, so its own children allocate from it. The child needs no receiver type and makes no query. xref:4.coroutines/4e.context-awaitable.adoc[The ContextAwaitable Protocol] describes the mechanics.

[#frame-allocation]
== When the Frame Is Allocated

Direction matters most for allocation. A coroutine's frame is allocated before its body runs. The compiler calls `operator new`, constructs the promise, and only then begins execution. Context delivered after the call is too late for that `operator new`:

[source,cpp,role=external]
----
auto sndr = async_work(); // frame allocated here
auto op = connect(sndr, receiver); // environment available here
start(op); // too late for the frame
----

P3552 therefore does not take the frame allocator from the environment. A coroutine that wants one accepts it as an argument after `std::allocator_arg`, and the promise's `operator new` finds it there:

[source,cpp,role=external]
----
sync_wait(fun(17, std::allocator_arg, my_allocator));
----

That works, but each coroutine that should use the allocator must receive it as an argument. Every function along a call chain carries it in its signature, even when it never uses it.

Capy sets the allocator before the coroutine is called. The two-call syntax of cpp:run_async[] installs a thread-local frame allocator first and evaluates the task expression second:

[source,cpp]
----
include::example$snippets/4h_allocators.cpp[tag=run_async_pmr_alloc,indent=0]
----

The task's `operator new` reads the thread-local allocator, and child tasks created along the chain inherit it. Signatures carry no allocator parameters. xref:9.design/9l.RunApi.adoc[Run API] explains why {cpp}17 sequencing guarantees the order. xref:4.coroutines/4h.allocators.adoc[Frame Allocators] covers usage.

The cost is a dependency on thread-local storage, and the allocator is chosen where a task starts rather than at every call. A coroutine that needs a different allocator for one child starts that child with cpp:run[]`(alloc)`, which keeps the caller's executor.

== Executors and Schedulers

A P2300 scheduler has one operation, `schedule()`, which returns a sender that completes on that scheduler. A Capy cpp:Executor[] has two scheduling operations. cpp:Executor::dispatch[dispatch] returns the continuation's handle for symmetric transfer when running inline is safe, and queues it otherwise. cpp:Executor::post[post] always queues. xref:9.design/9k.Executor.adoc[Executor] gives the rationale for both.

P3552's `task` transfers execution back to its scheduler when an awaited operation completes. Capy gives the same guarantee by a different route. The xref:4.coroutines/4d.executors.adoc#the-same-executor-invariant[same-executor invariant] holds because each I/O awaitable captures the caller's executor at initiation and dispatches its completion through it.

== Stop Tokens

P3552's `task` queries the receiver for a stop token in `start()` and links a stop source of its own to it. Each task environment can name its own stop source type, so the link is made again at every task boundary.

Capy passes one `std::stop_token` in cpp:await_context[]. A child awaited with `co_await` receives its caller's token unchanged. The combinators cpp:when_all[] and cpp:when_any[] are the exception. Each links a stop source of its own to the caller's token, so it can stop the remaining children when one fails or wins. Corosio's I/O objects register a `std::stop_callback` on that token to cancel pending operations at the operating-system level. xref:4.coroutines/4f.cancellation.adoc[Stop Tokens and Cancellation] covers the rules.

== What You Give Up

* *Capy tasks are not senders.* Sender algorithms such as `let_value` do not accept a cpp:task[] directly. Use Capy's own combinators, cpp:when_all[] and cpp:when_any[], or a bridge.
* *Frame allocation depends on thread-local storage.* xref:4.coroutines/4h.allocators.adoc#_tls_preservation[TLS Preservation] describes the save-and-restore protocol this requires.
* *A different target.* P2300 is designed for general work graphs, including heterogeneous dispatch to accelerators. Capy is designed around coroutines and I/O.

== Using Both

Adopting Capy does not mean leaving senders behind. Bridges run in both directions. Both build when `BOOST_CAPY_BUILD_P2300_EXAMPLES=ON` and the {cpp} standard is 23 or later:

* xref:8.examples/8o.sender-bridge.adoc[Bridging a P2300 Sender] -- `co_await` a sender from a Capy coroutine. The bridge posts the resumption through the caller's executor, so the same-executor invariant holds across it.
* https://github.com/cppalliance/capy/tree/develop/example/awaitable-sender[`example/awaitable-sender/`] -- the reverse direction. It wraps a Capy cpp:ContextAwaitable[] as a sender for https://github.com/NVIDIA/stdexec[`stdexec`], NVIDIA's P2300 implementation, so a sender pipeline can consume it.

== Further Reading

* https://github.com/cppalliance/capy/blob/develop/papers/B1001.comparison-to-p3552-task.md[BOOST1001] -- a section-by-section comparison of P3552 `task` and IoAwaitable for asynchronous I/O, written for WG21. It predates some protocol changes, including the rename of `IoAwaitable` to cpp:ContextAwaitable[] and `io_env` to cpp:await_context[]. Where it differs from this documentation, for example on which executor a coroutine resumes on, this documentation describes current behavior.
* https://wg21.link/P2300[P2300, `std::execution`]
* https://wg21.link/P3552[P3552, _Add a Coroutine Task Type_]

== Summary

[cols="1,2,2"]
|===
| | `std::execution` with P3552 `task` | Capy

| Context direction
| Backward: the receiver's environment, queried after `connect()`
| Forward: cpp:await_context[] passed to `await_suspend`

| Frame allocator
| `std::allocator_arg` in each coroutine's arguments
| Thread-local, set by cpp:run_async[] or cpp:run[] before the call

| Scheduling primitive
| `schedule()` returns a sender
| cpp:Executor::dispatch[dispatch] and cpp:Executor::post[post] take a cpp:continuation[]

| Where a coroutine resumes
| Transferred back to the task's scheduler
| The same-executor invariant

| Stop token
| Queried from the receiver, linked per task
| One `std::stop_token` in cpp:await_context[], linked again only by combinators

| Composition
| Sender algorithms
| `co_await`, cpp:when_all[], cpp:when_any[]
|===
Loading