Actor Model
Actors, mailboxes, and message passing.
What is an actor?
An actor is an isolated state machine with a mailbox. Each actor processes one handler at a time, including across suspension. Actors do not share mutable state; callers use typed actor handles to send requests or submit messages.
The compiler checks which values may cross that boundary, so mutable state stays with one actor instead of being protected by locks shared among callers. Read Actor Isolation and Message Passing for the boundary model in plain language.
Choose an actor's authority
Isolation gives each actor private state. To also check which services its code can acquire, use authority confinement. The guide starts with an in-memory service, then supplies access to individual files. #[confined] asks the compiler to verify that boundary transitively.
Defining actors
Actors are declared with the actor keyword. Fields hold the actor's private state — use var for fields a handler mutates (give a default) and let for fields set once at spawn. State is referenced by bare name (no self). Message handlers use receive fn; shared helper logic lives in top-level free functions. Use spawn Counter to start an instance with its default field values, or spawn Counter { count: 10 } to supply initial state.
fn validate(n: i64) -> bool {
n >= 0
}
actor Counter {
var count: i64 = 0;
receive fn increment(n: i64) {
if validate(n) { count = count + n; }
}
receive fn get_count() -> i64 {
count
}
}Actors can also declare a trait bound, allowing polymorphic behaviour:
trait Pingable {
fn label(p: Self) -> string;
}
actor Pinger: Pingable {
receive fn ping() -> string {
"pong"
}
}Messaging patterns
Named actors expose receive fn methods as direct method calls — no explicit message enums are needed. Every ordinary handler call waits for completion and reports its outcome:
- A handler without a return annotation replies with
Result<(), ActorError<E, Req>>. - A handler returning
Rreplies withResult<R, ActorError<E, Req>>. Match the reply, propagate it with?, or useexpectwhere failure should stop the caller. - Use
let task = fork actor.method(...)to start the call concurrently, thenawait taskfor its result.
In ActorError<E, Req>, E is the handler's declared fails type and Req is the type of an unaccepted request returned to the caller. Each is Never when that outcome cannot carry a value.
actor Counter {
var count: i64 = 0;
receive fn increment(n: i64) {
count = count + n;
}
receive fn get_count() -> i64 {
count
}
}
fn main() {
let c = spawn Counter { count: 0 };
c.increment(10).expect("actor stopped"); // waits for completion
let n = c.get_count(); // request-reply: Result<i64, ActorError>
match n {
.Ok(v) => println(v),
.Err(_) => println("ask failed"),
}
}Message values must satisfy Send, which the compiler derives and checks. Ordinary data crosses as an independent logical snapshot, so the sender may keep using its value. A sendable affine resource instead transfers its sole owner and cannot be reused by the sender. To pass an actor handle to another actor, pass its actor type T and call its handlers. The ownership guide explains the difference between ordinary and affine values.
actor Sink {
receive fn take(message: i64) {
println(message);
}
}
fn forward(message: i64, target: Sink) {
target.take(message).expect("actor stopped"); // waits until the target handles the message
}Lambda actors
Lambda actors provide lightweight, inline actor definitions written as actor |params| { body }. They handle a single message type, capture variables from the enclosing scope, and use completion-call syntax like named actors.
fn main() {
let factor = 3;
let multiplier = actor |x: i64| -> i64 {
x * factor
};
println(multiplier(7).expect("actor stopped")); // 21
stop(multiplier);
stopped(multiplier);
}Multiplexed messaging
Hew provides two built-in concurrency expressions for coordinating multiple asynchronous operations: select and await fork (...). These are expressions — they produce values — and integrate with Hew's structured concurrency model.
actor StockApi {
receive fn get_price(sym: string) -> i64 { 100 }
}
actor PriceCache {
receive fn lookup(sym: string) -> i64 { 99 }
}
fn main() {
let stock_api = spawn StockApi;
let cache = spawn PriceCache;
let default_price: i64 = 0;
// select — first to reply wins, with pattern binding
let result = select {
price from stock_api.get_price("AAPL") => price handle e { default_price },
cached from cache.lookup("AAPL") => cached handle e { default_price },
after 5s => default_price,
};
println(result);
}actor Db {
receive fn get_users() -> i64 { 1 }
receive fn get_posts() -> i64 { 2 }
}
fn main() {
let db = spawn Db;
// Start both calls and collect their results in a tuple
let (users, posts) = await fork (
db.get_users(),
db.get_posts(),
);
println(users.expect("database actor stopped"));
println(posts.expect("database actor stopped"));
}Actor generators
A receive gen fn handler produces a stream. Its return annotation names the yielded item type. Consume it with an ordinary for loop; each pull waits for the producer automatically.
actor Counter {
receive gen fn count_up() -> i64 {
yield 0;
yield 1;
yield 2;
}
}
fn main() {
let counter = spawn Counter;
for value in counter.count_up() {
println(value);
}
}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 inside an actor handler. Each let name = fork fn(); starts the expression as a child task bound to name : Task<T>. Inside the scope, let x = await name; suspends the handler until that task finishes and binds the result to x. The scope joins every child before it returns, so no task outlives the block.
The structured concurrency guide explains task ownership, deadlines, cooperative cancellation, and how task lifetime differs from actor lifetime.
fn fetch_first() -> i64 { 100 }
fn fetch_second() -> i64 { 200 }
actor Fetcher {
receive fn fetch_pair() -> i64 {
var first_val = 0;
var second_val = 0;
scope {
let first = fork fetch_first();
let second = fork fetch_second();
// await is a statement expression — bind results to new lets,
// then store into outer vars
let f = await first;
let s = await second;
first_val = f;
second_val = s;
};
first_val + second_val
}
}Cooperative cancellation
Concurrency in Hew is cooperative: a long-running computation yields to the scheduler at loop headers and call sites (the compiler inserts cooperate points), so other tasks and actors keep making progress. When a child task in a scope fails, its siblings are cancelled and the scope unwinds — failure propagation is structural, not a manual cancel-flag dance.
- Child failure cancels sibling tasks in the same scope.
- A scope never returns until every child has finished or been cancelled.
cooperatepoints (inserted automatically) are where cancellation takes effect.
Actor lifecycle
spawn constructs an actor and its mailbox. A handler call waits for that turn, not for the actor's entire lifetime. Use stop(actor) to request graceful shutdown and stopped(actor) to wait for terminal hooks, state cleanup, and owned resource release. terminate(actor) requests cancellation.
#[on(start)]runs after spawn and after a supervised restart.#[on(stop)]runs during graceful shutdown.#[on(crash)]can inspect a supervised failure and request restart or give up.
See Actor Lifecycle and Supervision for the complete source-level path and the distinction between stable supervised roles and actor incarnations. Runtime scheduler states are implementation detail, not a synchronization API.
Supervision
Supervisors own child actors and manage their lifecycles. When a child , 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.
actor Logger {
var level: i64 = 0;
receive fn log(msg: string) { println(msg); }
}
actor CacheActor {
var capacity: i64 = 0;
receive fn get(key: string) -> string { "" }
}
actor WorkerActor {
var id: i64 = 0;
receive fn work(n: i64) { println(n); }
}
supervisor AppSupervisor {
strategy: one_for_one;
intensity: 5 within 60s;
child logger: Logger { level: 3 } restart: permanent;
child cache: CacheActor { capacity: 1024 } restart: permanent;
child worker: WorkerActor { id: 1 } restart: transient;
}Lifecycle hooks
Actors run setup in an #[on(start)] hook (after spawn or restart) and teardown in #[on(stop)]; an #[on(crash)] hook observes failures. These are plain fns with no parameters, and fields are in scope by bare name.
actor Counter {
var value: i64 = 0;
#[on(start)] fn boot() {
println(f"Counter started at {value}");
value += 10;
}
#[on(stop)] fn done() {
println("Counter stopping");
}
receive fn tick() {
value += 1;
println(value);
}
}Accessing supervised children
A supervised child is reached by its declared name through field syntax — supervisor.child_name. The compiler resolves the name to a fully typed handle for that child actor at compile time.
actor Counter {
var start: i64 = 0;
receive fn tick() { start += 1; println(start); }
}
actor Logger {
var level: i64 = 0;
receive fn log() { println(f"log level {level}"); }
}
supervisor ServiceTree {
strategy: one_for_one;
intensity: 5 within 60s;
child counter: Counter { start: 100 };
child debug_log: Logger { level: 1 };
child error_log: Logger { level: 3 };
}
fn main() {
let svc = spawn ServiceTree;
sleep(50ms);
let c = svc.counter;
c.tick().expect("actor stopped");
let dlog = svc.debug_log;
dlog.log().expect("actor stopped");
stop(svc);
stopped(svc);
}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.
actor Cache {
var capacity: i64 = 0;
receive fn get(key: string) -> string { "" }
}
actor Database {
var connections: i64 = 0;
receive fn query(sql: string) -> string { "" }
}
actor ApiHandler {
var port: i64 = 0;
receive fn handle(req: string) -> string { "" }
}
// Child order matters: cache -> db -> api
// If db crashes, db AND api restart (api depends on db).
// If cache crashes, cache, db AND api restart in declaration order.
supervisor ServiceStack {
strategy: rest_for_one;
intensity: 5 within 60s;
child cache: Cache { capacity: 1000 } restart: permanent;
child db: Database { connections: 4 } restart: permanent;
child api: ApiHandler { port: 8080 } restart: transient;
}Nested supervisor trees
A declared child may itself be a supervisor. Each level owns its restart policy and budget; an exhausted inner supervisor reports its failure to its parent. Child access follows the declared tree and returns the current stable role.
actor Worker {
let id: i64;
receive fn get() -> i64 { id }
}
supervisor WorkerPool {
strategy: one_for_one;
intensity: 3 within 60s;
child first: Worker { id: 1 } restart: permanent;
child second: Worker { id: 2 } restart: permanent;
}
supervisor Application {
strategy: one_for_one;
intensity: 5 within 60s;
child workers: WorkerPool;
}
fn main() {
let app = spawn Application;
println(app.workers.first.get().expect("worker stopped"));
}Backpressure
An actor without a mailbox declaration has unbounded capacity. Declare a bound when the service needs an explicit admission limit; mailbox 1024; waits for space by default.
A bounded declaration can choose what happens to one-way submissions when the mailbox is full:
- 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
Completion calls and stream starts wait for space; they are never discarded or superseded. The backpressure comparison shows these policies in a complete example.