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 dispatches on the current state to resume at the right point.
What we’re lowering
func countUp(start: Int32) -> Generator<Int32> {
var i = start
while true {
yield i
i += 1
}
}
let gen = countUp(1)
gen.next() // yields 1
gen.next() // yields 2
gen.next() // yields 3
The yield keyword suspends the function, returning a value to the caller, and later resumes from where it left off. The local variable i must survive across yields. The frontend must transform this into a regular function that can be called repeatedly, each time continuing from the last suspension point.
The lowering strategy
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 orjmpt(branch table) - An init function that allocates the state struct and sets the initial state
Step-by-step lowering
Step 1: Identify yield points
The compiler scans the coroutine body and numbers each yield point. countUp has one yield inside the loop, so:
- State 0: Initial entry. Execute from the top down to the first
yield. - State 1: Resume after the
yield. Incrementi, loop back, yield again.
Step 2: Identify variables that live across yields
The variable i is written before the yield and read after the yield. It must be stored in the state struct. Variables that only live within a single state (between two yield points) can remain as SBBE locals.
Step 3: Design the state struct
// 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)
Total: 12 bytes. The state_index tells the resume function where to jump. The yielded_value slot is where the resume function writes the value that was yielded, so the caller can read it after resuming.
Step 4: Transform the body into a dispatch function
The while loop becomes two states in the resume function:
- State 0: Store
iinto the yielded_value slot, set state_index to 1, return “yielded.” - State 1: Load
i, increment, store back, write to yielded_value, return “yielded.” (state_index stays at 1 since we loop.)
The dispatch at the top of the resume function loads state_index and branches to the correct state handler.
SBBE lowering
Init function
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 function
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
}
Value accessor
// 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
let gen = countUp(1)
var sum = 0
// Yield 3 values and sum them: 1 + 2 + 3 = 6
while gen.next() {
sum += gen.value
if sum >= 6 { break }
}
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
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.
Pseudo-code
async func fetchAndParse(url: String) -> Data {
let response = await fetch(url) // yield point 0
let parsed = await parse(response) // yield point 1
return parsed
}
How it maps to the coroutine pattern
The state struct for an async function includes:
- State index (which await point we are at)
- All locals that live across await points (
response,parsed) - 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 orjmpt - The frontend performs the transformation; SBBE sees only regular functions and memory operations
- This pattern works for generators, async/await, and cooperative multitasking