Odin Speaks Odin ยท Chapter 15

Chapter 15: The resumable VM: yield, wait, and cooperative scripts

This chapter:

  • Turn the VM's execution state into a first-class, suspendable value: vm_resume, a three-way contract a host can call once per tick without ever blocking.
  • Add exactly one new opcode, Yield, and one new statement, yield.
  • Build wait and wait_until, Muspel's entire cooperative-timing vocabulary, as ordinary Muspel-level library code on top of that one opcode, not as new VM machinery.
  • Grow musc run a third mode, --tick, that drives a script through simulated host frames against a fake clock instead of running it straight through.

Where we left off: Chapter 14 gave the VM a readable disassembler, a real call-stack trace on runtime error, and a step debugger. Every program the book has compiled still runs exactly one way: start it, and it runs to completion. Nothing can pause partway through and be picked back up later without the host blocking while it waits.


15.1 The problem, made concrete with a trace

Imagine a script that says, in the most natural words available: move here, wait two seconds, move there. A host driving this (a game loop, calling into the script once per rendered frame, roughly every sixteen milliseconds) must never block on that wait. If it did, the whole game would freeze for two seconds waiting for one enemy's script to finish counting.

Walk through what the naive implementation of wait would do. Say wait(seconds) were an ordinary native builtin, a Muspel function that calls straight into Odin and returns a value, the same shape as print. The only way such a function can "wait" without cooperation from whatever called it is to not return until the time has elapsed, either by busy-looping inside the native call itself or by blocking on a real OS sleep. Either way, the single Odin call frame that invoked vm_step, which invoked the VM's dispatch loop, which invoked this native function, does not get control back until the wait is over. The host's own frame loop is that same call stack, one level up. A blocking wait inside a script is a blocking wait inside the host, full stop; there's no boundary between them for a native call to hide behind.

So the real requirement, stated precisely, is this: the VM must be able to stop running anywhere, including partway through a nested function call several frames deep, hand control back to the host immediately, and later continue from exactly that point, every local variable, every pending operand, every call it was nested inside, as if nothing had happened in between.

15.2 Why Chapter 13 already paid for most of this

Section 13.3's Design decision is the reason this chapter is short. vm_step (muspel/vm.odin) is one flat loop over vm.frames[len(vm.frames) - 1]'s next instruction. Call pushes a Frame onto vm.frames and lets the same loop keep running; it never recurses through a second Odin call into vm_run. The consequence, spelled out fully now: at any instant, the entire state of a running script, every local variable, every pending arithmetic operand, every call it's nested inside, is sitting in vm.stack and vm.frames, which are just plain Odin data living on the VM struct, not smeared across Odin's own native call stack the way a recursive evaluator's would be.

Suspending is therefore "stop the loop and hand back the struct." Resuming is "call the loop again on the same struct." Nothing needs saving that isn't already sitting there in the open.

Make the counterfactual explicit, because it's the whole argument. Had Chapter 13 instead built a recursive vm_call(fn, args), a real Odin procedure that itself called back into the dispatch loop for the callee, exactly the way Chapter 8's tree-walker's call_function does (treewalk.odin), suspending mid-recursion would have no clean answer in Odin. The paused state would be scattered across Odin's own stack frames: locals sitting in Odin's own stack slots, the current instruction sitting in Odin's own program counter for that frame, none of it addressable, storable, or resumable from outside. This is exactly why treewalk.odin's own Stmt_Yield case (added this chapter, see section 15.4) doesn't attempt anything clever: it reports an error and exits, because there genuinely is nothing clever to attempt on top of exec_stmt's own recursive shape.

15.3 Three ways to pause a running computation, compared honestly

Design decision: how to make a running computation pause and resume.

Alternatives considered:

  1. OS threads or green threads/fibers with real stack-switching. Run each script on its own stack (a real OS thread, or a lighter-weight fiber/coroutine primitive), and have wait actually block that stack while every other stack keeps running. This is a real, production technique: several commercial engines implement script coroutines exactly this way.
  2. A compile-time CPS/state-machine transform. Rewrite the whole function, at compile time, into an explicit resumable state machine: the same desugaring async/await compiles to in several mainstream languages. Every statement after a possible suspension point becomes its own state in an explicit switch, and the transform has to stay correct under arbitrary nesting of loops, conditionals, and calls.
  3. Keep VM state as VM-owned data in a flat dispatch loop; suspend is an early return, resume is calling the loop again. Exactly what section 15.2 already has in hand.

