Skip to content

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 — FIFO-ordered message queue
  • An identity — a unique Actor reference (64-bit id)
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(sender: Actor) {
send sender reply(self.count)
}
}

spawn creates a new actor instance and returns its reference:

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 link and spawn monitor create an actor and immediately link or monitor it to the spawner:

// Link: abnormal exits propagate to the spawner
let worker = spawn link Worker {} in { worker }
// Monitor: spawner receives a DOWN message when worker exits
let 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.

send delivers a message to an actor’s mailbox asynchronously:

send counter inc()
send counter inc_by(5)
send counter get(self) // self refers to the current actor

Messages are always delivered and never dropped. The sender does not block.

Inside a behavior, receive blocks until a matching message arrives:

behavior wait_for_reply() {
receive {
| reply(value: Int) => {
perform IO.print("Got: " + Int.to_string(value))
}
}
}

The receive expression supports selective matching — only matching messages are consumed; non-matching messages are requeued:

receive {
| reply(value: Int) => {
// Handle integer reply
perform IO.print("Int: " + Int.to_string(value))
}
| reply(msg: String) => {
// Handle string reply
perform IO.print("String: " + msg)
}
}

Wait for a message with a timeout:

receive {
| reply(value: Int) => perform IO.print("Got it")
} after 5000 => {
perform IO.print("Timed out after 5 seconds")
}

When no matching message is in the mailbox and the timeout is positive, the actor suspends. The runtime:

  1. Suspends the actor, capturing its execution state (frames, PC, registers).
  2. Arms a one-shot timer for the timeout duration.
  3. 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.

  1. Spawned — Actor is created with initial state
  2. Running — Processing messages from the mailbox
  3. Waiting — Blocked in a receive with no matching messages
  4. Exited — Actor terminated (normal, error, or killed)

Actors can self-exit:

perform Actor.exit(0) // Normal exit
perform Actor.exit(1) // Error exit
perform Actor.exit(2) // Kill (cannot be trapped)

Actors are scheduled cooperatively with reduction-bounded fairness. Each actor gets a budget of 1000 message reductions per turn. When exhausted, it yields and requeues at the back of its priority queue.

Priorities: High → Normal (default) → Low. All High-priority actors run before any Normal; Normal before Low.