Re: Capy Review

Rainer Deyke via Boost <[email protected]> Sat, 27 Jun 2026 00:24:15 +0200
Newsgroups gmane.comp.lib.boost.devel
Message-ID <[email protected]>
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 - [email protected]

_______________________________________________
Boost mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://lists.boost.org/mailman3/lists/boost.lists.boost.org/
Archived at: https://lists.boost.org/archives/list/[email protected]/message/CREWOM4K3H5RN6XDZP43LGB3Z4XEZWKP/