Second Draft: Coroutines
Richard (Rikki) Andrew Cattermole
richard at cattermole.co.nz
Fri Jan 24 22:34:46 UTC 2025
On 25/01/2025 9:49 AM, Mai Lapyst wrote:
> On Friday, 24 January 2025 at 06:16:27 UTC, Richard (Rikki) Andrew
> Cattermole wrote:
>> "If the compiler generates an error that a normal function would not
>> have, the error is guaranteed to not be a false positive when
>> considering a multithreaded context of a coroutine."
>
> With error you mean an exception? As there are compiler errors (as in
> the compiler refuses to compile something), and execptions (i.e. `throw
> X`). Just makeing sure we're on the same page. If so, then I get what
> you are meaning and should ofc be the case, as is not really different
> as non-multithreaded non-coroutine code: any exception thrown shouldn't
> be a false-positive as long as the logic guarding it is not flawed in
> any form.
I mean a compiler error. Not a runtime exception.
I listed it as a requirement just to make sure we tune any additional
errors that can be generated towards being 100% correct. Its more for me
than anyone else.
I.e. preventing TLS memory from crossing yield points.
>>> Like `tag`: there should be no situation where an outside entitiy
>>> should control the state of the coroutine, not even in as a part of a
>>> library or do I miss something?
>>
>> You may wish to complete a coroutine early.
>>
>> Nothing bad should happen if you do this.
>>
>> If it does, that is likely a compiler bug, or the user did something
>> nasty.
>
> Hmmm, thats indeed a reason for changing `tag`; you wouldn't need a
> cancelation token as the tag is this cancelation token to some extend.
> On that note, we could add a third negative value to indicate an
> coroutine was canceled from an external source or one could generally
> specify that any negative value means canceled and libraries can
> "encode" their own errorcodes into this...
I don't think that we need to.
The language only has to know about -1, -2 and >= 0.
At least currently, anything below -64k you can probably set safely.
The >= 0 ones are used for the branch table, and you really want those
values for that use case as its an optimization.
Just in case we had more tags in the language, they'll be more like -10
not -100k.
>>>> Then `yield` would be a keyword, which in turn breaks code which is
>>>> known to exist.
>>>
>>> Which is the same with `await`; I honestly like the way rust solved
>>> it: any Future (rust's equivalent to a coroutine type), has
>>> implicitly the `.await` method, so instead of writing `await X`, you
>>> have `X.await`. This dosn't break exisiting code as `.await` is still
>>> perfectly fine an method invocation. When we're here to reduce
>>> breaking code as much as possible, I strongly would go with the
>>> `.await` way instead of adding a new keyword.
>>
>> I don't expect code breakage.
>>
>> Its a new declaration so I'd be calling for this to only be available
>> in a new edition.
>
> Sadly it will; take for example my own little attempt to build a
> somewhat async framework ontop of fibers: https://github.com/Bithero-
> Agency/ninox.d-async/blob/f5e94af440d09df33f1d0f19557628735b04cf43/
> source/ninox/async/futures.d#L42-L44 it declares a function `await` for
> futures; if `await` will become a general keyword, it will have the same
> problems as if `yield` becomes one: all places where `await` was an
> identifier before become invalid.
>
>> Worse case scenario we simply won't parse it in a function that isn't
>> a coroutine.
>
> Which could be done also with `yield` tbh. I dont see why `await` is
> allowed to break code and `yield` is not. We could easily make both only
> available in coroutines / `@async` functions.
The ``await`` keyword has been used for multithreading longer than I've
been alive. To mean what it does.
Its also very uncommon and does not see usage in druntime/phobos.
As it has no meaning outside of a coroutine, it'll be easy to handle I
think.
>> I am struggling to see how the waker/poll API from Rust is not a more
>> complicated mechanism for describing a dependency for when to continue.
>
> It's easier, as it describes how an coroutine should be woken up by the
> executor, a dependency system is IMO more complicated because you need
> to differentiate between dependencies whereas Wakers serve only one
> purpose: wakeup a coroutine / Future that was pending before to be re-
> polled / executed.
If you want to do this you can.
I did spend some time last night thinking about this.
```d
sumtype PollResult(T) = :NotReady | T;
PollResult!(int[]) co(Socket socket) @async {
if (!socket.ready) {
return :NotReady;
}
@async return socket.read(1024);
}
```
The rest is all on the library side, register in the waker, against the
socket. Or have the socket reschedule as you please.
Note: the socket would typically be the one to instantiate the
coroutine, so it can do the registration with all the appropriate object
references.
Stuff like this is why I added the multiple returns support, even though
I do not believe it is needed.
Its also a good example of why the language does not define the library,
so you have the freedom to do this stuff!
> ---
>
> I've read a second time through your DIP and also took a look at your
> implementation and have some more questions:
>
>> opConstructCo
>
> You use this in the DIP to showcase how an coroutine would be created,
> but it's left unclear if this is part of the DIP or not. Which is weird
> because without it the translation
It is not part of the DIP. Without the operator overload example, it
wouldn't be understood.
> ```d
> ListenSocket ls = ListenSocket.create((Socket socket) {
> ...
> });
> ```
> to
> ```d
> ListenSocket ls = ListenSocket.create(
> InstantiableCoroutine!(__generatedName.ReturnType,
> __generatedName.Parameters)
> .opConstructCo!__generatedName);
> );
> ```
> would not be possible as the compiler would not know that opConstructCo
> should be invoked here.
Let's break it down a bit.
The compiler using just the parse tree can see the function
``opConstructCo`` on the library type ``InstantiableCoroutine``.
Allowing it to flag the type as a instantiable coroutine.
It can see that the parameter in ``ListenSocket.create`` is of type
``InstantiableCoroutine`` via a little special casing (if it hasn't been
template instantiated explicitly).
The argument to parameter matching only needs to verify that the
parameter has the flag that it is a instantiable coroutine, and the
argument is some kind of function, it does not need to instantiate any
template.
Once matched, then it'll do the conversion and instantiations as required.
I've played with this area of dmd, it should work. Although if the
parameter is templated, then we may have trouble, but I am not expecting
it for things like sockets especially with partial arguments support.
https://github.com/Project-Sidero/eventloop/blob/master/source/sidero/eventloop/coroutine/instanceable.d#L72
> Which also has another problem: how do one differentiate between
> asyncronous closures and non-asyncronous closures? Because you clearly
> intend here to use the closure passed to `ListenSocket.create` as an
> coroutine, but it lacks any indicator that it is one. Imho it should be
> written like this:
> ```d
> ListenSocket ls = ListenSocket.create((Socket socket) @async {
> ...
> });
> ```
See above, it can see that it is a coroutine by the parameter, rather
than on the argument.
Even with the explicit ``@async`` it is likely that the error message
would have to do something similar to detect that case. Otherwise people
are going to get confused.
You don't win a whole lot by requiring it. Especially when they are
templates and they look like they should "just work".
>> GenericCoroutine
>
> Whats this type anyway? I understand that `COState` is the state of the
> coroutine, aka the `__generatedName` struct which is passed in as a
> generic parameter and I think the `execute(COState)(...)` function is
> ment to be called through a type erased version of it that is somehow
> generated from each COState encountered. But what is `GenericCoroutine`
> itself? Is it your "Task" object that holds not only the state but also
> the type erased version of the execute function for the executor?
I didn't define ``GenericCoroutine`` in the DIP, as it wasn't needed.
Indeed, this is my task abstraction with the type erased executor for
execution.
Think of the hierarchy as this, it is what I have implemented (more or
less), and you could do it differently if it doesn't suit you:
```d
struct GenericCoroutine {
bool isComplete();
CoroutineCondition condition();
void unsafeResume();
void blockUntilCompleteOrHaveValue();
}
struct Future(ReturnType) : GenericCoroutine {
ReturnType result();
}
struct InstantiableCoroutine(ReturnType, Parameters...) {
Future!ReturnType makeInstance(Parameters);
InstantiableCoroutine!(ReturnType, ughhhhh) partial(Args...)(Args); //
removes N from start of Parameters
static InstantiableCoroutine opConstrucCo(CoroutineDescriptor :
__descriptorco)();
}
```
https://github.com/Project-Sidero/eventloop/tree/master/source/sidero/eventloop/coroutine
Consider why ``GenericCoroutine`` exists, internals, the scheduler ext.
cannot deal with a typed coroutine object, it must have an untyped one.
Here is how I do it:
https://github.com/Project-Sidero/eventloop/blob/master/source/sidero/eventloop/coroutine/builder.d#L47
>> Function calls
>
> I also find no information in the DIP on how function calls itself are
> transformed. What the transformation of a function looks like is clear,
> but what about calling them in a non-async function?
Currently they cannot be.
It was heavily discussed, and I did support it originally.
It was decided that the amount of code that will actually use this is
minimal enough, and there are problems/confusion possible that it wasn't
worth it for the time being.
See the ``Prime Sieve`` example for one way you can do this.
I can confirm that it does work in practice :)
https://github.com/Project-Sidero/eventloop/blob/master/examples/networking/source/app.d#L398
> I would argue that
> this should be possible and have an type that reflects that they're a
> coroutine as well as the returntype, similar to rust's `Future<T>`. This
> would also proof that coroutines are zero-overhead, which I would really
> like them to be in D.
>
>> ```d
>> struct AnotherCo {
>> int result() @safe @waitrequired {
>> return 2;
>> }
>> }
>>
>> int myCo() @async {
>> AnotherCo co = ...;
>> // await co;
>> int v = co.result;
>> return 0;
>> }
>> ```
>
> How is `AnotherCo` here a coroutine that can be `await`ed on? With my
> current understanding of your proposal, only functions and methods are
> transformed, which means that `AnotherCo.result` would be the coroutine,
> not it's whole parent struct.
Nothing in ``AnotherCo`` would be transformed.
The ``await`` statement does two things.
1. It assigns the expression's value into the state variable for waiting on.
2. It yields.
It doesn't know, nor care what the type of the expression resolves to.
The expression has no reason to be transformed in any way.
Also there struct/classes are inherently defined as supporting methods
that are ``@async``, what happens it the this pointer for that type,
goes after the state struct pointer post transformation and you have to
explicitly pass it in (via partial perhaps?).
More information about the dip.development
mailing list