From 8e8c1eff8511b2e1bcc69ccaf5a1df1e9d82474b Mon Sep 17 00:00:00 2001 From: Michael Vandeberg Date: Sat, 17 Oct 2026 09:24:32 -0600 Subject: [PATCH] docs: add a Why Not std::execution design page Give the std::execution comparison its own page beside the Asio, Cobalt and TooManyCooks comparisons. The page contrasts the two models' context direction, frame-allocation timing, scheduling primitives and stop-token handling, lists what Capy gives up, and links both sender bridges and the BOOST1001 comparison paper. The context-direction and frame-allocation-timing argument was repeated in the Executor, Run API and Why Not Asio pages. Each now keeps its topic-specific point and links to the new page. The sender bridge example and the ContextAwaitable tutorial link to it as well. Why Not Asio also now describes std::execution and P3552 task as in the C++26 working draft, and no longer claims the frame allocator can't be a parameter. Closes #403 --- doc/modules/ROOT/nav.adoc | 1 + .../4.coroutines/4e.context-awaitable.adoc | 2 + .../pages/8.examples/8o.sender-bridge.adoc | 1 + doc/modules/ROOT/pages/9.design/9.intro.adoc | 2 + .../ROOT/pages/9.design/9k.Executor.adoc | 16 +-- .../ROOT/pages/9.design/9l.RunApi.adoc | 10 +- .../ROOT/pages/9.design/9m.WhyNotAsio.adoc | 8 +- .../pages/9.design/9q.WhyNotStdExecution.adoc | 134 ++++++++++++++++++ 8 files changed, 147 insertions(+), 27 deletions(-) create mode 100644 doc/modules/ROOT/pages/9.design/9q.WhyNotStdExecution.adoc diff --git a/doc/modules/ROOT/nav.adoc b/doc/modules/ROOT/nav.adoc index cfde9a299..e00abaeac 100644 --- a/doc/modules/ROOT/nav.adoc +++ b/doc/modules/ROOT/nav.adoc @@ -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] diff --git a/doc/modules/ROOT/pages/4.coroutines/4e.context-awaitable.adoc b/doc/modules/ROOT/pages/4.coroutines/4e.context-awaitable.adoc index 045105bf5..76488b88d 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4e.context-awaitable.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4e.context-awaitable.adoc @@ -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. diff --git a/doc/modules/ROOT/pages/8.examples/8o.sender-bridge.adoc b/doc/modules/ROOT/pages/8.examples/8o.sender-bridge.adoc index 38525cbd6..3db602ed0 100644 --- a/doc/modules/ROOT/pages/8.examples/8o.sender-bridge.adoc +++ b/doc/modules/ROOT/pages/8.examples/8o.sender-bridge.adoc @@ -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 diff --git a/doc/modules/ROOT/pages/9.design/9.intro.adoc b/doc/modules/ROOT/pages/9.design/9.intro.adoc index dfbf87ffd..2de5fe8e6 100644 --- a/doc/modules/ROOT/pages/9.design/9.intro.adoc +++ b/doc/modules/ROOT/pages/9.design/9.intro.adoc @@ -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) @@ -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. diff --git a/doc/modules/ROOT/pages/9.design/9k.Executor.adoc b/doc/modules/ROOT/pages/9.design/9k.Executor.adoc index 7da9fb06c..3ac1eb0b5 100644 --- a/doc/modules/ROOT/pages/9.design/9k.Executor.adoc +++ b/doc/modules/ROOT/pages/9.design/9k.Executor.adoc @@ -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 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 diff --git a/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc b/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc index a3e432492..2fe729e69 100644 --- a/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc +++ b/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc @@ -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 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: diff --git a/doc/modules/ROOT/pages/9.design/9m.WhyNotAsio.adoc b/doc/modules/ROOT/pages/9.design/9m.WhyNotAsio.adoc index 5d57ef8cb..69e3a5b9c 100644 --- a/doc/modules/ROOT/pages/9.design/9m.WhyNotAsio.adoc +++ b/doc/modules/ROOT/pages/9.design/9m.WhyNotAsio.adoc @@ -126,11 +126,11 @@ 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. @@ -138,7 +138,7 @@ The *ContextAwaitable protocol* solves context propagation. When you `co_await`, *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. @@ -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 diff --git a/doc/modules/ROOT/pages/9.design/9q.WhyNotStdExecution.adoc b/doc/modules/ROOT/pages/9.design/9q.WhyNotStdExecution.adoc new file mode 100644 index 000000000..628c6d958 --- /dev/null +++ b/doc/modules/ROOT/pages/9.design/9q.WhyNotStdExecution.adoc @@ -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 +void state::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[] +|===