1. Introduction
Actors and agents provide thread-safe concurrent state management through message passing. Instead of protecting shared state with locks, you send messages to an actor or agent which processes them one at a time on a dedicated thread. This eliminates data races by design.
Groovy provides two levels of abstraction:
-
Agent — a thread-safe mutable value updated via functions
-
Actor — a message-processing entity with flexible dispatch
Both use virtual threads on JDK 21+ for efficient scaling.
2. Agent
An Agent wraps a value that can be read by any thread but modified
only through serialised update functions:
import groovy.concurrent.Agent
def counter = Agent.create(0)
counter.send { it + 1 }
counter.send { it + 1 }
counter.send { it + 1 }
assert await(counter.getAsync()) == 3
2.1. Reading
// Snapshot read — non-blocking, returns current value
def current = counter.get()
// Consistent read — waits for pending updates to complete
def consistent = await(counter.getAsync())
2.2. Updating
// Fire-and-forget update
counter.send { it + 1 }
// Update and get new value
def newValue = await(counter.sendAndGet { it * 2 })
2.3. Complex state
Agents work with any value type:
def inventory = Agent.create([:])
inventory.send { state -> state + [apples: 10] }
inventory.send { state -> state + [bananas: 5] }
inventory.send { state ->
state.collectEntries { k, v -> [k, v * 2] }
}
assert await(inventory.getAsync()) == [apples: 20, bananas: 10]
2.4. Observing state changes
An agent exposes a Flow.Publisher<T> of state transitions via
changes(). Each successful update emits the new value to every
subscriber that is already subscribed at the time of the update. The
stream is hot (no replay of prior state), per-subscriber buffered,
and closes with onComplete when shutdown() is called:
def agent = Agent.create(0)
try {
async {
3.times { agent.send { it + 1 } }
Thread.sleep(50)
agent.shutdown()
}
def seen = []
for await (v in agent.changes()) {
seen << v
}
assert seen == [1, 2, 3]
} finally {
agent.shutdown()
}
Slow subscribers drop newly-offered values rather than backpressuring
the agent’s update loop — buffered values are still delivered in order,
but the most recent update may be skipped if a subscriber’s buffer is
full (default size 256). If changes() is first called after
shutdown(), the returned publisher is already closed and subscribers
receive onComplete immediately.
3. Actor
An Actor processes messages from a queue on a dedicated thread.
Two factory methods cover the common patterns:
3.1. Reactor (stateless)
A reactor applies a function to each message. The return value
becomes the reply for sendAndGet callers:
import groovy.concurrent.Actor
def doubler = Actor.reactor { it * 2 }
assert await(doubler.sendAndGet(5)) == 10
assert await(doubler.sendAndGet(21)) == 42
doubler.stop()
Reactors are ideal for pure-function message processing — validators, transformers, calculators:
def validator = Actor.reactor { msg ->
if (msg instanceof String && msg.length() > 0) 'valid'
else 'invalid'
}
3.2. Stateful
A stateful actor maintains state across messages. The handler
receives (state, message) and returns the new state:
def counter = Actor.stateful(0) { state, msg ->
switch (msg) {
case 'increment': return state + 1
case 'decrement': return state - 1
case 'reset': return 0
default: return state
}
}
counter.send('increment')
counter.send('increment')
counter.send('decrement')
assert await(counter.sendAndGet('increment')) == 2
counter.stop()
For sendAndGet, the new state is the reply. This makes it easy
to query the current state:
// Send a no-op message to read the state
def currentState = await(counter.sendAndGet('query'))
3.3. Typed message dispatch
Use pattern matching for rich message protocols:
def account = Actor.stateful(0.0) { balance, msg ->
switch (msg) {
case { it instanceof Map && it.deposit }:
return balance + msg.deposit
case { it instanceof Map && it.withdraw }:
if (msg.withdraw > balance)
throw new RuntimeException('Insufficient funds')
return balance - msg.withdraw
default:
return balance
}
}
account.send([deposit: 100])
account.send([withdraw: 30])
def balance = await(account.sendAndGet([deposit: 0]))
assert balance == 70.0
account.stop()
4. Lifecycle
Both actors and agents support lifecycle management:
def actor = Actor.reactor { it }
assert actor.isActive()
actor.stop() // graceful: processes remaining messages then exits
Thread.sleep(50)
assert !actor.isActive()
Actors implement AutoCloseable, so they work with
try-with-resources (Groovy or Java):
Actor.reactor { it * 2 }.withCloseable { actor ->
assert await(actor.sendAndGet(5)) == 10
}
// actor is stopped
5. Error Handling
Exceptions in message handlers are captured and delivered to
sendAndGet callers:
def risky = Actor.reactor { throw new RuntimeException('oops') }
try {
await(risky.sendAndGet('anything'))
} catch (RuntimeException e) {
assert e.message == 'oops'
}
risky.stop()
For fire-and-forget send calls, exceptions are silently absorbed
(the actor continues processing subsequent messages).
6. Choosing Between Agent and Actor
| Aspect | Agent | Actor |
|---|---|---|
State |
Single value, updated via function |
Arbitrary, managed by handler |
Messages |
Update functions only |
Any message type with dispatch |
Reply |
|
|
Use case |
Thread-safe counters, caches, accumulators |
State machines, services, typed protocols |
Both guarantee sequential message processing — no locks needed.
7. @ActiveObject / @ActiveMethod
For a more OOP approach, annotate a class with @ActiveObject and
its methods with @ActiveMethod. The compiler automatically routes
annotated method calls through an internal actor — callers just see
a normal class:
import groovy.transform.ActiveObject
import groovy.transform.ActiveMethod
@ActiveObject
class Account {
private double balance = 0
@ActiveMethod
void deposit(double amount) { balance += amount }
@ActiveMethod
void withdraw(double amount) {
if (amount > balance) throw new RuntimeException('Insufficient funds')
balance -= amount
}
@ActiveMethod
double getBalance() { balance }
}
def account = new Account()
account.deposit(100) // async, serialised, blocks until done
account.deposit(50)
account.withdraw(30)
assert account.getBalance() == 120.0
Methods without @ActiveMethod run on the caller’s thread as normal.
7.1. Blocking vs non-blocking
By default, @ActiveMethod calls block until the actor processes
them. For non-blocking calls, set blocking = false:
@ActiveObject
class Service {
@ActiveMethod(blocking = false)
def compute(int x) { x * x }
}
def svc = new Service()
def result = svc.compute(7) // returns Awaitable immediately
assert await(result) == 49
7.2. Thread safety by annotation
The key benefit: you write normal-looking classes with normal methods.
Thread safety is guaranteed by the annotation — all @ActiveMethod
calls are serialised through the actor. No locks, no concurrent
collections, no race conditions.
This also makes the code highly readable for AI tools — the
@ActiveObject annotation explicitly declares the concurrency model,
and each @ActiveMethod contains pure business logic without
messaging boilerplate.