Coroutines
Declare with the coroutine keyword; calling one returns a coroutine handle instead of running the body. A range-based for over the handle runs the body and hands out each yielded value; handle.advance(v) is the same step written by hand, and handle.next() / handle.done() are the protocol underneath both. Nothing in the body runs until the first step. The interpreter implements coroutines by continuation replay, the VM by fibers. Behavior is identical. Coroutines turn stateful multi-frame behavior (AI, cutscenes, staged animations) into straight-line code that pauses where it yields.
Range-for drives a coroutine
A range-based for over a handle (or a handle-producing call) advances it until done, iterating the yielded values (never the final return value); a handle already advanced with next() continues from where it stands. This is the way to consume a coroutine:
coroutine int squares(int n) {
for (int i = 1; i <= n; ++i) { yield i * i; }
}
auto total = 0;
for (auto v : squares(5)) {
print("got {}", v);
total += v;
}
total;got 1
got 4
got 9
got 16
got 25
== result: 55advance: the manual loop
When the steps cannot sit in one loop (one step per frame, a handle parked in a field), h.advance(v) takes one step. Shaped like a C++ bool advance(T& v), it resumes the body once and returns true when the body yielded, with the yielded value stored into v; it returns false when the body ran to its end, or had already ended, and leaves v as it was. The answer never depends on the value, so a coroutine that yields null is driven like any other:
coroutine scan() { yield "wall"; yield null; yield "door"; }
auto h = scan();
var seen;
while (h.advance(seen)) { print("saw {}", seen); }
print("again: {}, seen: {}", h.advance(seen), seen);saw wall
saw null
saw door
again: false, seen: doorv binds as a T& parameter does: a variable, a field or an element, bound at the call before the body runs. A temporary fails the load when the checker sees it (advance() writes the yielded value through its argument; pass a variable) and otherwise raises Cannot pass non-lvalue to reference parameter without resuming; a const fails the load. The yielded value converts to v's declared type as an assignment would, and when the checker knows the coroutine's declared type and v's, a pair no assignment accepts fails the load (advance() cannot store the yielded 'string' into 'int'). The advance that finishes the body returns false; the body's return value is then h.result():
coroutine int forage(int patches) {
int total = 0;
for (int i = 1; i <= patches; ++i) {
total += i * 3;
yield total; // the running count, one step per patch
}
return total / patches; // the average per patch
}
auto h = forage(3);
int so_far = 0;
while (h.advance(so_far)) { print("so far {}", so_far); }
print("average {}", h.result());
try { forage(1).result(); } catch (e) { print("too early: {}", e); }so far 3
so far 9
so far 18
average 6
too early: Coroutine has not finished: result() is available once done() is trueresult() answers once done() is true, however the body was driven (by advance, next() or a range-for), as often as it is asked, each time a copy. A body that returns nothing gives null, and so does one that failed (its throw reached the step that ran it). Before the body finishes it raises Coroutine has not finished: result() is available once done() is true. The checker types it as the coroutine's declared type.
yield / next / done
handle.next() runs to the next yield and returns the yielded value, and the call that finishes the body returns the final return value. handle.done() reports completion:
coroutine int counter() {
yield 1;
yield 2;
return 3;
}
auto c = counter();
print("next: {}", c.next());
print("next: {}", c.next());
print("done? {}", c.done());
print("next: {}", c.next());
print("done? {}", c.done());next: 1
next: 2
done? false
next: 3
done? truedone() only asks: it never starts or advances the body. next(n) advances n yields and returns the value of the last one, the same as n calls to next() in a row; the count is an int of at least 1. A loop on done() runs a coroutine to the end and sees the final value too:
coroutine int steps() {
for (int i = 1; i <= 5; ++i) { yield i * 10; }
return -1;
}
auto h = steps();
print("skip ahead: {}", h.next(3));
while (!h.done()) { print("then: {}", h.next()); }skip ahead: 30
then: 40
then: 50
then: -1Step by step
A body with N yields takes N + 1 steps. The first N stop at the yields; the last runs the body from its last yield to its end. After the last yield done() is still false, because the body has not finished; it turns true only once that last step has run. That last step is where the forms differ: next() returns the final value (the return value, or null when the body returns nothing), advance(v) returns false, and a range-for ends; from then on result() holds the final value whichever form took the step. For counter() above, each row one step taken with either call:
| Step | The body runs | c.next() returns | c.advance(v) returns | done() afterwards | c.result() afterwards |
|---|---|---|---|---|---|
auto c = counter(); | nothing | (the call returns the handle) | false | raises | |
| 1st | to yield 1 | 1 | true, v is 1 | false | raises |
| 2nd | to yield 2 | 2 | true, v is 2 | false | raises |
| 3rd | to the end | 3 (null for a body with no return value) | false, v unchanged | true | 3 |
| 4th | nothing | raises Cannot resume a completed coroutine | false, v unchanged | true | 3 |
So the three loops below see the same yields; the next() loop, which asks done() before each step, also hands out the final value, and after any of them result() holds it:
coroutine int waves() { yield 1; yield 2; return 3; }
for (auto v : waves()) { print("for got {}", v); }
auto a = waves();
int v = 0;
while (a.advance(v)) { print("advance got {}", v); }
print("then result {}", a.result());
auto b = waves();
while (!b.done()) { print("next gave {}", b.next()); }for got 1
for got 2
advance got 1
advance got 2
then result 3
next gave 1
next gave 2
next gave 3Coroutines take parameters like any function: each argument takes its value at the call, and the first next() moves it into its parameter, so a by-value parameter is one object that lives as long as the body's frame. yield works inside loops and conditionals. Side effects in a yield expression run exactly once, however the suspension is implemented underneath.
Spellings
coroutine is a prefix, and it goes in front of every function spelling from the Functions chapter: return-type-first, trailing arrow, the function keyword, or nothing at all. Methods take the same forms, static ones included:
coroutine int a() { yield 1; } // return-type-first
coroutine b() -> int { yield 2; } // trailing arrow
coroutine function c() -> auto { yield 3; } // function keyword + arrow
coroutine function d() { yield 4; } // function keyword, inferred
coroutine e() { yield 5; } // bare: no type, no keyword
print("{} {} {} {} {}", a().next(), b().next(), c().next(), d().next(), e().next());
class Spawner {
int base = 10;
coroutine function waves(int n) { for (int i = 0; i < n; ++i) { yield base + i; } }
coroutine int w2() { yield base * 2; }
coroutine w3() -> int { yield base * 3; }
static coroutine int ticks() { yield 1; yield 2; }
}
auto s = Spawner();
for (auto w : s.waves(3)) { print("wave {}", w); }
print("{} {}", s.w2().next(), s.w3().next());
auto t = Spawner::ticks();
print("{} {}", t.next(), t.next());1 2 3 4 5
wave 10
wave 11
wave 12
20 30
1 2The type you write describes what the body yields and returns (it is never the type of the call, which is always a handle), and at run time each yield and the final return convert to it (see the last sample in this chapter). There is no coroutine lambda, and a coroutine cannot return a reference; both are parse errors. A namespace body declares coroutines like any other function (namespace ns { coroutine int g() { ... } }, called as ns::g()).
Each call is an independent instance
coroutine int letters() { yield 1; yield 2; yield 3; }
auto x = letters();
auto y = letters();
print("{} {} {} {}", x.next(), x.next(), y.next(), x.next());
y.done();1 2 1 3
== result: falseMethod coroutines
Class methods can be coroutines. The suspended body sees this. Fields read/write normally across suspensions, and every instance gets independent generators:
class Gen {
int id = 0;
Gen(int i) { id = i; }
coroutine int gen() { yield id; yield id * 2; }
}
auto a = Gen(1);
auto b = Gen(2);
auto ga = a.gen();
auto gb = b.gen();
print("{} {} {} {}", ga.next(), gb.next(), ga.next(), gb.next());1 2 2 4Range-for drives method coroutines too, and inherited method coroutines work on derived classes.
Receiver pinning
The handle pins the receiver alive. Even if the only strong reference to the instance dies with the frame that created it, resuming still sees the fields:
class D {
int v = 7;
coroutine int g() { yield v; v = 99; yield v; }
}
function make() {
auto d = D();
return d.g();
}
auto h = make();
print("{}", h.next());
print("{}", h.next());
h.done();7
99
== result: falseThe handle keeps the instance alive for as long as the coroutine is suspended.
Rules and edges
- Handles are reference-semantic: copying a handle (into a field, an array/map element, a by-value parameter, another variable) shares the ONE live coroutine; every alias sees the same
next()/advance()/done()/result()state. Even deep-copying an instance shares its handle field (a handle references a running computation; there is no meaningful deep copy). This is what lets an AI park its behavior coroutine in a field of its own game object and advance it every tick. Note the deliberate strong cycle when an instance's field holds a handle of its own method (the handle pins the receiver): break it by re-assigning/nulling the field, exactly like any shared structure.staticcoroutine methods work the same way; the handle just carries no receiver.type_ofnames a handle"coroutine_handle", as its error texts do. - Handles advance correctly across separate
execute()calls, through by-ref helper params, and from inside running functions without disturbing the caller's frame. next()takes only its optional count,advance()only the variable it stores into, anddone()andresult()take no arguments (h.advance()raisesadvance() takes 1 argument (the variable that receives the yielded value)): a value cannot be sent into a running coroutine (h.next("go")raisesnext() count must be an integer,h.next(0)raisesnext() count must be at least 1). Give it its inputs as parameters when it is created, or let it read state it shares with the caller: a field of its instance for a method coroutine, or a program-scope variable.- A failed
next()marks only that handle done. Callingnext()on a finished coroutine raises the catchableCannot resume a completed coroutine; anext(n)that reaches the end before its last step runs the steps before the end, then raises the same text. - Hot reload: a free coroutine can be redefined while one of its handles is suspended (the old handle keeps the old body), and method coroutines survive field-changing reloads of their class.
- The execution budget still applies during
next(), and budget errors skip catches inside coroutines too. - The checker stays out of the body's way: yield and return values are not checked statically, and what
next()returns is unknown, so everything downstream of it is lenient (result()has the declared type, as a lenient type: a body that falls off its end givesnull). The handle itself is typed: a member other thannext,advance,doneorresultis a load error (h.resume()reportsno member 'resume' on 'coroutine_handle' (did you mean 'next'?)).
A yield e hands out a value as a return e would: an existing object (a global, the coroutine's own local, an element) is copied, so the receiver never shares it, and the coroutine keeps its locals as they were. The declared type still does its work at run time: a yield e converts like a return of the same type, and a value the type cannot take raises at the yield, catchable around the next(). A failed next() finishes the coroutine. A bare yield; hands out nothing and is not converted:
coroutine int g() { yield 2.9; yield "three"; return 4; }
auto h = g();
print(h.next());
try { h.next(); } catch (e) { print("caught: ", e); }
print(h.done());2
caught: Cannot yield string from 'g' (declared -> int)
truesleep: behavior that waits
sleep(seconds) waits that long on the engine's time source (Time). It is a builtin of the language, like print: every engine has it, with no library to install. A float or an int is a number of seconds, and anything else raises sleep expects a number of seconds. Zero or less returns at once and never suspends a run (it is not a yield); sleep(0) is the debugger's sync point, where an attach, a breakpoint edit or a pause the debugger made while the program runs takes effect, so a loop the host drives calls it once per frame (Debugging).
What waiting does depends on how the host runs the program. A host that starts it as a run (engine::start, Suspendable runs) gets control back at every sleep: the whole run suspends with every frame and local where it is, the host goes on with its own work, and it resumes the run once the time has passed, which continues right after the sleep. The standalone runner runs every file this way. A plain execute() never suspends: there sleep blocks the thread for that long. The program is the same either way:
coroutine int countdown(int from) {
for (int n = from; n > 0; --n) { sleep(0.01); yield n; }
}
float start = time::now();
for (int n : countdown(3)) { print("{}...", n); }
print("liftoff after at least 30 ms: {}", time::now() - start >= 0.03);3...
2...
1...
liftoff after at least 30 ms: true- A sleep in a coroutine body is not a yield. The run waits as a whole, the caller of
next()included, and the coroutine is still running meanwhile: anothernext()on the same handle raisesCoroutine is already running. - A sleep under native code blocks. A run suspends only where script alone is on the way down from the host: a sleep reached through a host function's callback, a destructor, a signal receiver, an include or import's file,
hot_reload_migrate, or a builtin's callback (asortcomparator, afilterpredicate) blocks as it would in anexecute(), and the run reports one warning the first time:sleep blocks here: a run suspends only where no host callback, destructor, signal receiver, include, import or hot_reload_migrate is running. The checker warns of asleepwritten in a destructor ('sleep' in a destructor blocks: a run never suspends inside a destructor) or inhot_reload_migrate, and refuses one in aparallel_forbody or a lambda handed toparallel_transformorparallel_reduce('sleep' cannot be called in a parallel body). - Hot reload reaches a waiting run. A frame that is waiting finishes the body it started; every call made after a reload runs the new definitions, so
while (true) { step(); sleep(0.1); }picks up a reloadedstep()at its next turn. - The budget covers each stretch. Every resume is a host entry of its own with a fresh execution budget, and time asleep never counts against it.
time::execute_elapsed()counts from the start of the run, its time asleep included. - A script may define its own
sleep: a program-scope function of that name replaces the builtin, as it wouldprint.