Generators and Actor Mailboxes

· 6 min read
Building Hew: Part 8 of 16

The v0.6.0 change that renamed yield to cooperate in the scheduler looked like housekeeping. It was a prerequisite. That rename existed specifically to unblock this: generators needed yield, and the cooperative scheduling system was squatting on the keyword.

Three generator forms landed in v0.6.1. The synchronous and async variants are well-understood — most languages with generators offer something equivalent. The third form, receive gen fn, is the one specific to Hew — a generator that runs inside an actor and yields values to a consumer in a different actor.

The Three Forms

The simplest is gen fn, which produces a Generator<Y>. Lazy, synchronous:

gen fn fibonacci() -> i32 {
    let (a, b) = (0, 1);
    loop {
        yield a;
        (a, b) = (b, a + b);
    }
}

// Lazy — nothing runs until you pull
for val in fibonacci().take(10) {
    println(val);
}

The generator suspends at each yield and resumes when the consumer pulls the next value. Generator<Y> implements Iterator<Y>, so any function that accepts an iterator can take a generator directly. (This matters more than it sounds — it means generators slot into the standard library without special-casing.)

The async variant, async gen fn, allows await inside the generator body:

async gen fn poll_updates(url: String) -> Update {
    loop {
        let response = await fetch(url);
        for item in response.items {
            yield item;
        }
        await sleep(Duration::seconds(5));
    }
}

for await update in poll_updates(endpoint) {
    handle(update);
}

Returns AsyncGenerator<Y>, which implements AsyncIterator<Y>. The consumer uses for await to pull values. Same lazy-pull semantics as the sync form, just with async suspension layered on top.

Crossing the Boundary

receive gen fn declares a generator that runs inside an actor:

actor Sensor {
    receive gen fn readings() -> f64 {
        for sample in hardware.stream() {
            let filtered = calibrate(sample);
            yield filtered;
        }
    }
}

// From another actor — crosses a boundary
let sensor = spawn Sensor::new(config);
for await reading in sensor.readings() {
    if reading > threshold {
        alert(reading);
    }
}

When sensor.readings() is called from another actor, it doesn’t return a generator handle in the traditional sense. It starts producing values inside the Sensor actor, and each yield sends a message through the actor’s mailbox to the consumer. The consumer’s for await loop pulls values by sending continuation requests back. The return type is ActorStream<Y> — a lazy stream the consumer iterates like any async sequence, even though the values arrive through the mailbox.

Backpressure is already handled — every actor mailbox has a bounded capacity, so when the consumer falls behind, the producer’s yield blocks waiting for space. Same mechanism that throttles any other mailbox send. (I didn’t have to build anything new for this, which is either good design or a coincidence. I’m honestly not sure which.)

The actor’s single-threaded execution model helps too. The generator body in a receive gen fn can freely access actor fields directly — without synchronization — because nothing else runs concurrently inside that actor. A traditional concurrent generator would need locks around shared state, but here the actor model provides mutual exclusion structurally.

The whole thing required no new runtime primitives. Actors already have mailboxes with backpressure, and receive already means “this method processes messages.” Adding gen to that combination just composes the two abstractions using infrastructure that was already there. The compiler desugars receive gen fn into an actor method that sends each yielded value as a mailbox message and awaits a continuation signal before resuming.

The trait hierarchy ties it together:

// Generators satisfy the iterator traits
trait Iterator<T> {
    fn next(mut self) -> Option<T>;
}

trait AsyncIterator<T> {
    async fn next(mut self) -> Option<T>;
}

// So this works:
fn sum_all<I: Iterator<i32>>(iter: I) -> i32 {
    let total = 0;
    for val in iter { total += val; }
    total
}

// Pass a generator where an iterator is expected
let fib_sum = sum_all(fibonacci().take(20));

Since Generator<Y> implements Iterator<Y> and AsyncGenerator<Y> implements AsyncIterator<Y>, anything written against the iterator traits already works with generators. The standard library’s map, filter, take, zip all just work — no adapter needed.