Coroutines

A function that waits

A coroutine is a function that can stop in the middle and be continued later. yield stops it; resume continues it from where it stopped.

tape
co fn countdown(start_at: i64) {
    var n: i64 = start_at;
    while (n > 0) {
        io.println("{n}...");
        n = n - 1;
        yield;
    }
    io.println("liftoff");
}

fn main() -> i32 {
    var c: countdown = countdown.start(3);
    while (c.resume()) {
        io.println("(a frame passes)");
    }
    return 0;
}
plaintext
3...
(a frame passes)
2...
(a frame passes)
1...
(a frame passes)
liftoff

co fn countdown declares the coroutine and a type with the same name: its frame. countdown.start(3) makes a frame holding the arguments; nothing runs yet. Each resume() runs the body up to the next yield and returns true, or runs it to the end and returns false.

There is no scheduler and nothing is allocated. The frame is a plain value: you keep it where you like (a local, a struct field, a component var), and you decide when to resume it.

The frame

F.start(args)A frame with the arguments in it, ready to run. Nothing runs yet
f.resume() -> boolRun to the next yield (true) or to the end (false). Traps when f is not running
f.started() -> boolstart made it
f.done() -> boolThe body has ended
f.running() -> boolStarted and not done
f.reset()Stop it (its defers run) and make it not started

A frame var with no initializer is not started: zeroed storage is a frame in a known state, like a ?T that is null.

tape
co fn blink() { yield; }

fn main() -> i32 {
    var b: blink;
    io.println("{b.started()} {b.running()} {b.done()}");    // false false false
    b = blink.start();
    io.println("{b.started()} {b.running()} {b.done()}");    // true true false
    while (b.resume()) { }
    io.println("{b.started()} {b.running()} {b.done()}");    // true false true
    return 0;
}

Locals live on

A coroutine’s locals keep their values across yield: loop counters, strings, structs, arrays. The arguments are the frame’s own, so a string passed to start can go out of scope at the caller.

tape
co fn greet(names: []const string) {
    for (i in 0..names.len) {
        let line: string = "hello, {names[i]}";
        yield;
        io.println(line);
    }
}

Values go in and out through fields and pointers, not through yield: a coroutine and the code resuming it share state. (yield x is reserved for iterators.)

In a component

A component’s coroutine reaches the component’s vars and props, as its methods do. It is started from inside the component, with its own arguments only.

tape
component Guard {
    var route: [4]i64 = [10, 20, 30, 40];
    var pos: i64 = 0;
    var last_dt: f32 = 0.0;
    var walk: walk_route;           // not started

    co fn walk_route() {
        for (i in 0..4) {
            pos = route[i];
            var t: f32 = 1.0;       // two ticks of 0.5 at each stop
            while (t > 0.0) { t = t - last_dt; yield; }
        }
    }

    fn spawn() { walk = walk_route.start(); }
    fn tick(dt: f32) {
        last_dt = dt;
        if (walk.running()) { walk.resume(); }
    }
}

yield from

yield from F.start(args); runs another coroutine to its end, suspending each time it does. The inner frame is part of the outer one.

tape
co fn step(name: string) {
    io.println("{name}: begin");
    yield;
    io.println("{name}: end");
}

co fn both() {
    yield from step.start("left");
    yield from step.start("right");
}

Stopping early

A frame stopped before its end, by reset(), by being dropped (going out of scope, being assigned a new frame), runs the defers it owes and frees what its locals own:

tape
co fn worker() {
    defer io.println("cleaned up");
    yield;
    yield;
}

fn main() -> i32 {
    var w: worker = worker.start();
    w.resume();           // suspended at the first yield
    w.reset();            // prints "cleaned up"
    return 0;
}

Saving and loading

A frame is data, so it can be saved, with a game’s save file say, and loaded later to carry on where it was. The easiest way is a struct field that json.gen_codec handles:

tape
import json from "json";

co fn patrol(laps: i64) {
    for (i in 0..laps) {
        walk_lap(i);
        yield @label(lap);
    }
}

struct Save {
    level: i64;
    guard: patrol;
}

@tape { json.gen_codec(Save); }

var s: Save = Save { level: 2, guard: patrol.start(5) };
s.guard.resume();
let text: string = json.stringify(&to_json_Save(&s));
// {"level":2,"guard":{"layout":"...","point":"lap","point_layout":"...","args":{"laps":5},"locals":{"$0":5,"i":0}}}

let doc: json.Value = json.parse(text) or { return 1; };
var back: Save = from_json_Save(&doc) or { return 1; };
back.guard.resume();      // on to the next lap, as s.guard would

What is saved is the point the coroutine is at, the arguments it was started with, and the locals it still needs there, by name. Values the compiler keeps for itself, such as a for loop’s end, are saved as $0, $1, …; you don’t write anything for them, and locals need no type annotations.

After the code changes. A save carries a hash of the coroutine’s layout: its resume points and what each saves. Loading it into a program whose coroutine has the same layout resumes exactly where it was. When the coroutine has changed, a frame saved at a yield @label(name) still carries on at that label, as long as the new code saves the same values there. Any other frame is restarted: from_json leaves it not started, and the code that drives it starts it again. So give the points a player can save at a label.

Pointers. A local that points into the frame, or into the component the coroutine belongs to (a for over a component’s array), is saved as an offset and works after loading. A pointer to anything else can’t be saved: keep an index instead of a pointer across a yield in a coroutine you want to save. Saving such a frame through json.gen_codec traps; the builtins below report it.

Any format. Frames are saved through serial’s format-neutral Writer and Reader (see serial), with two builtins. A component’s frames are saved and loaded in the component’s own code, which gives a loaded frame its instance:

tape
import serial from "serial";

var fw: json.FrameWriter = json.frame_writer(&doc);
var w: serial.Writer = fw.writer();
@frame_save(&walk, &w) or { io.println("can't save: {err}"); };

var fr: json.FrameReader = json.frame_reader(&doc);
var r: serial.Reader = fr.reader();
@frame_load(&walk, &r) or { io.println("can't load: {err}"); };

let h: u64 = @frame_layout(walk_route);   // the layout hash

err is a serial.FrameError: ForeignPointer when saving, LayoutChanged or Malformed when loading. A failed load leaves the frame not started.

An optional local is saved as a record: some (true or false) and, when it is true, value. Locals that are tagged unions or slices can’t be saved yet: @frame_save on such a frame is a compile error naming the local.

The rules

  • yield is only in a co fn’s own body. A function it calls runs to completion.
  • No pointer into a frame across a yield. A pointer to a local, or a slice of a local array, used after a yield it was made before is a compile error. A frame is a value that moves; a pointer into it would not move with it. (A slice of a string’s bytes is fine: they live on the heap.)
  • A suspended frame is not copied. It owes its defers, and a copy would run them twice: copying a running frame traps. Assign a frame a start(...) result, or a frame that has finished or not started.
  • No errdefer in a coroutine, and no yield inside a defer or a @constant_time function.
  • A coroutine returns nothing. It is a function or a component method, not a method of a type.
  • Every profile, t0 included: a frame needs no heap and no runtime.

Under the hood

The compiler turns a coroutine’s body into a state machine over its frame: the resume point, and the values that live across each yield. It is lowered once, so the VM and every native target run the same code. @sizeof(F) is the frame’s size.

Last modified: