Coroutines

29 views
Skip to first unread message

Gnomi

unread,
Apr 24, 2021, 6:36:25 AM4/24/21
to Discussion of the LDMud driver.
Hello everyone,

I want to present another experimental feature for upcoming LDMud releases
and invite you to test them: Coroutines.

Coroutines are special functions that can be suspended and resumed at a
later time. This is an example of a coroutine:

async void demo()
{
string msg;

msg = await(get_input("Choice: "));

await(sleep(2));

printf("Your choice: %Q\n", msg);
}

The example uses two other coroutines get_input() and sleep().

As you see coroutines are declared with the 'async' keyword. When such a
function is called it will return immediately a new coroutine object
representing the execution of the function. The function itself is not
executed at that time. So there is also a new LPC type named 'coroutine'.

A coroutine object represents the (usually suspended) state of a function
execution. Such a function can be resumed with the call_coroutine() efun.

Whenever a coroutine is suspended and resumed, data can be exchanged. The
coroutine can return data upon suspension, the caller can pass data when
resuming. So call_coroutine() will take in addition to the coroutine
object itself an optional parameter to pass into the coroutine, and it
will return a value that the coroutine yielded at the next suspension
point.

To suspend its execution a coroutine has four options:

1. yield( [value] )

This is a normal suspension to continue execution of the caller.
The value is passed to the caller. The yield() expression returns
the value that will be passed in at the next resumption. This
is an example of a counting coroutine:

async void range(int start, int stop, int step = 1)
{
for (int i = start; i < stop; i+= step)
yield(i);
}

2. yield( value, coroutine )

This is similar to the simple yield() expression, but instead of
returning to the caller it will resume another coroutine.

3. await( coroutine [, value] )

This is similar to the second yield() expression and will resume
another coroutine. But the current coroutine will only be resumed
when the other coroutine finishes (with a return statement or
end of function). If some caller tries to resume it before then,
the other coroutine will be resumed instead.

You may view this as a the equivalent of a function call for
coroutines. The new coroutine steps into place of the current
coroutine.

4. return [value]

Finishes the coroutine, destroys the coroutine object and
resumes execution of either an awaiting coroutine or a
regular caller.

You might wonder how a sleep() function as used in the first example then
might look like:

async void sleep(int sec)
{
call_out(#'call_coroutine, sec, this_coroutine());

yield();
}

This is a coroutine that calls call_out() with the task to resume
(call_coroutine() efun) itself. Then it suspends execution.

You can use this scheme to wait for any event (input_to, erq callbacks,
mudlib events).

Coroutines are implemented (together with lightweight objects) in my
experimental branch:
https://github.com/amotzkau/ldmud/tree/experimental

There is also another overview of coroutines:
https://github.com/amotzkau/ldmud/blob/experimental/doc/LPC/coroutines

Mudlibs need no adaptations to use the coroutines.

As this is an experimental feature it may be unstable and is not ready for
production use. Also implementation details may change until release.

I'm very interested in your thoughts about this feature, any further ideas,
and I'd like to hear about your own experiments with these coroutines.

Regards,
Gnomi

Invisible

unread,
Apr 24, 2021, 9:43:42 AM4/24/21
to ldmud...@googlegroups.com
Hello,

First of all: nice feature! We have several locations in our lib where
we currently use closures to achieve similar results, but this has the
potential for a cleaner implementation (albeit, it's not too bad with
closures).
The main event is of course await(), which opens up some interesting new
possibilities!


Just my 2 cents regarding syntax:


> As you see coroutines are declared with the 'async' keyword. When such a
> function is called it will return immediately a new coroutine object
> representing the execution of the function. The function itself is not
> executed at that time. So there is also a new LPC type named 'coroutine'.
>
> A coroutine object represents the (usually suspended) state of a function
> execution. Such a function can be resumed with the call_coroutine() efun.


This seems somewhat counterintuitive and also inconsistent with
functions or closures as we know them.

When I call a function I expect it to run. And we have to explicitely
create closures before I can call them. But this is somewhere in between
the two.


So IMHO it would be more logical to let the coroutine actually run (to
the first suspension point) when called. This would also mean that there
is no explicit reference to the couroutine, but it's always just
referenced by its name.

But I do of course see the bonus of being able to multiple instances of
a coroutine with different arguments, so maybe just make the creation
explicit with a "new_coroutine()" efun and don't allow direct calls to
an 'async' function at all (just like a closure cannot be just called by
name).


Another question: what happens to the value passed on the first
"call_coroutine(cr, value)", i.e. before the coroutine yielded the first
time? Is it just silently discarded? If so: would it make sense to have
another operation that just returns that value inside the coroutine?
IIRC in the Python implementation you always have to call "next(cr)" to
let the coroutine run up to the first yield, which is basically a
version of "call_coroutine()" without the optional argument.
So why not have something like "resume_arg()" that just returns the
value from the last "call_coroutine()" (i.e. the same value that is also
returned from yield() on subsequent resumes).

cya,
  Invis


P.S.: don't forget efun::coroutinep()

Stephan Weinberger

unread,
Apr 24, 2021, 10:00:24 AM4/24/21
to ldmud...@googlegroups.com

On 24.04.21 15:43, Invisible wrote:
>
> Another question: what happens to the value passed on the first
> "call_coroutine(cr, value)", i.e. before the coroutine yielded the
> first time? Is it just silently discarded?
>
>
Just found it in the docs... :-) So yes, why not have a way to also use
the first passed value inside the coroutine, before the first yield()?


