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 {
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:
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 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, which yieldsResult<R, CancellationError>.
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.
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.
let worker = spawn (msg: Int) => {
println(f"Got: {msg}");
};
worker <- 42; // fire-and-forgetLambda 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 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 — 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 — results collected into a tuple
let (users, posts) = join {
db.get_users(),
db.get_posts(),
};
// Dynamic join_all for collections
let results: Vec<Int> = 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.
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.
receive fn process_batch(ids: Vec<string>) -> Vec<Data> {
scope |s| {
var results: Vec<Task<Data>> = 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 scopes.is_cancelled()— returnstrueif cancellation was requested; useif s.is_cancelled() { return; }- 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 |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.
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.
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 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.
// 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.
// 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.