Trade-off: (1) works in general and is real prior art, but it costs a genuine dependency: either OS threading (no bare core: primitive on every Odin target gives this for free) or a fiber library (which doesn't exist as one either), plus synchronisation between the host and whichever stack is currently paused. It is also a dead end for a future WASM host: a js_wasm32 build has no OS threads at all without the still-immature threads proposal, so this route would strand Muspel on native targets only. (2) is powerful and general, the mechanism a real async runtime needs, but it's a substantially bigger compiler: every statement after a possible suspension point needs its own state, correctly, under arbitrary nesting, for a problem this book's whole scope is a small embedded game-scripting language, not a general concurrency runtime.

Choice: route 3. Given section 15.2's dispatch loop already exists, it costs one opcode and a status field. No OS dependency, no fiber library, and it is exactly as portable as the rest of the VM already is, js_wasm32 included, should a future chapter ever target it.

Real prior art exists on both sides of this choice, named rather than left implicit: Lua's coroutines are the closest mainstream analogue to route 3 for pure-language code, a Lua coroutine that calls coroutine.yield() suspends exactly the way this chapter's Yield does. Lua's own lua_yieldk/continuation mechanism exists specifically to handle the harder problem of yielding across a native (C) call boundary, precisely the problem route 1 or route 2 would need to solve fully and which section 15.6's rule sidesteps entirely, by rule, rather than by machinery.

15.4 The Yield opcode and the yield statement

Yield takes no operands. Its entire case in vm_step is one field write:

// vm.odin
case .Yield:
    vm.status = .Suspended

That's the whole implementation, and the reason it's this short is worth spelling out. By the time this case runs, read_u8 has already advanced frame.ip past Yield's own single opcode byte, decode-then-execute, the same as every other instruction. Resuming needs no "where do I continue from" bookkeeping at all: the next call into the dispatch loop simply starts again, and the next byte it reads is whatever comes right after Yield in the bytecode.

yield reaches the compiler through the same three passes every other statement does. The lexer gains a keyword (token.odin):

keyword_kind :: proc(lexeme: string) -> (Token_Kind, bool) {
    switch lexeme {
    // ...
    case "yield":
        return .Yield, true
    // ...
    }
}

and joins return in the set of tokens automatic-semicolon-insertion is willing to end a statement after (lexer.odin's ends_statement), so a bare yield on its own line, exactly like a bare return, needs no trailing ; typed by hand. The AST node carries nothing but its own line:

// ast.odin
Stmt_Yield :: struct {
    line: int,
}

The parser is one line added to parse_statement's dispatch, and one small proc:

// parser.odin
if match(p, .Yield) do return parse_yield_stmt(p, allocator)

@(private = "file")
parse_yield_stmt :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
    line := previous(p).line
    consume_statement_end(p, "expected ';' after 'yield'")

    node := new(Stmt_Yield, allocator)
    node^ = Stmt_Yield{line = line}
    return node
}

We terminate through consume_statement_end (Chapter 3), not a bare consume(.Semicolon, ...), so a yield gets the same one-line-block courtesy every other statement enjoys: automatic semicolon insertion supplies the terminator at a newline, and the block's own } stands in for it when the two share a line, letting on("tick", func() { yield }) read as one line.

The resolver has nothing to do (Stmt_Yield declares no name and references none) beyond an empty case so its switch stays exhaustive. And the compiler's case is the same one-line shape as every opcode that carries no operand and needs no expression compiled first:

// compiler.odin
case ^Stmt_Yield:
    chunk_write_op(c.chunk, .Yield, node.line)

musc compile on a script with a bare yield shows exactly what that one line produces:

0007 0002 YIELD

one byte, no operand, disassembled by the same generic zero-operand branch disassemble_instruction already had (section 14.1 never needed to know Yield was coming; a new zero-operand opcode simply falls into the case that already existed).

15.5 vm_resume, the host-facing entry point

Yield setting vm.status is only useful if something is actually watching that field and stopping the dispatch loop when it changes. VM gains that field, and the tri-state result a host reads back:

// vm.odin
VM_Status :: enum {
    Running,
    Suspended,
    Done,
    Error,
}

Resume_Result :: enum {
    Suspended,
    Done,
    Error,
}

.Running is deliberately the zero value: a freshly-initialised VM that hasn't been resumed yet reads exactly the same as one mid-tick, since neither is .Done or .Error, the only two states vm_resume actually checks before doing any work. Resume_Result is named here with all three cases even though only two are reachable from this book's own code today: .Error is Chapter 18's to make real (runtime_error still ends the whole process with os.exit(1), exactly as it did in Chapter 14), and giving the type its full shape now means Chapter 18 extends this enum's meaning, not its cases.

// vm.odin
vm_resume :: proc(vm: ^VM) -> Resume_Result {
    if vm.status == .Done do return .Done
    if vm.status == .Error do return .Error

    vm.status = .Running
    for vm.status == .Running {
        if !vm_step(vm) {
            vm.status = .Done
            return .Done
        }
    }
    return .Suspended
}

This is the entire host/script contract, stated once and never grown past a three-way branch: call vm_resume once per tick, and act on whichever of .Suspended, .Done, or .Error comes back. An already-.Done or already-.Error VM makes every later call a no-op, so a host doesn't need to track "have I already finished this one" itself.

vm_run, the loop every earlier chapter's musc run (and vm_run_chunk) has used since Chapter 13, is rebuilt on top of vm_resume rather than vm_step directly:

// vm.odin
vm_run :: proc(vm: ^VM) {
    for vm_resume(vm) == .Suspended {}
}

This is what keeps every earlier example still working exactly as before with no ticking host in sight: vm_run simply resumes again the instant it sees .Suspended, so a script with no yield in it at all runs to .Done in one vm_resume call, identical to Chapter 13's behaviour, and a script that does call wait busy-waits against the real clock inside this tight loop, correct even though nothing here is pacing it to any real frame rate.

15.6 Design decision: native builtins may not yield

Design decision: a native (Odin) builtin called from Muspel may never trigger Yield or otherwise suspend the VM mid-call.

Alternatives considered: let a native builtin suspend the VM itself, mid-call, the way a real async-native-function boundary in a production embedding sometimes needs to (Lua's own lua_yieldk exists for exactly this: a C function that calls back into Lua, which itself yields, needs a continuation to resume into once the yield is satisfied).

Trade-off: supporting that properly means every native call site needs a notion of "this call might not finish yet," and resuming it means restoring not just Muspel's own VM state but wherever inside the native function's own logic it had gotten to, which Odin gives no more of a hook into than it gave the tree-walker's own call stack in section 15.2. Lua solves this with its continuation mechanism; building the equivalent here would be real, ongoing machinery threaded through every future builtin the book (or Embers) ever adds.

Choice: forbid it, by rule, not by enforcement machinery. A native builtin runs to completion in one call and returns a value, full stop. If a behaviour needs to span multiple ticks, it is written in Muspel, using yield directly, never as a native function pretending to be resumable. This single rule is what lets section 15.7 build an entire timing vocabulary without touching the native-call layer (Call's Builtin case in vm_step) at all: a native builtin can never itself be "the thing that waits," which is precisely why the next section's wait is written as Muspel source, not as a second native function beside host_seconds.

15.7 Building wait and wait_until as an ordinary Muspel-level standard library

Everything above adds up to needing exactly one native builtin: a clock. host_seconds is registered in vm_init alongside print, exactly as ordinary a Builtin as that one already was:

// vm.odin
vm_init :: proc(allocator := context.allocator) -> VM {
    vm := VM{ /* ... */ clock_start = time.now() }
    vm.globals["print"] = Builtin(native_print)
    vm.globals["host_seconds"] = Builtin(native_host_seconds)
    return vm
}

@(private = "file")
native_host_seconds :: proc(vm: ^VM, args: []Value) -> Value {
    if vm.fake_clock {
        return Value(vm.fake_seconds)
    }
    return Value(time.duration_seconds(time.since(vm.clock_start)))
}

vm.fake_clock/vm.fake_seconds and the vm_set_fake_clock proc that sets them exist purely for section 15.10's tick harness; by default host_seconds reads the real wall clock, elapsed time since this VM was created.

Given that one builtin, the entire timing vocabulary a game script will ever want is just Muspel source, no new opcodes, no VM changes at all beyond Yield itself:

// muspel/stdlib.odin, Stdlib_Source
func wait(seconds) {
    var deadline = host_seconds() + seconds
    while (host_seconds() < deadline) {
        yield
    }
}

func wait_until(condition) {
    while (!condition()) {
        yield
    }
}

Both compile through the ordinary Chapter 11/10 compiler with no special-casing whatsoever: yield is a statement that compiles straight to Yield, exactly like return compiles to Return. This is the chapter's thesis made concrete: one opcode plus one native clock function is enough to build an entire cooperative-scheduling vocabulary, and the alternative this avoids is real: a bespoke new opcode for every timing primitive a script author eventually asks for (wait_frames, wait_for_animation, wait_for_signal, โ€ฆ), each one growing the VM instead of staying an ordinary function any reader of this book could have written themselves.

Stdlib_Source needs to reach a script without the reader typing it into every file by hand. A small helper parses it alongside the user's own program, into one combined statement list:

// stdlib.odin
parse_with_stdlib :: proc(src: string, allocator := context.allocator) -> (stmts: []Stmt, errors: []Parse_Error) {
    std_tokens := lex_all(Stdlib_Source, allocator)
    std_p := parser_init(std_tokens, allocator)
    std_stmts := parse_program(&std_p, allocator)

    tokens := lex_all(src, allocator)
    p := parser_init(tokens, allocator)
    user_stmts := parse_program(&p, allocator)

    if len(p.errors) > 0 {
        return nil, p.errors[:]
    }

    combined := make([dynamic]Stmt, allocator)
    append(&combined, ..std_stmts)
    append(&combined, ..user_stmts)
    return combined[:], nil
}

Two entirely separate lex/parse passes, not one source string concatenated with another: each statement's own .line field comes from whichever token stream it was actually parsed from, so a syntax error in the reader's own file still reports that file's own line number, never shifted by however many lines the standard library happens to occupy. compile_program's existing function-hoisting pass (section 12.5) does the rest: wait and wait_until are declared before anything in the combined list runs, so user code can call either one regardless of where in the file that call happens to sit.

This is deliberately wired into musc run's real-VM path only, not --walk. The tree-walker has no way to honour a Yield that lands mid-evaluation (section 15.2's whole argument), so treewalk.odin gains one new case that fails loudly rather than silently:

// treewalk.odin
case ^Stmt_Yield:
    panic("runtime error: 'yield' is not supported by the tree-walking interpreter (--walk); see Chapter 15")

and --walk keeps parsing the reader's file on its own, without Stdlib_Source, so wait resolves as an ordinary undefined-global error under --walk rather than ever reaching that case at all. Consistent with Chapter 14's own note that --walk doesn't reach every VM-side improvement: this is a real, stated scope boundary, not an oversight.

15.8 A worked trace

examples/wait_trace.mus:

print("start")
wait(0.12)
print("done")

musc run --tick (section 15.10 builds this) drives it through simulated frames, a fixed 0.05-second step per frame, printing the frame number, the fake clock's value, and vm_resume's own result after each one:

./run.sh run --tick examples/wait_trace.mus
start
frame 0: clock=0.00 status=Suspended
frame 1: clock=0.05 status=Suspended
frame 2: clock=0.10 status=Suspended
done
frame 3: clock=0.15 status=Done

Read this against wait's own source. start prints once, on the very first vm_resume call, before the VM ever reaches wait's while loop. Frames 0 through 2 each represent one full trip through wait's condition check: host_seconds() (reading vm.fake_seconds, which vm_set_fake_clock set just before that call) is still less than deadline (0.12), so the loop body runs yield, and that single Yield instruction is the only thing that executes before vm_resume returns .Suspended. By frame 3, the fake clock has advanced to 0.15, the condition is finally false, wait returns, print("done") runs, and the outermost frame's own Return unwinds everything: .Done. Three suspensions, one resumption that actually finishes the script, all reproducible byte-for-byte, because nothing about this trace depends on the real clock's own unpredictable pacing.

15.9 Scope cut: no preemptive cancellation of a suspended script

There is no mechanism here for a host to say "interrupt this wait right now, something more important happened" and have the VM unwind cleanly, the way, say, a Python generator's close() method runs any pending finally blocks on its way out. A suspended VM sitting mid-wait has no equivalent to call.

The pattern this book uses instead, and the one Chapter 21's boss-phase transitions rely on directly, is simpler and worth stating plainly: the host just discards the suspended VM instance and starts a fresh one at whatever the interrupt handler's own entry point is. Because Muspel has no destructors, no garbage collector, and no open resources a suspended script could be holding open (Chapter 21 discusses this trade-off directly, once there's a real host to make it concrete), discarding a suspended VM is always safe. It is sometimes wasteful, throwing away whatever partial progress that instance had made, but never unsafe. Appendix D notes where "true" cancellation would need to be added for a language that does need cleanup-on-abandon.

15.10 musc run --tick

The harness behind section 15.8's trace is a third mode for musc run, alongside the existing plain run and --walk:

// tools/musc/tick_cmd.odin
cmd_run_tick :: proc(src: string) {
    stmts, errors := muspel.parse_with_stdlib(src)
    // ... same parse/resolve/compile pipeline as cmd_run's VM path ...

    vm := muspel.vm_init()
    muspel.vm_push_script(&vm, &chunk)

    clock := 0.0
    frame := 0
    for {
        muspel.vm_set_fake_clock(&vm, clock)
        result := muspel.vm_resume(&vm)
        fmt.printfln("frame %d: clock=%.2f status=%v", frame, clock, result)
        if result != .Suspended do return
        frame += 1
        clock += tick_dt
    }
}

This is genuinely the whole mechanism from sections 13.4 through 13.9, wired to a loop a reader can run themselves: vm_set_fake_clock before every simulated frame, one vm_resume call, print what came back, stop the instant it's anything other than .Suspended. No Embers, no real host loop, no real clock, and the exact behaviour section 15.8 already showed.

Checkpoint

musc compile shows Yield as an ordinary zero-operand instruction for any script that uses yield directly. musc run executes wait/wait_until-using scripts correctly by busy-waiting against the real clock, with no ticking host needed. musc run --tick reproduces section 15.8's worked trace exactly, one simulated frame at a time, against a fake clock nothing here depends on real time to advance. musc run --walk reports a clear, immediate error the moment a script tries to use yield under the tree-walker, rather than silently doing the wrong thing.

Exercises

  1. Write a script with two consecutive wait calls (wait(0.1); wait(0.1);) and predict, before running it, how many --tick frames it takes to finish at the default 0.05-second step. Run it and check.
  2. vm_resume's doc comment states that .Error is unreachable from this book's own code until Chapter 18. Confirm this by tracing what actually happens today when examples/div_zero.mus is run through --tick instead of plain run: does vm_resume ever get the chance to return .Error, or does something else happen first? Read runtime_error's own doc comment (Chapter 14) again if the answer isn't obvious from vm_step alone.
  3. Section 15.6's rule forbids a native builtin from yielding. Suppose Embers (Chapter 21) wanted a native wait_frames(n) builtin instead of a Muspel one. Sketch, in prose, why writing it as a native Builtin proc that counts down and returns early on frames it hasn't finished waiting can't work correctly, and what the corresponding Muspel-level version (matching the shape of wait/wait_until) would look like instead.
  4. Section 15.9 accepts that a discarded suspended VM is always safe because Muspel has nothing worth cleaning up. Name one host-side resource (not a Muspel-level one) an entity's script instance might reasonably be associated with, that discarding the VM alone would not clean up, and where the responsibility for that cleanup belongs given muspel/ never imports embers/.

Further reading

  • Lua's coroutines, the closest mainstream analogue to this chapter's own suspend/resume mechanism for pure-language code: https://www.lua.org/manual/5.4/manual.html#2.6
  • Lua 5.2's lua_yieldk and the continuation-function mechanism, the authentic prior art for the harder native-call-boundary problem section 15.6's rule avoids by design rather than by machinery: https://www.lua.org/manual/5.4/manual.html#4.7
  • How async/await desugars into an explicit state machine in a mainstream language: any well-regarded compiler-internals writeup of the technique serves as route (2)'s real-world existence proof; Crafting Interpreters does not cover this directly, since clox is not resumable, which is exactly why this chapter has no direct equivalent to credit there.
  • Python generators' close() method, the comparison point for section 15.9's scope cut: https://docs.python.org/3/reference/expressions.html#generator.close