Actors

An actor is a concurrent object — it runs as an independent BEAM process with its own mailbox. Actors can hold mutable state and communicate by sending messages. This maps directly to Erlang's gen_server model.

Use Actor subclass: when you need:

Use Value subclass: (chapter 10) for plain data with no mutable state.

Defining an actor

Actor subclass: Counter
  state: value = 0

  // Mutate state with self.slot :=
  increment =>
    self.value := self.value + 1

  // Return the current value (^ for early return; last expression is implicit return)
  getValue =>
    self.value

  // Arguments work just like value class methods:
  incrementBy: n =>
    self.value := self.value + n

  reset =>
    self.value := 0

Creating and using actors

spawn creates a new actor process. Unlike new (value classes), actors run as independent BEAM processes.

TestCase subclass: Ch11Actors

  testBasicCounterLifecycle =>
    c := Counter spawn
    self assert: c getValue equals: 0
    c increment
    self assert: c getValue equals: 1
    c increment
    c increment
    self assert: c getValue equals: 3

  testIncrementBy =>
    c := Counter spawn
    c incrementBy: 10
    self assert: c getValue equals: 10
    c incrementBy: 5
    self assert: c getValue equals: 15

  testReset =>
    c := Counter spawn
    c increment
    c increment
    c increment
    self assert: c getValue equals: 3
    c reset
    self assert: c getValue equals: 0

  testSpawnWithInitialState =>
    // spawnWith: lets you set initial slot values:
    c := Counter spawnWith: #{#value => 100}
    self assert: c getValue equals: 100
    c increment
    self assert: c getValue equals: 101

Call vs cast: synchronous and asynchronous sends

By default, sending a message to an actor is synchronous — the sender blocks until the actor processes the message and returns a result. This is a call (maps to gen_server:call).

For fire-and-forget messages, append ! to the send. This is a cast — the sender continues immediately without waiting. The cast always returns nil to the caller. (Maps to gen_server:cast.)

TestCase subclass: Ch11CastSend

  testCastSendIncrements =>
    c := Counter spawn
    c increment!
    // Sync barrier: getValue forces cast to be processed first
    self assert: c getValue equals: 1

  testMultipleCastSends =>
    c := Counter spawn
    c increment!
    c increment!
    c increment!
    self assert: c getValue equals: 3

  testSyncCallReturnsValue =>
    c := Counter spawn
    c increment
    self assert: c getValue equals: 1

When to use cast (!):

When to use call (default):

Actor lifecycle

The initialize method

If your actor needs setup beyond default state values, define initialize. In the REPL, it runs automatically after spawn. In compiled code (including BUnit tests), call it explicitly after spawn.

Actor subclass: StackActor
  state: items = nil

  initialize =>
    self.items := #[]

  push: item =>
    self.items := self.items add: item

  size => self.items size

Usage in the REPL:

s := StackActor spawn    // initialize runs automatically
s size                   // => 0
s push: "a"
s size                   // => 1

In BUnit tests, call initialize explicitly (see the Stack example in chapter 13).

Stopping actors

  testIsAlive =>
    c := Counter spawn
    self assert: c isAlive
    c stop
    self deny: c isAlive

  testStopIsIdempotent =>
    c := Counter spawn
    self assert: (c stop) equals: #ok
    self assert: (c stop) equals: #ok    // second stop is a no-op

  testSendingToStoppedActorRaisesError =>
    c := Counter spawn
    c stop
    self should: [c increment] raise: #actor_dead

Lifecycle methods:

MethodEffect
spawnCreate actor process with default state
spawnWith:Create with initial slot values
initializeCalled after spawn (override for setup)
isAliveCheck if actor process is running
stopGraceful shutdown (idempotent)

Deadlock prevention

A deadlock occurs when two actors call each other synchronously at the same time — each waits for the other's reply, and neither can proceed. Beamtalk detects this as a #timeout error after 5 seconds.

Prevention strategies:

  1. Use cast (!) for one direction in cyclic relationships:
