On 6/26/26 17:57, Peter Dimov via Boost wrote:
Rainer Deyke wrote:
On 6/25/26 16:58, Peter Dimov via Boost wrote:
The problem is that there needs to be a standard way to propagate important state to the foreign coroutine, so that the foreign coroutine can at least propagate that same state back to you if it co_awaits one of yours.
That state in Capy consists of the executor, the stop token, and the allocator. And while you can argue that the allocator is optional (but it isn't in practice), the other two most certainly are not.
Let's call this the "A co_awaits B, B co_awaits C" problem, where A and C use the same coroutine library. This is distinct from the "A co_awaits B, B resumes A" problem, where the awaitable B attempts to return control back to A after suspending.
This problem has solutions.
It does, but they aren't good ones.
Let's first address the executor. The programming model where coroutines resume on their original executor, as opposed to a random one, is much easier to reason about, much less error prone, and allows much simpler code.
If you know that your I/O coroutine will always resume on your singe threaded I/O executor, this allows you to skip all locking. (*) It also avoids your coroutine borrowing a thread pool thread that is intended for CPU intensive tasks; this is suboptimal. (And conversely, a CPU intensive task ending up on the single threaded I/O executor would be a disaster, although this would be much less likely to happen.)
First off, that's the "A co_awaits B, B resumes A" problem, which is explicitly *not* what your previous mail, nor my reply, was about. We were talking about the "A co_awaits B, B co_awaits C" problem. This problem also has solutions, but they are different solutions. I've already posted one earlier. Second off, my texture-loading coroutine does three things: - Read an image file. - Decode the image file. - Convert upload the image as a texture to OpenGL. None of these operation, taken individually, require a coroutine. I'll grant that the first step, the i/o layer, does use coroutines internally, but the hard work in done by SDL, wrapped in a custom awaitable. Wrapping the inner awaitable in a coroutine was a choice I made. I could have chosen to wrap the outer functionality in a custom awaitable directly. A coroutine was used, but not required. The second step is a CPU-bound synchronous function that needs to run in a thread pool. The third step is a GPU-upload-speed-bound synchronous function that needs to run in the main thread. Right now I have one short, readable coroutine that co_awaits one awaitable, calls two functions. Between the two function is a very visible and very explicit tmc::resume_on to jump between executors. It's four lines if you don't count error handling. It's already as simple as possible, and its trivial to reason about. Breaking this up into multiple coroutines makes it neither simpler nor easier to reason about. It just adds additional noise. But that's not what you're talking about here. You're talking about the *implicit*, *accidental* switching of executors through foreign awaitables. And I agree that this *can* be a real issue, depending on the coroutine in question. (Some coroutines just don't care.) But it's one that should be solvable in a generic sense.
Indeed. The first solution is for the coroutine author to manually switch back to the original executor after each co_await. Needless to say, this quickly becomes verbose, repetitive, boring and ultimately unworkable.
The coroutine author wouldn't necessarily have to switch between *every* co_await, which actually makes this quite performant (if brittle and tedious). Do something that needs to be done in the main thread? Switch to the main thread. Have a cpu-intensive operation? Switch to a thread pool. Just want to co_await another synchronization structure? Don't bother switching at all; we're doing any work in the current executor anyway.
The second solution is to automate this and make the promise insert the switch back automatically in await_transform. This works, but requires the allocation of a coroutine frame on each co_await, which is suboptimal and unnecessary if we know that the co_awaited coroutine will resume on the original executor.
It's certainly not ideal for performance. And I would welcome a generic solution that can solve the problem in a generic way. But given the choice of a simple automatic (but still explicit) system that comes with a high performance cost and a ton of custom code that comes with the same performance cost, I'll take the former.
The third solution is to devise a way to mark the coroutines that already resume on the original executor in some way (this is the IoAwaitable protocol), and then, if the co_awaited routine is one of those, avoid wrapping it in another frame.
The IoAwaitable concept is one attempt at solving the problem, but it's apparently only suitable for people who want to use Corosio for socket i/o. Inside or outside Capy, I want generic components. Either I can assume that every coroutine is a capy::task, in which case IoAwaitable is fine but Capy really must aspire to be the coroutine framework to end all coroutine frameworks (or at least the basis thereof), or I live in a world with many different coroutine types, in which case there should be a set of C++ concepts that the coroutine types can adhere (possibly through an adapter) to that allows generic code to operate on them generically. There is nothing intrinsic about IoAwaitable that requires io_env to be a concrete type. It just is. It's an overly specific solution to a general problem.
The verbose, explicit option to carry the auxiliary objects we need as function arguments. I actually like this option for stop tokens. It means that each co_await makes it explicit if the awaitable in question (potentially) uses the stop token or the allocator. It means being able to substitute in a different stop token or allocator at the call point. (To be fair, Capy offers just that functionality for stop tokens, through boost::capy::run.)
That's possible but, again, verbose, repetitive, and error prone. Since at this point we're at our third solution above, where we already have a protocol for passing state from A to B to C, it's natural to just put the stop token there as well, and avoid all that manual handling.
Either all coroutines require a stop token, or they don't. In the former case, make the stop token part of the coroutine concept, with a library-neutral way to access it. In the latter case, there's not really much special about the stop token. It's just another part of the coroutine payload, which can be treated as an opaque type parameter in generic code.
And now we arrive at our next juncture. We can co_await our IoAwaitables with everything being passed down splendidly, and we can also co_await foreign awaitables, which we wrap so that we resume on our original executor. This allows foreign awaitables to work, but we lose all the propagated context.
This is kind of fine when everything is written correctly; it does exactly what needs to be done. But it's error prone.
A lot of coroutine code is error-prone. Not saying this shouldn't be avoided where possible, but given the amount of common C++ assumptions that coroutines and thread pools and coroutines running on thread pools break, it's an line to draw in the sand.
We have to pass the additional state to the foreign awaitable. Maybe it does have an overload taking a stop token as a parameter, and maybe we can actually call it the right way and have everything working. But if we forget, everything still compiles and works, except that the stop token is silently dropped, and the coroutine becomes uncancelable.
No, the coroutine doesn't become uncancelable. The operation *within* the coroutine becomes uncancelable, which is exactly the same situation as when the coroutine calls a plain function instead of co_awaiting an awaitable. Why does the awaitable require more protection than the plain function? Either way, the coroutine can stop immediately after the operation completes.
Similarly, maybe the author of the foreign awaitable did actually intend to implement IoAwaitable, but got the signature subtly wrong. Again, everything still compiles and works, but we get an unnecessary coroutine frame and all the additional context is silently dropped.
Only a very specific, fairly unlikely error would case an incorrectly written IoAwaitable to be a valid awaitable. Unless the IoAwaitable was written to *also* be an awaitable, which is exactly the kind of code duplication I *don't* want. Also, I'm not (necessarily) asking for 'co_await awaitable'. I'm fine with 'co_await foreign_awaitable(awaitable)', or some other spelling that makes it explicit that I am crossing a library barrier and losing context. I just don't want to have to write the foreign_awaitable function myself, and I especially don't want to have to write a different wrapper function for every different foreign awaitable.
The pragmatic option is to simply create a new context at the point where C is called. C has a stop token, but it's distinct from A's stop token. If we don't want that, we can manually pass the stop token through B.
A new token is not what we want. The stop token supports the basic when_any primitive and we really want everything co_awaited from this when_any call downwards to use the same stop token so that when_any can do its job.
Usually. Sometimes we want to cancel a suboperation without canceling the main operation, which is in fact what when_any does. And sometimes we just don't care about cancelation at all. -- Rainer Deyke - rainerd@eldwood.com