Gnomi

unread,
Apr 24, 2021, 10:58:56 AM4/24/21
to ldmud...@googlegroups.com
Hi Invisible,

Invisible wrote:
> So IMHO it would be more logical to let the coroutine actually run (to the
> first suspension point) when called.

I have thought about it. The advantage is that with immediately starting
coroutines the caller doesn't need to know whether a function is a coroutine
or normal function. Coroutines can be used as a drop-in without problems.
But the yield value might differ from the return type and the function
might not be completed, when execution of the caller resumes. So it's
not necessarily a bad thing to make the caller aware, that he's using
a coroutine.

The disadvantage is that the coroutine operations themselves get more ugly,
because for await or yield you'll need a coroutine object. So you'll have
to always write something like new_coroutine(#'fun, args...), because
there you'll need an immediately suspended routine most of the time. When
you forget that and write await(fun(args)) you'll get runtime errors.

So basically immediately starting coroutines are good for passing the
execution from regular functions to coroutines, but bad for passing from
coroutine to coroutine.

The alternative would be to have promise-based coroutines as in JavaScript,
but then you would loose the ability to pass values.

> This would also mean that there is no explicit reference to the couroutine,
> but it's always just referenced by its name.

The name is not enough, you may have multiple instances of the same
function. (For example two sleep() calls with different arguments.)

> So why not have something like "resume_arg()" that just returns the value
> from the last "call_coroutine()" (i.e. the same value that is also returned
> from yield() on subsequent resumes).

The main reason is that I tried to minimize the language extension. This
feature introduced three new keywords, one type and three efuns. This is a
lot. And normally this isn't needed, because there are function arguments.
Do you have any examples where that would be of use?

> P.S.: don't forget efun::coroutinep()

The efun is indeed there.

Regards,
Gnomi.

Invisible

unread,
Apr 24, 2021, 12:27:10 PM4/24/21
to ldmud...@googlegroups.com

On 24.04.21 16:58, Gnomi wrote:
> Hi Invisible,
>
> Invisible wrote:
>> So IMHO it would be more logical to let the coroutine actually run (to the
>> first suspension point) when called.
> I have thought about it. The advantage is that with immediately starting
> coroutines the caller doesn't need to know whether a function is a coroutine
> or normal function. Coroutines can be used as a drop-in without problems.
> But the yield value might differ from the return type and the function
> might not be completed, when execution of the caller resumes. So it's
> not necessarily a bad thing to make the caller aware, that he's using
> a coroutine.

Well, isn't that an even stronger argument for an explicit efun to
create coroutines and prohibiting immediate calls? As I already
mentioned this would be consistent with how closures work.

>> This would also mean that there is no explicit reference to the couroutine,
>> but it's always just referenced by its name.
> The name is not enough, you may have multiple instances of the same
> function. (For example two sleep() calls with different arguments.)

Yes, I'm aware of that, as mentioned in my previous posting ;-)


>> So why not have something like "resume_arg()" that just returns the value
>> from the last "call_coroutine()" (i.e. the same value that is also returned
>> from yield() on subsequent resumes).
> The main reason is that I tried to minimize the language extension. This
> feature introduced three new keywords, one type and three efuns. This is a
> lot. And normally this isn't needed, because there are function arguments.
> Do you have any examples where that would be of use?

The problem with function arguments is that they serve a different
purpose when the function is explicitly _not_ started on creation. Then
they in a way act more like arguments to the constructor of the
coroutine object (which happens to just initialize the local variables).

The first "real" call (as in "actually executing the function body") to
a coroutine is always somewhat special, regardless of when it happens.
Your approach basically conflates two different things - e.g. Python's
"next(cr)" vs. "cr.send(value)" - into one same efun "call_coroutine()",
thus the optional value is discarded on the first call. The
drawback/trap here is that, if a programmer wants to pass a value, he
has to be aware (in every possible execution path!) if a
"call_coroutine()" is the first or a subsequent call. It also isn't
always feasible to just blindly do a call_coroutine() right after
creation, as this might already alter the state of the coroutine (like
e.g. in your "range()" example). Thus I fear that coders will add a
separate logic to keep track of the first call - which kinda defeats the
purpose of coroutines.

Having a way to fetch the value independently from yield() would allow
to implement coroutines that behave consistently on every call,
including the first one.

Silly example (I know this would be better implemented as closure):

---------------------------------------
async void appender(string start)
{
    while (1)
    {
        start += yield(start);
    }
}

...

coroutine hello_appender = appender("Hello ");

...

call_coroutine(hello_appender, "World!");  --> "Hello" !!!
call_coroutine(hello_appender, "World!");  --> "Hello World!"
---------------------------------------

versus:
---------------------------------------
async void appender(string start)
{
    start += resume_value();
    while (1)
    {
        start += yield(start);
    }
}

...

coroutine hello_appender = new_coroutine(appender, "Hello ");

...

call_coroutine(hello_appender, "World!");  --> "Hello World!", as expected
---------------------------------------




cya,
  Invis

Gnomi

unread,
Jun 18, 2021, 5:58:11 PM6/18/21
to ldmud...@googlegroups.com
Hello Invisible,

You raised valid concerns about the initial running state and first value
passed via call_coroutine(). I thought a lot of those questions beforehand
and still do, because they don't seem harmonious in the design. But I don't
see a good solution.

Let's start at the main question:

Should coroutines start immediately when created?
-------------------------------------------------

Note that one can be implemented with the other:

coroutine start_immediately()
{
coroutine cr = async function void() { ... };
call_coroutine(cr);
return cr;
}

// or

async void start_suspended()
{
yield();

...
}

So the question is, which type would be more common, or should both types
be supported?

Immediate start:
- Drop-in replacements for normal functions
When a coroutine starts running by itself, it can be used like normal
functions for example as an action, call_out or callback.

- Consumers
Consumers need to consume values from the yield statement, so they'll
need to advance to the first yield statement at startup.

Suspended start:
- Awaitables
For coroutines to await on other coroutines, the sub-coroutine should
only start after the calling coroutine was suspended.

- Generators
Generators should only give values when asked for them. So the first
yield statement should be executed after the first call_coroutine().

For me both options seem on a par. Both variants have their purpose, so
exchanging one with the other doesn't solve the dilemma. But should both
be implemented? How? Wouldn't that further confusion?

Coming to the secondary question:

What to do with the first value?
--------------------------------
Both possibilities loose a value: When the coroutine starts immediately,
what happens to the value passed to the first yield statement? When the
coroutine starts suspended, what happens to the value passed to the first
call_coroutine() call?

Every problem in computer science can be solved by another level of
indirection. So both problems can be solved by introducing additional
efuns or keywords. But this is what I wanted to avoid. I tried to have
a minimal design with "only" three new keywords and three new efuns.
(If I could reduce that number, I would.)

For example having a fourth efun just for the first time a value is passed,
doesn't look beautiful at all.

I'm telling myself that we don't need full-fledged implementations of
coroutines like C++ or Python offer. Instead a simple and clear design is
more desirable for LPC. But bringing up exact those concerns that have
occupied my mind during the design kept me thinking. But I don't see a
solution that doesn't make it more complex.

As always, I'm open to suggestions...

Best regards,
Gnomi
Reply all
Reply to author
Forward
0 new messages