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 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:
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 fnwithout return type → fire-and-forget. The call returns()immediately; noawaitneeded.receive fnwith return type → request-response. The call returnsTask<R>and the caller mustawaitthe result.
// 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 i32Messages 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.
receive fn forward(message: Message, target: ActorRef<Handler>) { 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 ().
// 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 awaitLambda 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
moveto capture variables from the enclosing scope (captured values must implementSend)
// 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 — 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 — all branches must have the same typelet fastest = race { server_a.fetch(query), server_b.fetch(query), after 3.seconds => fallback(),};// join — results collected into a tuplelet (users, posts) = join { db.get_users(), db.get_posts(),};// Dynamic join_all for collectionslet results: Vec<i32> = 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.
receive fn process_batch(ids: Vec<String>) -> Vec<Data> { scope { var results: Vec<Task<Data>> = 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 scopescope.is_cancelled()— check if cancellation was requestedscope.check_cancelled()— returnsErr(TaskCancelled)if cancelled- Pending tasks that haven't started are immediately marked
Cancelled - Running tasks continue until they reach a cancellation point
receive fn download_files(urls: Vec<String>) -> Result<Vec<Data>, Error> { var results: Vec<Data> = 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.
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.