Tzopilotl — Coroutines
Comprehensive guide to the coroutine system: language usage, runtime architecture, and implementation internals.
1. Overview
Tzopilotl provides coroutines — cooperative generators that yield values one at a time and can be suspended and resumed on demand. A coroutine is declared with coro fn, yields values with yield, and is consumed via the next builtin which returns Option<T>.
-- Declare a coroutine that yields integers
coro fn count(n Int) Int {
var i = 0;
while (i < n) {
yield i;
i = i + 1;
}
}
-- Create and consume
let c = 3 count;
c next println; -- Option<Int>.some(0)
c next println; -- Option<Int>.some(1)
c next println; -- Option<Int>.some(2)
c next println; -- Option<Int>.none
Key properties of Tzopilotl coroutines:
- Statically typed. The yield type is known at compile time.
coro fn foo() IntyieldsIntvalues and returnsCoroutine<Int>. - Single-level yield.
yieldis lexically restricted tocoro fnbodies. A non-coroutine function called from within a coroutine cannot yield on the coroutine's behalf. - Save-slot frames. Each coroutine has a single heap-allocated
CoroutineFramethat serves as a save slot. During execution, the coroutine body and all functions it calls use the shared flat register file. At yield time, the coroutine body's registers are copied into the save slot; at resume time, they are copied back. This allows multiple coroutines to be suspended simultaneously without interfering with each other, while avoiding per-call heap allocation overhead. - GC-safe. Per-yield-point GC maps track which registers hold object pointers in the save slot, enabling the incremental garbage collector to correctly scan suspended coroutines.
- Real-time safe. No system allocator calls, no blocking operations. Save-slot allocation uses the TLSF real-time allocator.
2. Language Guide
2.1 Declaring Coroutines
A coroutine is declared by prefixing a function declaration with coro. The return type annotation specifies the yield type — the type of values that the coroutine produces.
coro fn naturals() Int {
var i = 0;
while (true) {
yield i;
i = i + 1;
}
}
The yield statement suspends the coroutine, returns the given value to the caller, and saves all local state. When the coroutine is next resumed, execution continues from the statement immediately after the yield.
Coroutines may take parameters just like regular functions:
coro fn range(start Int, end Int) Int {
var i = start;
while (i < end) {
yield i;
i = i + 1;
}
}
2.2 Creating & Resuming
Calling a coro fn does not execute its body. Instead it creates a Coroutine<T> object in the Created state, capturing the arguments for later use:
let c = 5 count; -- creates Coroutine<Int>, body not yet entered
To advance the coroutine, call next. Each call returns Option<T>:
Option<T>.some(value)— the coroutine yielded a value and is now suspended.Option<T>.none— the coroutine body has finished; no more values.
let c = 3 count;
c next println; -- Option<Int>.some(0)
c next println; -- Option<Int>.some(1)
c next println; -- Option<Int>.some(2)
c next println; -- Option<Int>.none
c next println; -- Option<Int>.none (remains none forever)
Once a coroutine is Done, all subsequent calls to next return none.
2.3 Draining a Coroutine
A common pattern is to drain a coroutine until it is done. Use isSome and unwrap from the standard Option API:
fn drain(c Coroutine<Int>) Void {
var result = c next;
while (result isSome) {
result println;
result = c next;
}
}
let c = 4 count;
c drain;
Output:
Option<Int>.some(0)
Option<Int>.some(1)
Option<Int>.some(2)
Option<Int>.some(3)
2.4 Yield Types
Coroutines can yield any type — value types (Int, Float, Bool) and object types (String, Array, Tuple) alike:
-- Yielding strings
coro fn greetings() String {
yield "hello";
yield "world";
yield "goodbye";
}
-- Yielding arrays
coro fn make_arrays() [Int] {
yield [1, 2, 3];
yield [4, 5];
}
-- Yielding tuples
coro fn pairs(n Int) (Int, Int) {
var i = 0;
while (i < n) {
yield (i, i * i);
i = i + 1;
}
}
2.5 Yield & Control Flow
yield can appear inside any control flow structure: if/else, match, while loops, and sequences thereof. The coroutine correctly remembers where it was suspended and resumes from that exact point.
Yield in if/else
coro fn conditional_yield(flag Bool) Int {
if (flag) {
yield 1;
yield 2;
} else {
yield 100;
yield 200;
}
yield 999;
}
Yield in match
enum Shape {
circle Float,
rect Float
}
coro fn describe_shapes() String {
let shapes = [Shape.circle(1.0), Shape.rect(4.0)];
var i = 0;
while (i < shapes length) {
match (shapes[i]) {
Shape.circle(r): yield "circle";
Shape.rect(w): yield "rect";
}
i = i + 1;
}
}
Sequential loops
coro fn two_loops() Int {
var i = 0;
while (i < 3) {
yield i;
i = i + 1;
}
i = 10;
while (i < 13) {
yield i;
i = i + 1;
}
}
-- Yields: 0, 1, 2, 10, 11, 12, then none
2.6 Nesting Coroutines
A coroutine can call next on another coroutine, forwarding its values with yield. This allows coroutine composition:
coro fn inner() Int {
yield 1;
yield 2;
}
coro fn outer() Int {
let ic = inner();
var r = ic next;
while (r isSome) {
yield r unwrap;
r = ic next;
}
yield 99;
}
let o = outer();
o next println; -- Option<Int>.some(1)
o next println; -- Option<Int>.some(2)
o next println; -- Option<Int>.some(99)
o next println; -- Option<Int>.none
Coroutines can also call ordinary (non-coroutine) functions. Those function calls use the normal flat register file — no heap-allocated frames are needed because normal functions always complete before the coroutine can yield:
fn double(x Int) Int = x * 2;
coro fn doubled_range(n Int) Int {
var i = 0;
while (i < n) {
yield i double; -- calls double(), yields result
i = i + 1;
}
}
2.7 Passing Coroutines as Values
Coroutine<T> is a first-class type. Coroutines can be stored in variables, passed to functions, and selected conditionally:
-- Coroutine as function parameter
fn drain(c Coroutine<Int>) Void {
var result = c next;
while (result isSome) {
result println;
result = c next;
}
}
-- Conditional selection (both must yield same type)
coro fn evens() Int { var i = 0; while (i < 6) { yield i; i = i + 2; } }
coro fn odds() Int { var i = 1; while (i < 6) { yield i; i = i + 2; } }
let selected = true ? evens() : odds();
selected drain;
Multiple coroutines can be interleaved — each maintains its own independent state:
let a = 3 count;
let b = 3 count;
a next println; -- Option<Int>.some(0)
b next println; -- Option<Int>.some(0)
a next println; -- Option<Int>.some(1)
b next println; -- Option<Int>.some(1)
2.8 Infinite Generators
Coroutines with while (true) produce infinite sequences. Consume only as many values as needed:
coro fn fibs() Int {
var a = 0;
var b = 1;
while (true) {
yield a;
let tmp = a + b;
a = b;
b = tmp;
}
}
let f = fibs();
f next println; -- Option<Int>.some(0)
f next println; -- Option<Int>.some(1)
f next println; -- Option<Int>.some(1)
f next println; -- Option<Int>.some(2)
f next println; -- Option<Int>.some(3)
f next println; -- Option<Int>.some(5)
f next println; -- Option<Int>.some(8)
f next println; -- Option<Int>.some(13)
An empty coroutine (no yield statements) immediately returns none:
coro fn empty() Int {}
let e = empty();
e next println; -- Option<Int>.none
2.9 Delegation with yieldAll
The yieldAll builtin drains an inner coroutine, yielding each of its values on behalf of the outer coroutine. This replaces the manual while/next/unwrap loop shown in section 2.6:
coro fn inner() Int {
yield 10;
yield 20;
yield 30;
}
coro fn outer() Int {
yield 1;
yieldAll(inner());
yield 99;
}
let o = outer();
o next println; -- Option<Int>.some(1)
o next println; -- Option<Int>.some(10)
o next println; -- Option<Int>.some(20)
o next println; -- Option<Int>.some(30)
o next println; -- Option<Int>.some(99)
o next println; -- Option<Int>.none
yieldAll also works in pipeline form:
coro fn outer_pipe() Int {
yield 1;
inner() yieldAll;
yield 99;
}
The inner coroutine must yield the same type as the outer coroutine. yieldAll can only be used inside a coro fn body, just like yield.
Multiple yieldAll calls can be combined with regular yield statements to compose sequences from several sources:
coro fn combined() Int {
var i = 0;
while (i < 2) {
yield i;
i = i + 1;
}
inner() yieldAll;
yield 999;
}
-- Yields: 0, 1, 10, 20, 30, 999, then none
2.10 For Loops over Coroutines
Coroutines are iterable — a for loop can iterate directly over a Coroutine<T>, extracting each yielded value until the coroutine is exhausted:
coro fn count_to(n Int) Int {
var i = 1;
while (i <= n) {
yield i;
i = i + 1;
}
}
for (x : count_to(5)) {
x println;
}
-- Prints: 1, 2, 3, 4, 5
The loop variable x has the coroutine's yield type (Int in this case) — it receives the unwrapped value directly, not an Option. The loop terminates automatically when the coroutine returns none.
This replaces the verbose manual drain pattern:
-- Before: manual drain
var result = c next;
while (result isSome) {
let x = result unwrap;
x println;
result = c next;
}
-- After: for loop
for (x : c) {
x println;
}
Break and continue
break and continue work as expected. This is especially useful with infinite coroutines:
for (x : fibs()) {
if (x > 10) { break; }
x println;
}
-- Prints: 0, 1, 1, 2, 3, 5, 8
Inline coroutine creation
Coroutines can be created inline in the for expression:
for (s : greetings()) {
s println;
}
-- Prints: hello, world, goodbye
Any yield type
for loops over coroutines work with any yield type — strings, arrays, tuples, and other object types:
coro fn colors() String {
yield "red";
yield "green";
yield "blue";
}
for (c : colors()) {
c println;
}
-- Prints: red, green, blue
for loops. The loop variable type is inferred from the coroutine's yield type, just as it is inferred from the element type of an Array or List.
2.11 Converting to Lists
The toList function converts a Coroutine<T> into a List<T>. The resulting list is lazy — the coroutine is resumed on demand as list elements are accessed:
coro fn count(n Int) Int {
var i = 0;
while (i < n) {
yield i;
i = i + 1;
}
}
count(5) toList println; -- List(0, 1, 2, 3, 4)
count(0) toList println; -- nil
Because the list is lazy, toList works with infinite coroutines when combined with lazy list operations like take, drop, or collect:
coro fn fibs() Int {
var a = 0;
var b = 1;
while (true) {
yield a;
let tmp = a + b;
a = b;
b = tmp;
}
}
fibs() toList take(8) println; -- List(0, 1, 1, 2, 3, 5, 8, 13)
fibs() toList collect(6) println; -- [0, 1, 1, 2, 3, 5]
Once converted, the full set of list operations is available — head, tail, map, drop, zip, and so on:
count(5) toList head println; -- 0
count(5) toList drop(3) println; -- List(3, 4)
count(3) toList map(fn(x Int) Int = x * 10) println;
-- List(0, 10, 20)
for loop when you want to process each value imperatively (with side effects). Use toList when you want to treat the coroutine's output as a data structure and apply functional list operations.
3. Design Rationale
3.1 Why Save-Slot Frames
Tzopilotl is event-driven: the call stack collapses between events, so the garbage collector never needs to scan the stack. This is fundamental to the VM's real-time guarantees. Traditional stackful coroutines would violate this invariant because they keep live stack frames around across event boundaries.
Each coroutine has a single heap-allocated CoroutineFrame that acts as a save slot. During execution, the coroutine body and all functions it calls use the shared flat register file — the same fast path as non-coroutine code. When the coroutine yields, its body registers are copied from the flat register file into the save slot. When resumed, they are copied back. The save slot is an ordinary GC-managed heap object that the incremental collector scans like any other Obj. This gives us:
- Multiple suspended coroutines. Each has its own independent save slot on the heap.
- Zero overhead for function calls. Normal functions called from within a coroutine use the flat register file with no heap allocation — identical to the non-coroutine path.
- GC compatibility. Suspended save slots are scanned incrementally via GC maps — no stack scanning required.
- No thread/fiber overhead. No separate stack allocation, no context switch machinery beyond copying registers and saving/restoring a few pointers.
3.2 Single-Level Yield
yield is restricted to the lexical body of a coro fn. A regular function called from within a coroutine cannot yield on the coroutine's behalf. This simplification:
- Avoids the complexity of "deep yield" (where any frame in the call chain could yield).
- Makes the write-barrier cost O(1) per resume (only one save slot to barrier).
- Allows function calls inside coroutines to use the fast flat-register-file path — no heap allocation, no special branching in
op_call/op_return. Since normal functions always complete before a yield, their registers don't need to be preserved across suspension.
Despite this restriction, coroutines can still call next on other coroutines. The outer coroutine's context is saved while the inner coroutine runs, and restored when the inner coroutine yields or finishes. This enables powerful composition patterns.
3.3 Real-Time Safety
All coroutine operations are real-time safe:
| Operation | Cost | Allocator |
|---|---|---|
Create coroutine (op_coro_create) | O(args) copy | TLSF |
Resume (op_coro_resume) | O(regs) copy + O(1) write barrier | TLSF (first resume only) |
Yield (op_yield) | O(regs) copy + O(1) context switch | TLSF (Option allocation) |
| Function call inside coroutine | Same as normal call | None (uses flat register file) |
| Function return inside coroutine | Same as normal return | None |
No system allocator calls. No blocking operations. All TLSF allocations are O(1) worst-case.
4. Type System
4.1 Coroutine<T>
When a function is declared with coro fn, its effective return type is Coroutine<T>, where T is the declared yield type. The type checker wraps the declared return type automatically:
-- Declared as yielding Int, actual return type is Coroutine<Int>
coro fn count(n Int) Int { ... }
CoroutineType is an object type (ObjType) parameterized by its yield type. Coroutine types are interned by the TypeUniverse: two calls to coroutineType(intType) return the same CoroutineType*.
Coroutine<T> can be used in type annotations for function parameters:
fn drain(c Coroutine<Int>) Void { ... }
4.2 Option<T> Return
The next builtin returns Option<T>, the language's standard sum type for nullable values:
Option<T>.some(value)—which_ = 0, carries the yielded value.Option<T>.none—which_ = 1, coroutine is done.
The next function is resolved as a template builtin that pattern-matches on its argument type:
-- Pseudo-signature (resolved by template resolver)
next(Coroutine<T>) → Option<T>
4.3 Type Checking Rules
| Rule | Description |
|---|---|
| Yield type match | The expression in yield expr must match the declared yield type T. Int-to-Float promotion is allowed. |
| Yield location | yield is only allowed inside a coro fn body. Using it elsewhere is a compile-time error. |
| Body return type | The body of a coro fn has return type Void. The coroutine finishes by falling off the end (triggering op_coro_done), not by returning a value. |
| Call site marking | Calls to coroutine functions are marked isCoroCall = true. Calls to next() on a coroutine are marked isCoroResume = true. These flags direct the code generator to emit the correct opcodes. |
| Conditional selection | flag ? coroA() : coroB() type-checks if both branches yield the same type, producing the same Coroutine<T> type. |
5. Runtime Architecture
5.1 State Machine
Each CoroutineObj progresses through four states:
Created ──(first next)──> Running ──(yield)──> Suspended
│ │
│ (next)
│ │
│ v
│ Running ──(yield)──> Suspended
│ │
└──(body ends)──> Done
│
(all subsequent
next → none)
| State | Description |
|---|---|
Created | Coroutine object exists but body has not been entered. Arguments are stored in the args_[] flexible array. |
Running | Coroutine body is actively executing. Set when op_coro_resume enters the coroutine. |
Suspended | Coroutine has yielded a value. Frame chain is saved in topFrame_, resume PC in resumePC_. |
Done | Coroutine body has finished. topFrame_ is null. All future next calls return none. |
5.2 CoroutineObj
CoroutineObj is the main heap object representing a coroutine instance. It uses a flexible array member to store the initial arguments inline:
class CoroutineObj : public Obj {
CodeBlock* entryBlock_; // compiled function body
State state_; // Created | Running | Suspended | Done
CoroutineFrame* topFrame_; // saved frame chain (null when Created/Done)
Code* resumePC_; // instruction to resume from
// Caller context (saved on resume, restored on yield/done)
Code* callerReturnPC_;
u16 callerResultReg_;
u32 callerBaseReg_;
u32 callerFrameCount_;
CoroutineFrame* callerCoroFrame_; // if caller is also a coroutine
CoroutineObj* callerCoroutine_; // if caller is also a coroutine
// Initial arguments
FunctionType* funcType_; // for GC scanning of Obj* args
u16 numArgs_;
Word args_[]; // flexible array
};
The caller context fields allow op_yield and op_coro_done to restore the caller's execution state without any stack unwinding.
5.3 CoroutineFrame (Save Slot)
CoroutineFrame serves as a save slot for a coroutine's body registers. Each coroutine has exactly one CoroutineFrame, allocated on first resume. During execution, the coroutine body uses the flat register file; the save slot is only populated when the coroutine yields (registers are copied in) and read when the coroutine resumes (registers are copied out).
class CoroutineFrame : public Obj {
CodeBlock* codeBlock_; // which function this frame belongs to
Code* returnPC_; // (legacy, unused in save-slot model)
CoroutineFrame* caller_; // (legacy, always nullptr in save-slot model)
u16 resultReg_; // (legacy, unused in save-slot model)
u16 numRegs_; // number of registers in this frame
u16 gcMapIndex_; // index into codeBlock_->coroGCMaps_
Word regs_[]; // flexible array of saved register values
};
Normal functions called from within a coroutine use the flat register file and flat CallFrame stack, identical to non-coroutine execution. Since yield can only appear in the coroutine body itself (not in called functions), all normal function calls have returned by the time a yield occurs, so their registers do not need to be preserved.
5.4 Context Switching
Context switching between the caller and coroutine involves saving and restoring a small set of pointers:
Resume (caller → coroutine)
- Save caller context into
CoroutineObj: return PC, result register, base register, frame count, and (if the caller is itself a coroutine) the caller's coroutine frame pointer and coroutine object. - Set
vm.currentCoroutine_andvm.currentCoroFrame_. - Compute
newBase— place the coroutine body's registers after the caller's register window in the flat register file. - First resume: copy arguments from
CoroutineObj::args_[]into the flat register file atnewBase. - Subsequent resume: copy saved registers from
CoroutineFrame::regs_[]into the flat register file atnewBase. Apply write barrier to the save slot. - Push a flat
CallFramefor the coroutine body (this updatesbaseReg_andcurrentRegs_to point into the flat register file). - Jump to entry point (first resume) or saved
resumePC_(subsequent resumes).
Yield (coroutine → caller)
- Copy the coroutine body's registers from the flat register file into the
CoroutineFramesave slot. - Save resume PC and GC map index into
CoroutineObjandCoroutineFrame. - Mark coroutine as
Suspended. - Wrap yielded value in
Option<T>.some. - Restore caller context from
CoroutineObjfields (baseReg, frameCount, currentCoroutine, currentCoroFrame). - Point
vm.currentRegs_at the flat register file at the restoredbaseReg_. - Write
Option<T>.some(value)to the caller's result register. - Clear stale caller references in the
CoroutineObj(prevents false GC retention). - Tail-call to caller's return PC.
Done (coroutine → caller)
Same as yield, but without copying registers to the save slot. Sets state to Done, clears topFrame_, and writes Option<T>.none.
5.5 Register Model
The VM's reg(i) accessor uses an indirection pointer currentRegs_:
inline Word& reg(u16 i) { return currentRegs_[i]; }
currentRegs_ always points to regs_ + baseReg_ (the flat register file). This is true for both normal execution and coroutine execution. The pushFrame and popFrame functions unconditionally update currentRegs_ when they change baseReg_.
This means all opcodes — including op_call, op_return, and all others — work identically whether inside a coroutine or not. No coroutine-aware branching is needed in the call/return path. The only coroutine-specific opcodes are op_coro_create, op_coro_resume, op_yield, and op_coro_done.
6. Garbage Collection
The incremental tri-color GC must correctly track object references inside suspended coroutines. This requires three mechanisms: GC maps, frame scanning, and write barriers.
6.1 GC Maps
Each CodeBlock stores a vector of GC maps — one per yield point in the coroutine function:
std::vector<std::vector<u16>> coroGCMaps_;
Each inner vector lists the register indices that hold Obj* values at that particular yield point. These maps are built at compile time by inspecting the local variable scopes at each yield statement.
For example, if a coroutine has two local variables — an Int in register 0 and a String in register 1 — the GC map at a yield point would be [1] (only register 1 holds an object pointer).
6.2 Frame Scanning
CoroutineFrame::gcScan() uses the GC map to mark only the registers that hold live object pointers:
void gcScan(GC* gc, i32& ioWordsToScan) {
Obj::gcScan(gc, ioWordsToScan);
if (caller_) { gc->mark(caller_); --ioWordsToScan; }
// Scan Obj* registers using GC map
if (codeBlock_ && gcMapIndex_ < codeBlock_->coroGCMaps_.size()) {
auto& map = codeBlock_->coroGCMaps_[gcMapIndex_];
for (u16 idx : map) {
if (idx < numRegs_ && regs_[idx].o) {
gc->mark(regs_[idx].o);
--ioWordsToScan;
}
}
}
}
CoroutineObj::gcScan() marks the top frame, any caller coroutine/frame references, and scans Obj* arguments using the function's type information:
void gcScan(GC* gc, i32& ioWordsToScan) {
Obj::gcScan(gc, ioWordsToScan);
if (topFrame_) { gc->mark(topFrame_); }
if (callerCoroFrame_) { gc->mark(callerCoroFrame_); }
if (callerCoroutine_) { gc->mark(callerCoroutine_); }
// Scan Obj* args using funcType_
for (u16 i = 0; i < numArgs_; ++i) {
if (funcType_->argTypes_[i]->isObjType() && args_[i].o)
gc->mark(args_[i].o);
}
}
6.3 Write Barriers on Resume
The incremental GC uses tri-color marking. Between a yield and the next resume, GC heartbeats may have scanned the suspended frames to black (fully scanned). When the coroutine resumes and writes new object pointers into those black frames, the tri-color invariant would be violated.
To prevent this, op_coro_resume applies a write barrier to the save-slot CoroutineFrame before resuming:
// Write barrier on CoroutineFrame (safety for incremental GC)
vm.gc().writeBarrier(frame);
This re-greys the save slot, ensuring it will be re-scanned by the GC and any new object references will be discovered. Since each coroutine has exactly one save slot, this is always O(1).
op_safepoint poll or via a between-event nrtTick) it will re-scan whatever object pointers the resumed body has written. Active call frames — including the coroutine's resumed frame — are scanned precisely via stack maps, so any new references created in the body are visible to the collector even before the next yield.
6.4 Root Marking
The tracing collector's root set has four substates, each with its own cursor so the scan can pause and resume across op_safepoint polls under a deadline budget:
- Globals — every slot for which
globalIsObj_[i]is set. - Dynamic-scope variables — every slot for which
dynVarIsObj_[i]is set. - Active call frames — for each frame, the codegen-emitted per-PC stack map says which registers in the frame currently hold live
Obj*references. - Extras — host-registered callbacks (e.g. the NRTVM
HandlerTablethat stores OSC and NATS handlers). Each callback walks its own table and callsmark()on every live pointer it owns.
Suspended coroutines are reachable either through a global slot that holds them or through the active-frame walk if the running mutator is mid-resume. Once the CoroutineObj is gray, gcScanByTag (tag-dispatched, non-virtual) transitively marks the topFrame_, the caller chain, and every Obj* argument and saved register, using the function's type info and the per-yield stack map.
7. Opcode Reference
7.1 op_coro_create
Creates a new CoroutineObj in the Created state.
| Word | Field | Description |
|---|---|---|
| 0 | op | Opcode handler pointer |
| 1 | regs[0..2] | dst, argBase, argc |
| 2 | i | Global index containing the CodeBlock* |
| 3 | p | Pointer to CoroutineType* |
Behavior: Allocates a CoroutineObj with space for argc arguments. Copies argument values from registers argBase..argBase+argc-1 into the coroutine's args_[] array. Stores the coroutine object in register dst. Advances PC by 4.
Emitted for: calls to coro fn functions (when isCoroCall is true).
7.2 op_coro_resume
Resumes a suspended coroutine, or returns none if it is done.
| Word | Field | Description |
|---|---|---|
| 0 | op | Opcode handler pointer |
| 1 | regs[0..1] | dst, coroReg |
Behavior:
- If
state_ == Done: createsOption<T>.none, writes todst, advances PC by 2. - Otherwise: saves caller context, activates coroutine, computes
newBase(after caller's register window), then:- If
Created(notopFrame_): allocates aCoroutineFramesave slot, copies args fromcoro->args_[]to flat register file, pushes a flat frame, jumps to entry point. - If
Suspended(hastopFrame_): applies write barrier to save slot, copies registers from save slot to flat register file, pushes a flat frame, jumps toresumePC_.
- If
Emitted for: calls to next() on a coroutine (when isCoroResume is true).
7.3 op_yield
Suspends the current coroutine and returns a value to the caller.
| Word | Field | Description |
|---|---|---|
| 0 | op | Opcode handler pointer |
| 1 | regs[0..1] | src (value register), gcMapIndex |
| 2 | p | Pointer to Option<T> type (EnumType*) |
Behavior: Copies the coroutine body's registers from the flat register file into the CoroutineFrame save slot. Saves resumePC_ = pc + 3 and gcMapIndex_ on the save slot. Sets state to Suspended. Creates Option<T>.some(value). Restores caller context (baseReg, frameCount, currentRegs). Writes the some to the caller's result register. Clears stale caller refs. Tail-calls to the caller's return PC.
Emitted for: yield expr; statements inside a coro fn body.
7.4 op_coro_done
Marks the current coroutine as finished and returns none to the caller.
| Word | Field | Description |
|---|---|---|
| 0 | op | Opcode handler pointer |
| 1 | p | Pointer to Option<T> type (EnumType*) |
Behavior: Sets topFrame_ = nullptr and state_ = Done. Creates Option<T>.none. Restores caller context. Writes none to caller's result register. Tail-calls to caller's return PC.
Emitted for: the implicit end of a coro fn body (replaces op_return_void).
7.5 Unpatched Opcodes
With the save-slot model, no existing opcodes need coroutine-specific branching. op_call, op_return, op_return_void, op_call_lambda, op_tail_call, and op_tail_call_lambda all use the flat register file unconditionally. Since pushFrame and popFrame always update currentRegs_ = regs_ + baseReg_, these opcodes work identically inside and outside of coroutines.
This is possible because yield is restricted to the coroutine body (single-level yield). Normal functions called from within a coroutine always complete before a yield can occur, so their registers do not need to survive across suspension points.
8. Compilation Pipeline
This section traces a coro fn from source text to executable opcodes.
8.1 Lexer
Two keywords are added to the TokenKind enum:
| Token | Keyword | File |
|---|---|---|
TokenKind::Coro | coro | lexer.hpp:34, lexer.cpp:170 |
TokenKind::Yield | yield | lexer.hpp:34, lexer.cpp:171 |
8.2 Parser
The parser handles two new syntactic forms:
coro fn declaration
When the parser encounters TokenKind::Coro, it consumes the token, expects fn to follow, parses the function declaration normally, and sets isCoroutine = true on the resulting FnDeclNode.
yield statement
When the parser encounters TokenKind::Yield in statement position, it calls parseYieldStmt(), which follows the same pattern as parseReturnStmt(): consumes the keyword, optionally parses an expression, and expects a terminator.
The AST node YieldStmtNode contains a single field value (the expression to yield).
8.3 Type Checker
The type checker performs several coroutine-specific tasks:
- Return type wrapping. For a
coro fnwith declared return typeT, the type checker wraps it toCoroutine<T>and stores this as the function's effective return type. - Body return type. Inside a
coro fnbody, the current return type is set toVoid(the body doesn't return values, it yields them). - Yield checking.
checkYieldStmt()verifies that:- The
yieldis inside acoro fnbody (otherwise: compile error). - The yielded expression's type matches the declared yield type.
- The
- Call site marking. When resolving a function call:
- If the target function returns
CoroutineType*, the call is markedisCoroCall = true. - If the function is
nextand the argument is aCoroutineType*, the call is markedisCoroResume = true.
- If the target function returns
- Type annotation resolution.
Coroutine<T>in type annotations (e.g., function parameters) is resolved by matching the template name"Coroutine"and delegating tocoroutineType().
8.4 Code Generator
The code generator tracks three pieces of coroutine state:
| Field | Purpose |
|---|---|
inCoroutineFn_ | Whether we're currently generating code for a coro fn body. |
currentYieldCount_ | Counter for GC map indices, incremented at each yield. |
currentCoroOptionType_ | The Option<T> type for the current coroutine's yield type. |
Coroutine function body
A coro fn body is compiled identically to a normal function body, with two differences:
- The
CodeBlock'sfuncTypefield is set (used byop_coro_createfor GC scanning of arguments). - The implicit terminator at the end of the body is
op_coro_doneinstead ofop_return_void.
Yield statement
genYieldStmt():
- Collects a GC map by scanning all local variable scopes for registers holding
Obj*types. - Appends the map to
currentBlock_->coroGCMaps_and records the index. - Generates the value expression.
- Emits
op_yield [srcReg, gcMapIndex] [optionType*].
Call site emission
In genCall(), two special cases are handled:
isCoroResume: emitsop_coro_resume [dst, coroReg]instead of a normal function call.isCoroCall: emitsop_coro_create [dst, argBase, argc] [globalIdx] [coroType*]instead ofop_call.
8.5 Builtin Resolution
The next function is registered as a template builtin with a custom resolver:
static bool resolve_next(Compiler& compiler, const std::vector<Type*>& args,
std::vector<Type*>& pt, Type*& rt, CFun& cf) {
if (args.size() != 1) return false;
auto* ct = dynamic_cast<CoroutineType*>(args[0]);
if (!ct) return false;
pt = {ct};
rt = compiler.optionType(ct->yieldType_);
cf = nullptr; // codegen handles via op_coro_resume
return true;
}
Key detail: cf = nullptr signals to the code generator that this builtin has no C function implementation — the codegen must emit op_coro_resume directly. The type checker uses the resolver to determine the return type (Option<T>) and to mark the call as isCoroResume.
9. Source File Map
Summary of all files modified or created for the coroutine implementation:
| File | Changes |
|---|---|
src/lexer.hpp | Added Coro, Yield to TokenKind enum. |
src/lexer.cpp | Added keyword recognition for "coro" and "yield". |
src/ast.hpp | Added YieldStmt to Kind enum. Added YieldStmtNode. Added isCoroutine flag on FnDeclNode. Added isCoroCall, isCoroResume flags on CallExpr_. |
src/parser.hpp | Declared parseYieldStmt(). |
src/parser.cpp | Added Coro case in parseDeclaration(). Added Yield case in parseStatement(). Implemented parseYieldStmt(). |
src/type_system.hpp | Added CoroutineType class (inherits ObjType). |
src/type_universe.hpp | Added coroutineType() factory declaration and coroutineTypeCache_. |
src/type_universe.cpp | Implemented coroutineType() with interning. |
src/value.hpp | Added coroGCMaps_ to CodeBlock. Added CoroutineFrame and CoroutineObj classes. |
src/value.cpp | Implemented CoroutineFrame::create() and CoroutineObj::create() factories. |
src/vm.hpp | Added currentCoroutine_, currentCoroFrame_, currentRegs_. Changed reg(i) to use currentRegs_. Added coroutine accessors and currentFrameNumRegs(). |
src/vm.cpp | Initialized currentRegs_. pushFrame/popFrame unconditionally update currentRegs_. Extended markRoots(). Added currentFrameNumRegs() accessor. |
src/opcodes.hpp | Declared op_coro_create, op_coro_resume, op_yield, op_coro_done. |
src/opcodes.cpp | Implemented four new opcodes (op_coro_create, op_coro_resume, op_yield, op_coro_done). Existing call/return opcodes are unmodified — they use the flat register file for all execution, including within coroutines. |
src/type_checker.hpp | Added inCoroutineBody_, currentYieldType_, checkYieldStmt(). |
src/type_checker.cpp | Coroutine return type wrapping. Yield statement checking. Call site marking. Coroutine<T> type annotation resolution. |
src/codegen.hpp | Added inCoroutineFn_, currentYieldCount_, currentCoroOptionType_, genYieldStmt(). |
src/codegen.cpp | Coroutine function codegen. Yield statement codegen. op_coro_create/op_coro_resume emission at call sites. |
src/builtins.cpp | Added resolve_next template resolver. Registered "next" builtin template. |
src/compiler.hpp | Added coroutineType() convenience accessor. |
tests/coro_test.x | Comprehensive coroutine test (16 test sections). |
tests/coro_test.expected | Expected output for the coroutine test. |