Coroutines and Async
How to implement coroutines, generators, and async/await using frontend state machine lowering.
SBBE has no built-in coroutine or async support. Coroutines are implemented by the frontend through state machine lowering: the frontend transforms each coroutine into a regular function that takes a state struct and a switch on the current state to resume at the right point.
The pattern
Every coroutine becomes:
- A state struct in linear memory holding the current state index and any local variables that live across yield points
- A resume function that dispatches on the state index using
jmp.ifchains (orjmptonce branch tables are supported)
Example: a counter generator
Pseudo-code
func countUp(start: Int32) -> Generator<Int32> {
var i = start
while true {
yield i
i += 1
}
}
State struct layout
// CounterState:
// offset 0: i32 state_index (0 = initial, 1 = after yield)
// offset 4: i32 i (the counter value)
// offset 8: i32 yielded_value (output slot)
SBBE lowering
// Initialize the coroutine state
func $count_up_init(i32) -> ptr {
var $state ptr
entry:
ldi 12
salloc
str $state
// state_index = 0
ld $state
ldi 0
stm i32
// i = start parameter
ld $state
ldi 4
add.s i64
ldl 0
stm i32
ld $state
ret
}
// Resume the coroutine. Returns 1 if a value was yielded, 0 if finished.
func $count_up_resume(ptr) -> i32 {
entry:
// Load state_index
ldl 0
ldm i32
// Dispatch on state
eqz i32
jmp.if state_0
jmp state_1
state_0:
// First entry: yield the initial value of i
// Load i
ldl 0
ldi 4
add.s i64
ldm i32
// Write to yielded_value slot
ldl 0
ldi 8
add.s i64
swap
stm i32
// Set state_index = 1 for next resume
ldl 0
ldi 1
stm i32
ldi 1 // yielded = true
ret
state_1:
// Resumed after yield: increment i, yield again
// Load i
ldl 0
ldi 4
add.s i64
dup // keep the addr for storing back
ldm i32
ldi 1
add.s i32 // i + 1
dup // keep value for yielded_value
// Store i back
stm i32
// Write to yielded_value slot
ldl 0
ldi 8
add.s i64
swap
stm i32
// state_index stays at 1 (loop forever)
ldi 1 // yielded = true
ret
}
// Read the last yielded value
func $count_up_value(ptr) -> i32 {
entry:
ldl 0
ldi 8
add.s i64
ldm i32
ret
}
Using the generator
func $main() -> i32 {
var $gen ptr
var $sum i32
entry:
// Create generator starting at 1
ldi 1
call $count_up_init
str $gen
ldi 0
str $sum
// Yield 3 values and sum them: 1 + 2 + 3 = 6
jmp iter
iter:
ld $gen
call $count_up_resume
drop // we know it always yields for this example
ld $gen
call $count_up_value
ld $sum
add.s i32
str $sum
// Check if we've summed 3 values (sum >= 6)
ld $sum
ldi 6
ge.s i32
jmp.if done
jmp iter
done:
ld $sum
ret
}
Async/await
Async functions follow the same state machine pattern. The main difference is that instead of yielding a value, the coroutine yields a “pending” status and the runtime scheduler decides when to resume it.
The state struct for an async function includes:
- State index (which await point we’re at)
- All locals that live across await points
- A “result” slot for the final return value
The resume function is called by the async runtime whenever the awaited resource becomes ready. SBBE provides the raw mechanism (function pointers via calli, state structs in memory); the runtime scheduler is implemented in the frontend or as an extern library.
Key takeaways
- Coroutines are state machines at the IR level: a state struct + a resume function
- Local variables that survive a yield point are stored in the state struct, not as SBBE locals
- The resume function dispatches on the state index using
jmp.ifchains - The frontend performs the transformation; SBBE sees only regular functions and memory operations
- This pattern works for generators, async/await, and cooperative multitasking