Actor Model

Actors, mailboxes, and message passing.

What is an actor?

An actor is an isolated, single-threaded state machine with a mailbox. Each actor processes one message at a time — there are no intra-actor data races. Actors do not share mutable state; they communicate exclusively by sending messages.

This model, inspired by Pony's capability discipline, provides compile-time data-race freedom without locks.

Defining actors

Actors are declared with the actor keyword. Fields hold the actor's private state. Message handlers use receive fn; internal methods use fn.

Actor definition
actor Counter {
    let count: Int;
    receive fn increment(n: Int) {
        count = count + n;
    }
    receive fn get_count() -> Int {
        count
    }
    fn validate(n: Int) -> bool {
        n >= 0
    }
}

Actors can also implement traits, allowing polymorphic behaviour:

Actor with trait
trait Pingable {
    receive fn ping() -> String;
}
actor Pinger: Pingable {
    receive fn ping() -> String {
        "pong"
    }
}

Messaging patterns

Named actors expose receive fn methods as direct method calls — no explicit send primitives needed. The calling convention depends on the return type:

  • receive fn without return type → fire-and-forget. The call returns () immediately; no await needed.
  • receive fn with return type → request-response. The call returns Task<R> and the caller must await the result, which yields Result<R, CancellationError>.
Fire-and-forget vs request-response
actor Counter {
    let count: Int;
    receive fn increment(n: Int) {
        count = count + n;
    }
    receive fn get_count() -> Int {
        count
    }
}
fn main() {
    let c = spawn Counter;
    c.increment(10);                     // fire-and-forget: no await needed
    let n = await c.get_count();         // request-response: awaits the reply
    println(n);
}

Messages are moved at the language level — the sender relinquishes ownership. At the runtime level, values are deep-copied into the receiver's per-actor heap. Types must implement the Send trait to cross actor boundaries. The compiler verifies Send bounds at compile time.

Message ownership
receive fn forward(message: Int, target: Actor<Int>) {
    target <- message;  // message is sent to target's mailbox
}

The <- send operator

Lambda actors receive messages via the <- send operator. It enqueues a message (fire-and-forget) and returns immediately.

Sending to lambda actors
let worker = spawn (msg: Int) => {
    println(f"Got: {msg}");
};
worker <- 42;   // fire-and-forget

Lambda actors

Lambda actors provide lightweight, inline actor definitions. They handle a single message type and are ideal for simple, short-lived workers.

  • Fire-and-forget lambdas return ActorRef<Actor<M>>
  • Request-response lambdas return ActorRef<Actor<M, R>>
  • Use move to capture variables from the enclosing scope (captured values must implement Send)
Lambda actors
// Fire-and-forget lambda actor
let worker = spawn (msg: Int) => {
    println(msg * 2);
};
// Capture variables from enclosing scope
let factor = 3;
let multiplier = spawn (x: Int) => {
    println(x * factor);
};
// Factory function returning a lambda actor
fn make_adder(base: Int) -> Actor<Int> {
    spawn (x: Int) => {
        println(base + x);
    }
}
// Send messages with <- operator
worker <- 42;
multiplier <- 7;

Multiplexed messaging

Hew provides two built-in concurrency expressions for coordinating multiple asynchronous operations: select and join. These are expressions — they produce values — and integrate with Hew's structured concurrency model.

select — wait for first response
// select — first to complete wins, with pattern binding
let result = select {
    price from stock_api.get_price("AAPL") => price,
    cached from cache.lookup("AAPL") => cached,
    after 5.seconds => default_price,
};
join — wait for ALL to complete
// join — results collected into a tuple
let (users, posts) = join {
    db.get_users(),
    db.get_posts(),
};
// Dynamic join_all for collections
let results: Vec&lt;Int&gt; = join_all(actors, (a) => a.compute());

Actor generators

Actors can expose streaming data with receive gen fn. The handler uses yield to emit values one at a time. Callers consume the stream with for await.

Streaming actor generator
actor Counter {
    let max: Int;
    receive gen fn count_up() -> Int {
        var i = 0;
        while i < max {
            yield i;
            i = i + 1;
        }
    }
}
fn main() {
    let c = spawn Counter(max: 5);
    for await val in c.count_up() {
        println(val);
    }
}

Task<T> and structured concurrency

A Task<T> represents a concurrent computation within an actor that will produce a value of type T. Tasks are cooperatively scheduled on the actor's single logical thread — no data races on actor state.

A scope block creates a structured concurrency boundary. All tasks launched within a scope must complete before the scope exits. Use s.launch inside scope |s| { ... } to create tasks and await to collect results.

Structured concurrency
receive fn process_batch(ids: Vec&lt;string&gt;) -> Vec&lt;Data&gt; {
    scope |s| {
        var results: Vec&lt;Task&lt;Data&gt;&gt; = Vec::new();
        for id in ids {
            let task = s.launch {
                if let Some(cached) = cache.get(id) {
                    return cached.clone();
                }
                let data = fetch_data(id);
                cache.insert(id, data.clone());
                data
            };
            results.push(task);
        }
        results.iter().map((t) => await t).collect()
    }
}

Cooperative cancellation

Cancellation is cooperative: s.cancel() requests cancellation of all child tasks, but tasks must reach a cancellation point to actually stop. Cancellation points include await expressions, IO operations, and explicit s.is_cancelled() checks.

  • s.cancel() — request cancellation of all tasks in the scope
  • s.is_cancelled() — returns true if cancellation was requested; use if s.is_cancelled() { return; }
  • Pending tasks that haven't started are immediately marked Cancelled
  • Running tasks continue until they reach a cancellation point
