Actors, Agents & Active Objects
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26
(checked on 2026-10-14). The classes live in the package groovy.concurrent, which is part of the Groovy 6.0 distribution and
is incubating in that release, so details may still change. Actors and agents replace shared state protected by locks with
message passing: you send messages to an actor or agent, and it processes them one at a time on a dedicated thread, so there is
no data race by design. They run on virtual threads when the JDK is 21 or later. Two imports are worth knowing: the classes
below are in groovy.concurrent and must be imported (only the async and await keywords are available without an import),
and the annotations are in groovy.transform. Companion pages are
Async/Await (the await used throughout),
Generators, Channels & Structured Concurrency
and Parallel Collections & Dataflow. For the same ideas
elsewhere, see Java High-Level Concurrency
and Kotlin Coroutines Basics.
Agent: a thread-safe value
An Agent wraps one value. Any thread can read it, but it changes only through update functions that the agent applies one
after another. It suits counters, caches and accumulators. The first example counts waterings, keeps a small stock map, and
then hammers an agent from eight concurrent writers with no lock anywhere.
import groovy.concurrent.Agent
import groovy.concurrent.Awaitable
def waterings = Agent.create(0)
3.times { waterings.send { it + 1 } }
println "snapshot (may lag): ${waterings.get() <= 3}"
println "consistent: ${await(waterings.getAsync())}"
println "sendAndGet: ${await(waterings.sendAndGet { it * 10 })}"
def stock = Agent.create([:])
stock.send { state -> state + [seeds: 10] }
stock.send { state -> state + [fertiliser: 5] }
stock.send { state -> state.collectEntries { k, v -> [k, v * 2] } }
println await(stock.getAsync())
// many writers, no lock
def tally = Agent.create(0)
def writers = (1..8).collect { async { 1000.times { tally.send { it + 1 } } } }
await(Awaitable.all(*writers))
println "tally: ${await(tally.getAsync())}"
tally.shutdown(); waterings.shutdown(); stock.shutdown()
snapshot (may lag): true
consistent: 3
sendAndGet: 30
[seeds:20, fertiliser:10]
tally: 8000
-
get()is a snapshot read: it returns immediately with the current value, which may not yet include updates still queued. -
getAsync()is queued behind the pending updates, soawait(agent.getAsync())is a consistent read. -
send { }is fire-and-forget.sendAndGet { }queues the update and returns an awaitable of the new value. -
The value can be any type. Treat it as immutable and return a new value from each update function, as the stock example does with
state + [...].
Source: Groovy documentation: Agent
Observing state changes
changes() returns a java.util.concurrent.Flow.Publisher of the new values after each update. The stream is hot (it does not
replay earlier states), is buffered per subscriber, and completes when shutdown() is called. The simplest consumer is
for await, covered on
Generators, Channels & Structured Concurrency.
import groovy.concurrent.Agent
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Flow
def counter = Agent.create(0)
def subscribed = new CountDownLatch(1)
// release the producer only once for await has actually subscribed
def source = counter.changes()
Flow.Publisher<Integer> changes = { Flow.Subscriber s ->
source.subscribe(s)
subscribed.countDown()
} as Flow.Publisher
async {
subscribed.await()
3.times { counter.send { it + 1 } }
Thread.sleep(50)
counter.shutdown()
}
def seen = []
for await (v in changes) {
seen << v
}
println seen
[1, 2, 3]
Because the stream is hot, for await subscribes only when the loop starts, and the updates are sent from another thread,
the example holds the producer back with a latch until the subscription exists. Without it the three updates can all be
applied before the loop subscribes, and the program prints [].
A slow subscriber never slows the agent: its buffer (256 values by default) fills, and then the newest value may be skipped
instead of applying back-pressure. Calling changes() after shutdown() returns a publisher that is already completed.
Actors
An Actor has a mailbox and a handler. Messages are processed one at a time in arrival order, and sendAndGet returns an
awaitable of the handler’s result. Two factories cover the common shapes.
Reactor: a stateless actor
A reactor applies a function to each message, and the return value is the reply. It fits validators, transformers and calculators.
import groovy.concurrent.Actor
def doubler = Actor.reactor { it * 2 }
println await(doubler.sendAndGet(5))
println await(doubler.sendAndGet(21))
doubler.stop()
def classify = Actor.reactor { msg -> msg instanceof Number && msg > 30 ? 'hot' : 'ok' }
println([20, 31.5].collect { await(classify.sendAndGet(it)) })
classify.stop()
10
42
[ok, hot]
Stateful: state across messages
A stateful actor starts from an initial state; the handler receives the state and the message and returns the new state, which
is also the reply to sendAndGet. To read the state, send a message that the handler ignores.
import groovy.concurrent.Actor
def valve = Actor.stateful('closed') { state, msg ->
switch (msg) {
case 'open': return 'open'
case 'close': return 'closed'
default: return state
}
}
valve.send('open')
println "state: ${await(valve.sendAndGet('query'))}"
valve.send('close')
println "state: ${await(valve.sendAndGet('query'))}"
valve.stop()
state: open
state: closed
Typed message dispatch
Because a handler is ordinary Groovy, the switch statement gives you a message protocol: match on a class, a map shape or a
closure (see Control Structures). Here a tank accepts fill and
drain messages and refuses to go below zero.
import groovy.concurrent.Actor
def tank = Actor.stateful(0.0) { level, msg ->
switch (msg) {
case { it instanceof Map && it.fill }:
return level + msg.fill
case { it instanceof Map && it.drain }:
if (msg.drain > level) throw new RuntimeException('Not enough water')
return level - msg.drain
default:
return level
}
}
tank.send([fill: 100])
tank.send([drain: 30])
println "level: ${await(tank.sendAndGet([fill: 0]))}"
try {
await(tank.sendAndGet([drain: 500]))
} catch (RuntimeException e) {
println "caught: ${e.message}"
}
println "level after failure: ${await(tank.sendAndGet('query'))}"
tank.stop()
level: 70.0
caught: Not enough water
level after failure: 70.0
The failed drain reaches the caller as an exception from await, and the actor keeps its previous state and carries on with
the next message.
Source: Groovy documentation: Actor
Construction options
An optional ActorOptions configures the mailbox, the executor and a few opt-in behaviours. It is a value object: each
with... method returns a new configuration, starting from ActorOptions.DEFAULTS, which you pass as the last argument to
Actor.reactor or Actor.stateful.
-
withBoundedMailbox(capacity, policy)limits the mailbox. The default is unbounded, which lets a fast producer exhaust memory. The overflow policies (ActorOptions.Overflow) areBLOCK(the sender waits for room),FAIL(sendthrowsIllegalStateException) andDROP_NEWEST(the new message is discarded, and asendAndGetreply is bound to anIllegalStateException). A handler that sends to its own fullBLOCKmailbox would deadlock, so the actor detects it and fails withIllegalStateExceptioninstead. -
withExecutor(executor)gives the actor its own executor for workload isolation. The default is a shared executor that uses virtual threads on JDK 21 and later. -
withStashBoundandwithCurrentSelfare further opt-in behaviours;withStashBoundis shown with the state-machine features below.
import groovy.concurrent.Actor
import groovy.concurrent.ActorOptions
import groovy.concurrent.ActorOptions.Overflow
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
// FAIL: send throws when the mailbox is full (the handler is parked on a latch)
def gate = new CountDownLatch(1)
def failing = Actor.reactor({ gate.await(); it },
ActorOptions.DEFAULTS.withBoundedMailbox(2, Overflow.FAIL))
def accepted = 0
def failure = null
try {
10.times { failing.send(it); accepted++ }
} catch (IllegalStateException e) {
failure = e.class.simpleName
}
println "overflow raised: $failure after a handful of sends: ${accepted in 2..4}"
gate.countDown()
failing.stop()
// DROP_NEWEST: the dropped message's reply is bound to an IllegalStateException
def gate2 = new CountDownLatch(1)
def dropping = Actor.reactor({ gate2.await(); it },
ActorOptions.DEFAULTS.withBoundedMailbox(1, Overflow.DROP_NEWEST))
def replies = (1..6).collect { dropping.sendAndGet(it) }
gate2.countDown()
def outcomes = replies.collect { r -> try { await(r) } catch (IllegalStateException e) { 'dropped' } }
println "some replies dropped: ${outcomes.count('dropped') > 0}"
dropping.stop()
// per-actor executor
def pool = Executors.newSingleThreadExecutor { r -> new Thread(r, 'greenhouse-actor') }
def named = Actor.reactor({ Thread.currentThread().name }, ActorOptions.DEFAULTS.withExecutor(pool))
println await(named.sendAndGet('who'))
named.stop()
pool.shutdown()
overflow raised: IllegalStateException after a handful of sends: true
some replies dropped: true
greenhouse-actor
The exact number of messages accepted before the overflow depends on how quickly the actor takes the first message out of the mailbox, which is why the example prints a range check rather than a count.
Lifecycle and errors
An actor has a three-state lifecycle that isActive() and isTerminated() report. While active it accepts and processes
messages. After stop() it is no longer active and accepts no new sends, but it may still be draining messages already queued.
Once the worker has exited and every queued message is processed, it is terminated. Actors are AutoCloseable, so
withCloseable (or try-with-resources in Java) stops them automatically. Agents have shutdown() instead.
An exception in a handler is delivered to sendAndGet callers through the awaited reply. A fire-and-forget send has no reply to
carry the exception, so register an onError callback to see those failures; the context-aware overload receives an
ActorContext first, which lets the callback react, for example by stopping the actor. An exception thrown inside the callback
is caught and discarded so it cannot disturb the actor’s loop.
import groovy.concurrent.Actor
def actor = Actor.reactor { it }
println "active=${actor.isActive()} terminated=${actor.isTerminated()}"
actor.stop()
println "after stop: active=${actor.isActive()}"
while (!actor.isTerminated()) Thread.sleep(10)
println "terminated=${actor.isTerminated()}"
Actor.reactor { it * 2 }.withCloseable { a ->
println await(a.sendAndGet(5))
}
def risky = Actor.reactor { throw new RuntimeException('oops') }
try {
await(risky.sendAndGet('anything'))
} catch (RuntimeException e) {
println "reply failed: ${e.message}"
}
risky.stop()
def errors = new java.util.concurrent.LinkedBlockingQueue()
def logged = Actor.reactor { msg -> if (msg == 'bad') throw new IllegalArgumentException('bad reading'); msg }
.onError { Throwable t, msg -> errors << "${t.message} on '$msg'" }
logged.send('bad')
println errors.poll(2, java.util.concurrent.TimeUnit.SECONDS)
println "still alive: ${await(logged.sendAndGet('ok'))}"
logged.stop()
def stopper = Actor.reactor { msg -> throw new IllegalStateException('fatal') }
stopper.onError { ctx, t, msg -> ctx.self().stop() }
stopper.send('x')
while (!stopper.isTerminated()) Thread.sleep(10)
println "stopped itself: ${stopper.isTerminated()}"
active=true terminated=false
after stop: active=false
terminated=true
10
reply failed: oops
bad reading on 'bad'
still alive: ok
stopped itself: true
Source: Groovy documentation: Lifecycle and Error handling
FSM-style actors
A stateful actor models a single behaviour. Multi-phase protocols, where the actor reacts differently depending on its phase,
need three more capabilities, all on the ActorContext that the handler receives as its first parameter. They are available
through the context-aware factory overloads, which take a StatefulHandler (a closure coerced with as StatefulHandler).
-
ctx.become(handler)swaps the active handler for the next message. The state is kept. -
ctx.stash()defers the current message, andctx.unstashAll()replays all deferred messages, in order, at the head of the mailbox. Use them for messages that arrive in the wrong phase. -
ctx.scheduleOnce(msg, delay)andctx.scheduleAtFixedRate(msg, initialDelay, interval)send a message to the actor itself later;scheduleOncereturns aCancellable.stop()cancels every timer the actor still has outstanding.
The first example shows become alone: the state survives the swap and the new behaviour applies from the next message.
import groovy.concurrent.Actor
import groovy.concurrent.ActorContext
import groovy.concurrent.StatefulHandler
// become: state is kept, behaviour changes
StatefulHandler phaseB = { ctx, s, m -> s + 10 } as StatefulHandler
def swap = Actor.stateful(0, { ActorContext ctx, int s, m ->
if (m == 'swap') { ctx.become(phaseB); return s }
s + 1
} as StatefulHandler)
println([await(swap.sendAndGet('inc')), await(swap.sendAndGet('swap')), await(swap.sendAndGet('inc'))])
swap.stop()
[1, 1, 11]
A greenhouse pump controller makes a fuller state machine. It starts idle and must be primed before it runs, and watering commands that arrive too early are stashed and replayed in order once the pump is running.
import groovy.concurrent.Actor
import groovy.concurrent.ActorContext
import groovy.concurrent.StatefulHandler
import java.time.Duration
// A pump controller: idle -> priming -> running, with a priming timeout.
StatefulHandler idle, priming, running
idle = { ActorContext ctx, Map s, m ->
if (m == 'start') {
ctx.become(priming)
ctx.scheduleOnce('prime-timeout', Duration.ofSeconds(30)) // never fires here: stopped first
return s
}
ctx.stash() // 'water' before 'start' waits
s
} as StatefulHandler
priming = { ActorContext ctx, Map s, m ->
if (m == 'primed') {
ctx.become(running)
ctx.unstashAll() // replay what arrived while priming
return s
}
if (m == 'prime-timeout') { ctx.become(idle); return s }
ctx.stash()
s
} as StatefulHandler
running = { ActorContext ctx, Map s, m ->
if (m == 'stop-pump') { ctx.become(idle); return s }
s + [cycles: s.cycles + 1, last: m]
} as StatefulHandler
def pump = Actor.stateful([cycles: 0, last: null], idle)
def early = pump.sendAndGet('water-tomatoes') // stashed: the pump is idle
pump.send('start')
def second = pump.sendAndGet('water-basil') // stashed: still priming
pump.send('primed') // -> running, replays both in order
println await(early)
println await(second)
println await(pump.sendAndGet('water-beds'))
pump.stop()
[cycles:1, last:water-tomatoes]
[cycles:2, last:water-basil]
[cycles:3, last:water-beds]
The two replies that were stashed are answered only after primed arrives, in the order they were sent, and the state
(cycles) carries on counting across the phase changes. Reading the first reply is what shows the stash worked: it could not
have been computed while the pump was idle.
Scheduled self-sends drive timeouts and periodic work. The next example uses short delays so it finishes quickly; in a real controller they would be seconds.
import groovy.concurrent.Actor
import groovy.concurrent.ActorContext
import groovy.concurrent.StatefulHandler
import java.time.Duration
// Scheduled self-sends: a recurring tick and a one-shot timeout
def ticks = Actor.stateful(0, { ActorContext ctx, int s, m ->
if (m == 'start') {
ctx.scheduleAtFixedRate('tick', Duration.ofMillis(10), Duration.ofMillis(20))
return s
}
if (m == 'tick') return s + 1
s
} as StatefulHandler)
ticks.send('start')
Thread.sleep(200)
println "ticked at least 3 times: ${await(ticks.sendAndGet('query')) >= 3}"
ticks.stop() // cancels the outstanding timer
def timeouts = Actor.stateful('waiting', { ActorContext ctx, String s, m ->
if (m == 'arm') { ctx.scheduleOnce('timeout', Duration.ofMillis(30)); return 'armed' }
if (m == 'timeout') return 'timed-out'
s
} as StatefulHandler)
timeouts.send('arm')
Thread.sleep(150)
println await(timeouts.sendAndGet('query'))
timeouts.stop()
ticked at least 3 times: true
timed-out
The stash is unbounded by default. An actor that stashes messages from a source you do not control (network input, external
clients) could grow its stash without limit and exhaust the heap if the phase change that calls unstashAll() never comes, so
set a bound at construction with withStashBound(limit, policy). The policies in ActorOptions.StashOverflow are FAIL
(stash() throws), DROP_OLDEST (evict the oldest stashed message and bind its reply to an error) and REJECT (bind the
current message’s reply to an error and do not stash it).
import groovy.concurrent.Actor
import groovy.concurrent.ActorContext
import groovy.concurrent.ActorOptions
import groovy.concurrent.StatefulHandler
def options = ActorOptions.DEFAULTS.withStashBound(2, ActorOptions.StashOverflow.REJECT)
def waiting = Actor.stateful('init', { ActorContext ctx, s, m -> ctx.stash(); s } as StatefulHandler, options)
def replies = (1..4).collect { waiting.sendAndGet("m$it") }
println replies[2..3].collect { try { await(it) } catch (e) { e.class.simpleName } }
waiting.stop()
[IllegalStateException, IllegalStateException]
The third and fourth messages did not fit in a stash of two, so their replies failed immediately; the first two stay stashed.
Choosing between Agent and Actor
| Aspect | Agent | Actor |
|---|---|---|
State |
One value, changed by update functions |
Anything the handler keeps |
Messages |
Update functions only |
Any message type, dispatched in the handler |
Reply |
|
|
Multi-phase behaviour |
Not supported |
|
Typical use |
Counters, caches, accumulators |
State machines, services, typed protocols |
Both guarantee that messages are handled one at a time, so neither needs a lock. If a single value and a handful of update functions are enough, use an agent; once you need different behaviour per phase or a message vocabulary, use an actor.
Active objects
@ActiveObject and @ActiveMethod give you the same guarantee in an ordinary class. The AST transformation routes every
@ActiveMethod call through an internal actor, so callers see a normal object while the calls are serialized. Methods without
@ActiveMethod run on the caller’s thread as usual. By default a call blocks until the actor has run it; with
@ActiveMethod(blocking = false) the call returns an Awaitable at once. For other AST transformations see
AST Transformations: Logging, Safety, Concurrency & Directives.
import groovy.transform.ActiveObject
import groovy.transform.ActiveMethod
@ActiveObject
class WaterTank {
private double level = 0
@ActiveMethod
void fill(double litres) { level += litres }
@ActiveMethod
void drain(double litres) {
if (litres > level) throw new IllegalStateException('Not enough water')
level -= litres
}
@ActiveMethod
double getLevel() { level }
int describeCalls() { 42 } // plain method: runs on the caller's thread
}
def tank = new WaterTank()
def workers = (1..4).collect { Thread.start { 100.times { tank.fill(1) } } }
workers*.join()
println "level: ${tank.getLevel()}"
try { tank.drain(1000) } catch (IllegalStateException e) { println "drain refused: ${e.message}" }
println "level: ${tank.getLevel()}"
println tank.describeCalls()
@ActiveObject
class Sensor {
@ActiveMethod(blocking = false)
def read(int zone) { zone * zone + 0.5 }
}
def s = new Sensor()
def pending = s.read(7)
println pending instanceof groovy.concurrent.Awaitable
println await(pending)
level: 400.0
drain refused: Not enough water
level: 400.0
42
true
49.5
Four threads each added 100 litres with no locking in WaterTank, and the total is exactly 400, because the unsynchronised
level += litres always ran inside the actor. An exception in a blocking @ActiveMethod reaches the caller. The annotation
also documents the concurrency model, which makes such classes easy to review: each method contains only business logic, with
no messaging code.
The groovy-concurrent-java module
Java programs can use the same API without the Groovy runtime. The module org.apache.groovy:groovy-concurrent-java is a
filtered subset of the Groovy classes: structured concurrency (AsyncScope, Awaitable), pools (Pool, ParallelScope,
ConcurrentConfig), actors, agents, and dataflow (DataflowVariable, AsyncChannel, BroadcastChannel, ChannelSelect). It
must not be put on the classpath together with the full groovy jar, which already contains these classes; Gradle enforces the
exclusion through a shared capability, and a runtime warning is logged if both jars are found. In Gradle it is
implementation 'org.apache.groovy:groovy-concurrent-java:6.0.0', and in Maven a dependency with the group
org.apache.groovy, the artifact groovy-concurrent-java and the version 6.0.0.
Java callers use the java.util.function interfaces and call AsyncSupport.await explicitly instead of the await keyword.
This program was compiled with javac against the 6.0.0 jar from Maven Central and run on JDK 26:
import groovy.concurrent.Actor;
import groovy.concurrent.Agent;
import groovy.concurrent.DataflowVariable;
import groovy.concurrent.Awaitable;
import org.apache.groovy.runtime.async.AsyncSupport;
public class Greenhouse {
public static void main(String[] args) throws Exception {
var doubler = Actor.<Integer, Integer>reactor(n -> n * 2);
int doubled = AsyncSupport.await(doubler.sendAndGet(21));
System.out.println("reactor: " + doubled);
doubler.stop();
var waterings = Actor.<String, Integer>stateful(0, (state, msg) -> "water".equals(msg) ? state + 1 : state);
waterings.send("water");
waterings.send("water");
int count = AsyncSupport.await(waterings.sendAndGet("water"));
System.out.println("stateful: " + count);
waterings.stop();
var level = Agent.create(0);
level.send(n -> n + 5);
level.send(n -> n + 5);
int total = AsyncSupport.await(level.getAsync());
System.out.println("agent: " + total);
level.shutdown();
var x = new DataflowVariable<Integer>();
var y = new DataflowVariable<Integer>();
var z = Awaitable.<Integer>go(() -> AsyncSupport.await(x) + AsyncSupport.await(y));
AsyncSupport.getExecutor().execute(() -> x.bind(10));
AsyncSupport.getExecutor().execute(() -> y.bind(5));
int sum = AsyncSupport.await(z);
System.out.println("dataflow: " + sum);
}
}
reactor: 42
stateful: 3
agent: 10
dataflow: 15
Several features need the Groovy compiler or dynamic dispatch and are therefore Groovy-only: the Dataflows class (it relies
on propertyMissing), the async/await, defer and yield return syntax and for await, @ActiveObject and
@ActiveMethod, the parallel collection methods (they are extension methods on Collection), and closure-taking overloads such
as scope.async { }. Java code reaches the same behaviour through the underlying types. How a Java application embeds
Groovy at all is on
Integrating Groovy into Java Applications.
Migrating from GPars
Before Groovy 6.0, actors, agents, dataflow and parallel collections came from the separate GPars library (package
groovyx.gpars). The Groovy 6.0 release notes say that previous GPars users will find much of its functionality in
groovy.concurrent, aligned with virtual threads and structured concurrency. The mapping below is a starting point based on
general knowledge of the GPars API; it was not run against GPars, so check each row against the GPars documentation before
relying on it.
| GPars | Groovy 6.0 groovy.concurrent |
|---|---|
|
|
|
The same method names on |
|
The same class names in |
|
|
Actors written with |
|
|
|
Dataflow operators, queues, CSP-style channels, software transactional memory |
Not documented in |
Programs that stay on a Groovy 4 or 5 line keep using GPars; the new package is for Groovy 6.0 and later.
Where the book is outdated
The Manning book Groovy in Action (publisher page in the references) was written for Groovy 1.0, long before this API
existed. Its coverage of concurrency stops at threads and the java.util.concurrent classes. Check these points against the
current documentation:
-
Actors, agents and active objects are now in the Groovy 6.0 distribution as
groovy.concurrent, incubating in 6.0, with no GPars dependency. -
They use virtual threads when the JDK is 21 or later. Groovy 6.0 needs JDK 17 or later; on JDK 17 to 20 a regular thread pool is used.
-
A Java-only variant of the API,
groovy-concurrent-java, is published for projects without the Groovy runtime.