GEP-18
Abstract
This GEP describes the integration of concurrency, parallelism, dataflow,
and actor-based programming into Groovy core, drawing from GPars' proven
patterns and modernising them for virtual threads, structured concurrency,
and the async/await language features introduced in Groovy 6.
The work delivers a comprehensive concurrent programming toolkit directly
in Groovy core, with a pure-Java subset published as a standalone module
(groovy-concurrent-java) for use by Java, Kotlin, and other JVM language
users without requiring the Groovy runtime.
Motivation
GPars has served the Groovy community as the primary concurrency library for over a decade, providing parallel collections, dataflow variables, actors, agents, and more. However, GPars is a separate library with its own release cycle, and modern JDK developments (virtual threads, structured task scope, parallel streams) have shifted the concurrency landscape.
Groovy 6 introduced native async/await support (GEP-16), providing
language-level concurrency primitives. This GEP extends that foundation
with the higher-level patterns that GPars users rely on, integrated
directly into Groovy core so they are available out of the box.
Design principles
-
Pure-Java API surface — all types in
groovy.concurrentusejava.util.functiontypes (Supplier,Function,Predicate, etc.) rather thangroovy.lang.Closure. Groovy closures work seamlessly via SAM coercion. This enables a Java-only extraction. -
No extension classes needed — SAM coercion eliminates the need for Closure-bridging extension methods for the public API.
-
Virtual-thread-first — on JDK 21+, the default executor uses virtual threads. All abstractions (actors, agents, pools) scale to millions of concurrent entities without pool tuning.
-
Structured concurrency — scopes ensure child tasks complete before the scope exits. Nesting, cancellation propagation, and timeouts are built in.
-
GPars migration path — familiar patterns (
withPool,Agent,DataflowVariable,Dataflows,@ActiveObject) are preserved with minimal API changes.
Features
Pool and ParallelScope
Pool is a managed thread pool extending Executor and AutoCloseable:
def pool = Pool.cpu() // ForkJoinPool sized to available processors
def pool = Pool.fixed(8) // Fixed-size ForkJoinPool
def pool = Pool.io() // Virtual threads on JDK 21+
def pool = Pool.virtual() // Virtual-thread-per-task
Pool.cpu() and Pool.fixed() create ForkJoinPool-backed pools,
enabling parallel stream isolation for CPU-bound work.
Pool.io() and Pool.virtual() use virtual threads for I/O-bound work.
ParallelScope binds a pool for scoped execution:
ParallelScope.withPool(Pool.cpu()) { scope ->
def a = scope.async { cpuWork1() }
def b = scope.async { cpuWork2() }
[await(a), await(b)]
}
Pool.current() tracks the active pool via ScopedValue on JDK 25+
(with ThreadLocal fallback), so parallel collection methods
automatically use the correct pool.
ConcurrentConfig provides global defaults via system properties
(groovy.concurrent.poolsize, groovy.concurrent.virtual) or
programmatic override.
Scope nesting and timeout
AsyncScope supports parent-child relationships. Cancelling a parent
scope propagates to all child scopes:
AsyncScope.withScope { outer ->
outer.async {
AsyncScope.withScope { inner ->
assert inner.parent == outer
inner.async { work() }
}
}
}
Scopes support timeouts:
AsyncScope.withScope(Duration.ofSeconds(5)) { scope ->
scope.async { longRunningWork() }
// Cancelled automatically if not complete within 5 seconds
}
Parallel collections
Seventeen parallel methods are added to Collection, backed by Java
parallel streams with pool isolation:
| Category | Methods |
|---|---|
Transformation |
|
Filtering |
|
Iteration |
|
Predicates |
|
Aggregation |
|
All methods use java.util.function types. Within a ParallelScope.withPool
block, operations automatically use the bound pool:
assert ParallelScope.withPool(Pool.cpu()) { scope ->
(1..15).collectParallel { it * 2 } // [2, 4, 6, ..., 30]
.findAllParallel { it % 3 == 0 } // [6, 12, 18, 24, 30]
.groupByParallel { it.toString().startsWith('1') }
} == [(false):[6, 24, 30], (true):[12, 18]]
The @Parallel annotation provides a convenient shorthand for
parallel for loops with structured completion:
@Parallel
for (item in bigList) {
process(item)
}
// All iterations complete before this line
Agent
Agent provides thread-safe mutable state through serialised update
functions, inspired by GPars' Agent and Clojure agents:
def counter = Agent.create(0)
counter.send { it + 1 }
counter.send { it + 1 }
assert 2 == await counter.getAsync()
Updates are queued and applied one at a time on a dedicated thread.
get() returns a non-blocking snapshot; getAsync() returns an
Awaitable that completes after pending updates.
Actor
Actor provides message-passing concurrency with two factory methods:
// Reactor — stateless, each message produces a reply
def doubler = Actor.reactor { it * 2 }
assert 42 == await doubler.sendAndGet(21)
// Stateful — maintains state across messages
def counter = Actor.stateful(0) { state, msg ->
switch (msg) {
case 'increment': return state + 1
case 'decrement': return state - 1
default: return state
}
}
counter.send('increment')
assert 2 == await counter.sendAndGet('increment')
Each actor has a dedicated thread processing messages sequentially. On JDK 21+, actors use virtual threads — millions of actors are feasible without pool tuning.
@ActiveObject / @ActiveMethod
For annotation-driven thread safety, @ActiveObject routes annotated
method calls through an internal actor:
@ActiveObject
class Account {
private double balance = 0
@ActiveMethod
void deposit(double amount) { balance += amount }
@ActiveMethod
void withdraw(double amount) { balance -= amount }
@ActiveMethod
double getBalance() { balance }
}
def account = new Account()
account.deposit(100)
account.withdraw(30)
assert account.balance == 70.0
All @ActiveMethod calls are serialised — no locks needed. Methods
without the annotation run on the caller’s thread as normal.
@ActiveMethod(blocking = false) returns an Awaitable immediately.
DataflowVariable and Dataflows
DataflowVariable is a single-assignment variable that blocks readers
until a value is bound:
def x = new DataflowVariable()
def y = new DataflowVariable()
def z = new DataflowVariable()
async { z << await(x) + await(y) }
async { x << 10 }
async { y << 5 }
assert await(z) == 15
Dataflows provides dynamic property-based access:
def df = new Dataflows()
async { df.z = df.x + df.y }
async { df.x = 10 }
async { df.y = 5 }
assert df.z == 15
Both integrate natively with Awaitable and the async/await keywords.
Channel composition
AsyncChannel supports composable pipeline operations:
def source = AsyncChannel.create(10)
def pipeline = source
.filter { it > 3 }
.map { it * 10 }
async {
(1..5).each { source.send(it) }
source.close()
}
def results = []
for (val in pipeline) { results << val }
assert results == [40, 50]
Additional operations: merge (interleave two channels), split
(partition by predicate), tap (fork a monitoring copy).
ChannelSelect waits for the first available value from multiple
channels:
def sel = ChannelSelect.from(prices, alerts)
def result = await sel.select()
println "Channel ${result.index}: ${result.value}"
BroadcastChannel delivers each value to all subscribers (one-to-many):
def broadcast = BroadcastChannel.create()
def sub1 = broadcast.subscribe()
def sub2 = broadcast.subscribe()
async { broadcast.send('hello'); broadcast.close() }
for (msg in sub1) { println "Sub1: $msg" }
for (msg in sub2) { println "Sub2: $msg" }
Java-only module
The groovy-concurrent-java module publishes the pure-Java subset of
the concurrent API as a standalone dependency:
<dependency>
<groupId>org.apache.groovy</groupId>
<artifactId>groovy-concurrent-java</artifactId>
<version>6.0.0</version>
</dependency>
This module contains 27 classes with zero Groovy runtime dependency.
Java users get access to AsyncScope, Awaitable, Pool,
ParallelScope, Actor, Agent, DataflowVariable, AsyncChannel,
BroadcastChannel, ChannelSelect, and ConcurrentConfig.
Mutual exclusion with the full Groovy runtime is enforced via a shared
Gradle capability (org.apache.groovy:groovy-concurrent-api). A runtime
warning is logged if both jars are detected on the classpath (for Maven
users who lack Gradle’s capability mechanism). JPMS split-package
detection provides additional protection on the module path.
Features not available in the Java module include Dataflows (requires
Groovy’s propertyMissing), @ActiveObject/@ActiveMethod (Groovy AST
transform), parallel collection methods (registered as Groovy extension
methods), and the async/await/defer/yield return keywords
(Groovy compiler syntax).
Excluded and deferred features
The following GPars features are intentionally excluded or deferred:
| Feature | Status | Rationale |
|---|---|---|
|
Not planned |
Superseded by |
|
Not planned |
Legacy JSR-166 ParallelArray API; obsolete since Java 8 streams |
|
Not planned |
Only if sufficient subsequent user demand |
|
Deferred |
Explicit |
|
Deferred |
Only if sufficient subsequent user demand |
|
Deferred |
Rendezvous semantics primarily for testing; low demand expected |
Remote actors |
Deferred |
Requires networking dependencies (serialization, transport, discovery); belongs in a separate optional module |
|
Not planned |
Legacy blocking pattern; virtual threads and |
|
Not planned |
JDK’s |
|
Not planned |
Superseded by |
|
Not planned |
Superseded by |
STM (Software Transactional Memory) |
Not planned |
GPars' STM support depended on the Multiverse library which is no longer maintained; modern alternatives (agents, actors, virtual threads) provide better concurrency models |
JCSP (Communicating Sequential Processes) |
Not planned |
GPars' CSP support depended on the JCSP library; Groovy’s built-in |
Compatibility
GPars migration
| GPars | Groovy 6 |
|---|---|
|
|
|
Same method names on |
|
|
|
|
|
|
|
|
|
|
|
Same annotations (in |
|
|
|
|
References
-
GROOVY-9381: Async/Await (Groovy 6)