Cancellation
receive fn download_files(urls: Vec&lt;string&gt;) -> Result&lt;Vec&lt;Data&gt;, Error&gt; {
    var results: Vec&lt;Data&gt; = Vec::new();
    scope |s| {
        for url in urls {
            s.launch {
                if s.is_cancelled() { return; }
                let data = http::get(url)?;  // also a cancellation point
                if s.is_cancelled() { return; }
                results.push(process(data));
            };
        }
    };
    Ok(results)
}

Actor lifecycle

Actors are scheduled on an M:N work-stealing runtime — many actors multiplex onto a small pool of OS threads. Each actor processes one message at a time from its mailbox. Every actor transitions through a well-defined set of states:

  • Init → Idle: Actor created, mailbox allocated
  • Idle → Runnable: Message arrives in the mailbox
  • Runnable → Running: Scheduler picks actor from the run queue
  • Running → Idle: Message budget exhausted or no more messages
  • Running → Blocked: Task awaits an external result
  • Blocked → Runnable: Awaited result arrives
  • Running → Stopping: Shutdown requested by supervisor or scope cancellation
  • Stopping → Stopped: Cleanup finished, normal exit
  • Running/Idle/Blocked/Stopping → Crashed: An unrecoverable trap occurs
  • Crashed → Stopped: Crash finalized, supervisor notified

Supervision

Supervisors own child actors and manage their lifecycles. When a child crashes, the supervisor decides whether and how to restart it based on the configured strategy:

  • one_for_one: Only the failed child is restarted
  • one_for_all: All children are restarted
  • rest_for_one: The failed child and all children started after it are restarted

Children have restart classifications: permanent (always restart), transient (restart on abnormal exit), or temporary (never restart). Each child has a restart budget (max restarts within a time window); exceeding it escalates the failure to the parent supervisor.

Supervision tree
supervisor AppSupervisor {
    child logger: Logger
        restart(permanent);
    child cache: CacheActor
        restart(permanent)
        budget(5, 60s)
        strategy(one_for_one);
    child worker: WorkerActor
        restart(transient);
}

The init() block

Actors can define an init() block that runs once when the actor starts (or restarts). Use it for initialization logic that depends on actor fields — computing derived state, logging startup, or establishing connections.

init() lifecycle hook
actor Counter {
    let value: Int;
    init() {
        println(f"Counter started at {value}");
        value += 10;
        println(f"Counter computed {value}");
    }
    receive fn tick() {
        value += 1;
        println(value);
    }
}

Accessing supervised children

Use supervisor_child(supervisor, index) to get a typed reference to a child actor managed by a supervisor. After a crash and restart, call it again to get the fresh reference.

supervisor_child()
supervisor ServiceTree {
    strategy: one_for_one;
    max_restarts: 5;
    window: 60;
    child counter: Counter(100);
    child debug_log: Logger(1);
    child error_log: Logger(3);
}
fn main() {
    let svc = spawn ServiceTree;
    sleep_ms(50);
    let c = supervisor_child(svc, 0);
    c.tick();
    let dlog = supervisor_child(svc, 1);
    dlog.log();
    let elog = supervisor_child(svc, 2);
    elog.log();
}

rest_for_one strategy

rest_for_one restarts the crashed child and all children declared after it. This models dependency order: if a database connection crashes, the API handler that depends on it must also restart.

rest_for_one — dependency-aware restarts
// Child order matters: cache -> db -> api
// If db crashes, db AND api restart (api depends on db).
// If cache crashes, only cache restarts (db and api are unaffected).
supervisor ServiceStack {
    strategy: rest_for_one;
    max_restarts: 5;
    window: 60;
    child cache: Cache(1000, 0);
    child db: Database(4);
    child api: ApiHandler(8080);
}

Nested supervisor trees

Supervisors can supervise other supervisors, forming a tree. When an inner supervisor's restart budget is exhausted, the failure escalates to the outer supervisor, which restarts the entire inner subtree. This is the same pattern used in Erlang/OTP application trees.

Nested supervisors
// Inner supervisor: manages a pool of workers
supervisor WorkerPool {
    strategy: one_for_one;
    max_restarts: 3;
    window: 60;
    child w1: Worker(1, 0);
    child w2: Worker(2, 0);
}
// Inner supervisor: manages cache actors
supervisor CacheManager {
    strategy: one_for_one;
    max_restarts: 2;
    window: 60;
    child cache: Cache(1000, 0);
}
// Root supervisor: manages the inner supervisors
//                  AppSupervisor
//                  /           \
//            WorkerPool     CacheManager
//            /      \            |
//        Worker(1)  Worker(2)  Cache
supervisor AppSupervisor {
    strategy: one_for_one;
    max_restarts: 5;
    window: 60;
    child pool: WorkerPool;
    child cache_mgr: CacheManager;
}
fn main() {
    let app = spawn AppSupervisor;
    sleep_ms(100);
    // Navigate the tree with supervisor_child()
    let pool = supervisor_child(app, 0);
    let w1 = supervisor_child(pool, 0);
    w1.process(100);
}

Backpressure

Every actor mailbox is bounded — the compiler enforces that all channels have a finite capacity. This prevents unbounded memory growth. The default mailbox capacity is 1024 messages.

When a mailbox is full, the overflow policy determines what happens:

  • block: Sender waits (cancellable) until space is available
  • drop_new: The new message is discarded
  • drop_old: The oldest message is evicted to make room
  • fail: An error is returned to the sender
  • coalesce(key): Replaces an existing message with the same key

Local sends default to block; network ingress defaults to drop_new unless overridden.