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

getAsync() / sendAndGet(fn) returns new value

sendAndGet(msg) returns handler result

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.