// A calls B synchronously, B notifies A asynchronously:
actorA doWork          // sync call to B inside
actorB notify: event!  // cast back — no deadlock
  1. Self-sends are always safe — they bypass gen_server entirely. An actor calling its own methods within a method body dispatches directly, not through the mailbox.

  2. Avoid circular sync call patterns. If A calls B and B calls A, restructure so one direction uses cast.

A richer example: a bank account

Actor subclass: BankAccount
  state: balance = 0
  state: owner = ""

  deposit: amount =>
    amount > 0
      ifFalse: [^self error: "Amount must be positive"]
    self.balance := self.balance + amount

  withdraw: amount =>
    amount > self.balance
      ifTrue: [^self error: "Insufficient funds"]
    self.balance := self.balance - amount

  getBalance => self.balance

  getOwner => self.owner

TestCase subclass: Ch11BankAccount

  testDepositAndWithdraw =>
    account := BankAccount spawnWith: #{#owner => "Alice"}
    account deposit: 100
    account deposit: 50
    self assert: account getBalance equals: 150
    account withdraw: 30
    self assert: account getBalance equals: 120

  testOwner =>
    account := BankAccount spawnWith: #{#owner => "Bob"}
    self assert: account getOwner equals: "Bob"

  testWithdrawInsufficientFunds =>
    account := BankAccount spawn
    account deposit: 50
    self should: [account withdraw: 100] raise: #beamtalk_error

  testDepositNegativeAmount =>
    account := BankAccount spawn
    self should: [account deposit: -10] raise: #beamtalk_error

Multiple actors working together

Actor subclass: IdGenerator
  state: next = 1

  nextId =>
    id := self.next
    self.next := self.next + 1
    id

TestCase subclass: Ch11MultipleActors

  testIdGeneratorProducesUniqueIds =>
    gen := IdGenerator spawn
    id1 := gen nextId
    id2 := gen nextId
    id3 := gen nextId
    self assert: id1 equals: 1
    self assert: id2 equals: 2
    self assert: id3 equals: 3

  testMultipleIndependentActors =>
    // Each spawn creates an independent process with its own state:
    c1 := Counter spawn
    c2 := Counter spawn
    c1 increment
    c1 increment
    c2 increment
    self assert: c1 getValue equals: 2
    self assert: c2 getValue equals: 1

Actor vs Server

Most actors use Actor subclass:. If you need to receive raw Erlang messages (timer events, monitor DOWN tuples, system messages), use Server subclass: instead. Server is an abstract subclass of Actor — migration is a one-word change. See the Server documentation for details.

Key differences from Value classes

AspectValue classActor
CreateClassName newClassName spawn
Initial statenew: #{slot => v}spawnWith: #{slot => v}
MutationNot allowedself.slot := expr
IdentityEquality by valueEach spawn is a unique process
BEAM modelErlang mapgen_server process
Concurrent?Copy-safe (no state)Yes, message-passing safe
Message styleAlways synchronousSync (default) or cast (!)

Exercises

1. Counter lifecycle. Spawn a Counter, increment it 5 times, verify the value is 5, then stop it and confirm it is no longer alive.

Hint
c := Counter spawn
5 timesRepeat: [c increment]
c getValue        // => 5
c stop
c isAlive         // => false

2. Independent actors. Spawn two Counters and increment them different numbers of times. Verify they have completely separate state.

Hint
c1 := Counter spawn
c2 := Counter spawn
c1 incrementBy: 10
c2 incrementBy: 3
c1 getValue    // => 10
c2 getValue    // => 3

Each spawn creates an independent BEAM process with its own state.

3. Error handling with actors. Create a BankAccount, deposit 50, then try to withdraw 100. Catch the error and return a meaningful message.

Hint
acct := BankAccount spawn
acct deposit: 50
result := [acct withdraw: 100] on: Error do: [:e | "Insufficient funds"]
// result => "Insufficient funds"