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 {    var count: i32 = 0;    // Message handler - callable from other actors    receive fn increment(n: i32) {        count += n;    }    // Request-response message handler    receive fn get_count() -> i32 {        count    }    // Internal method - not accessible to other actors    fn validate(n: i32) -> bool {        n >= 0    }}

Actors can also implement traits, allowing polymorphic behavior:

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 .send() or .ask() 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.
Fire-and-forget vs request-response
// Fire-and-forget (receive fn without return type)actor Counter {    var count: i32 = 0;    receive fn increment(n: i32) {        count += n;    }    receive fn get_count() -> i32 {        count    }}let c = spawn Counter { count: 0 };c.increment(10);              // fire-and-forget: no await neededlet n = await c.get_count();  // request-response: returns i32

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: Message, target: ActorRef&lt;Handler&gt;) {    target.handle(message);  // message is MOVED to target's mailbox    // message is no longer accessible here}

The <- send operator

For lambda actors and explicit message sending, the <- operator provides a concise send syntax. It enqueues a message (fire-and-forget) and returns ().

The <- operator
// For lambda actors and explicit sendslet worker = spawn (msg: i32) => {    println(f"Got: {msg}");};worker <- 42;                                   // fire-and-forgetlet result = await worker <- compute_request;   // with await

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 actorlet worker = spawn (msg: i32) => {    println(msg * 2);};// Request-response lambda actorlet calc = spawn (x: i32) -> i32 => { x * x };// Capture with movelet factor = 2;let multiplier = spawn move (x: i32) -> i32 => {    x * factor};// Send messages using the <- operatorworker <- 42;let result = await calc <- 5;

Multiplexed messaging

Hew provides three built-in concurrency expressions for coordinating multiple asynchronous operations: select, race, 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 bindinglet result = select {    price from stock_api.get_price("AAPL") => price,    cached from cache.lookup("AAPL") => cached,    after 5.seconds => default_price,};
race — first to complete, cancel the rest
// race — all branches must have the same typelet fastest = race {    server_a.fetch(query),    server_b.fetch(query),    after 3.seconds => fallback(),};
join — wait for ALL to complete
// join — results collected into a tuplelet (users, posts) = join {    db.get_users(),    db.get_posts(),};// Dynamic join_all for collectionslet results: Vec&lt;i32&gt; = join_all(actors, |a| a.compute());

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 spawned within a scope must complete before the scope exits. Use scope.spawn to create tasks and await to collect results.

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

Cooperative cancellation

Cancellation is cooperative: scope.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 scope.check_cancelled() calls.

  • scope.cancel() — request cancellation of all tasks in the scope
  • scope.is_cancelled() — check if cancellation was requested
  • scope.check_cancelled() — returns Err(TaskCancelled) if cancelled
  • 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 {        for url in urls {            scope.spawn {                scope.check_cancelled()?;                let data = http::get(url)?;  // also a cancellation point                scope.check_cancelled()?;                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 → Running: Actor created, mailbox allocated
  • Running → Waiting: No runnable messages or tasks
  • Waiting → Running: Message arrives or timer fires
  • Running → Stopping: Supervisor requests shutdown or scope cancellation drains
  • Stopping → Stopped: Cleanup finished, normal exit
  • Running/Waiting/Stopping → Crashed: A trap (panic) 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). Supervisors have a restart budget (max_restarts within a window); exceeding it escalates the failure to the parent supervisor.

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

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.