Actor Model
The Actor Model
Section titled “The Actor Model”Nulang actors are isolated, concurrent units of computation that communicate exclusively through asynchronous message passing. Each actor has:
- Private state — mutable fields only accessible from within the actor
- Behaviors — message handlers that transform state and send responses
- A mailbox — priority-separated message queues (system ahead of normal/bulk messages), not a single global FIFO
- An identity — a unique
Actorreference (64-bit id)
Defining Actors
Section titled “Defining Actors”actor Counter { state count: Int = 0
behavior inc() { self.count = self.count + 1 }
behavior inc_by(amount: Int) { self.count = self.count + amount }
behavior get() { self.count }}Behaviors that return a value can be called synchronously with ask. For actor-to-actor replies, use the sender pattern: behavior get(sender: Actor) { send sender reply(self.count) }.
Spawning Actors
Section titled “Spawning Actors”spawn creates a new actor instance and returns its reference:
actor Counter { state count: Int = 0 behavior inc() { self.count = self.count + 1 }}let counter = spawn Counter {} in { // counter is an Actor reference send counter inc() counter}The expression after in runs in the spawner’s context and can reference the new actor.
Spawn with Link or Monitor
Section titled “Spawn with Link or Monitor”spawn link and spawn monitor create an actor and immediately link or monitor it to the spawner:
actor Worker { behavior work() { 0 }}
// Link: abnormal exits propagate to the spawnerlet worker = spawn link Worker {} in { worker }
// Monitor: spawner receives a DOWN message when worker exitslet worker = spawn monitor Worker {} in { worker }These are parser desugars — spawn link Actor {..} in {..} is equivalent to spawn Actor {..} in { perform Actor.link(actor); .. }, and likewise for monitor. See Supervision Trees for link/monitor semantics and exit trapping.
Sending Messages
Section titled “Sending Messages”send attempts asynchronous delivery to an actor’s mailbox:
actor Counter { state count: Int = 0 behavior inc() { self.count = self.count + 1 } behavior inc_by(n: Int) { self.count = self.count + n } behavior get() { self.count } behavior tick() { // self refers to the current actor — usable as a send target send self inc() send self inc_by(5) send self get() }}let counter = spawn Counter {}send counter tick()A successful send expression is not a delivery acknowledgment. New actors use unbounded mailboxes by default, but the runtime supports bounded mailboxes: normal/bulk messages can be rejected when capacity is exhausted, while system/supervision messages bypass that limit. Sends to missing actors or unknown behaviors can also be rejected, as can unsupported cross-shard payloads. The local runtime records a dead-letter notification for some failed deliveries, not a durable copy of the original message.
The language-level fire-and-forget send does not establish exactly-once delivery, durable retries, or remote-delivery guarantees. For critical messages, design an explicit acknowledgment/idempotency protocol and plan for backpressure. See Implementation Status for the runtime boundary.
Receiving Messages
Section titled “Receiving Messages”Inside a behavior, receive looks for a matching message without blocking. Non-matching messages remain available for later receives; priority bands mean the mailbox is not one globally ordered FIFO. Without a timeout, receive returns nil immediately when there is no match:
actor Worker { behavior check_mailbox() { receive { | reply(value) => { perform IO.print("Got: " + perform Int.to_string(value)) } } }}The receive expression supports selective matching — only matching messages are consumed; non-matching messages are requeued. Arms dispatch on the behavior name, so each arm must use a distinct behavior name:
receive { | reply_int(value) => { // Handle integer reply perform IO.print("Int: " + perform Int.to_string(value)) } | reply_str(msg) => { // Handle string reply perform IO.print("String: " + msg) }}Timed Receive
Section titled “Timed Receive”Wait for a message with a timeout:
receive { | reply(value) => perform IO.print("Got it")} after 5000 => { perform IO.print("Timed out after 5 seconds")}How timed receive works
Section titled “How timed receive works”When no matching message is in the mailbox and the timeout is positive, the actor suspends. The runtime:
- Suspends the actor, capturing its execution state (frames, PC, registers).
- Arms a one-shot timer for the timeout duration.
- Resumes the actor when either a matching message arrives or the timer fires.
If the timer fires first, the after body runs. If a matching message arrives first, the matching arm runs and the timer is cancelled. The actor stays suspended and re-suspends if a non-matching message arrives (it is requeued, not consumed).
Suspension only happens inside an actor context with the runtime enabled. Outside an actor (e.g. in a standalone --eval script), receive with after resolves synchronously — it returns immediately when no match is found rather than suspending.
Actor Lifecycle
Section titled “Actor Lifecycle”- Spawned — Actor is created with initial state
- Running — Processing messages from the mailbox
- Waiting — Suspended in a timed
receive ... afterwith no matching messages - Exited — Actor terminated (normal, error, or killed)
Actors can self-exit:
perform Actor.exit(0) // Normal exitperform Actor.exit(1) // Error exitperform Actor.exit(2) // Kill (cannot be trapped)Scheduling
Section titled “Scheduling”Actors are scheduled cooperatively. A newly created actor has a 1,000-message-reduction ceiling per turn, but the live scheduler also limits mailbox batches: currently up to 16 messages when peers are ready and up to 256 messages when the actor is running alone (subject to mailbox depth and the reduction ceiling).
These are message-level scheduling limits, not a promise that every CPU-intensive behavior can be interrupted mid-handler. JIT safepoints and instruction-budget work have separate semantics and validation requirements; do not assume universal preemption for blocking native calls or all execution backends.
Priorities are strict: High → Normal (default) → Low. Sustained High-priority traffic can starve lower-priority work.
- Distribution & Clustering — run actors across multiple nodes
- Supervision Trees — build fault-tolerant systems with OTP supervisors