GEP-18


Metadata
Number

GEP-18

Title

Integrated Concurrency and Parallel Processing

Version

1

Type

Feature

Status

Draft

Leader

Paul King

Created

2026-04-15

Last modification

2026-04-15

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.concurrent use java.util.function types (Supplier, Function, Predicate, etc.) rather than groovy.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

collectParallel, collectManyParallel

Filtering

findAllParallel, findParallel, findAnyParallel, grepParallel, splitParallel

Iteration

eachParallel, eachWithIndexParallel

Predicates

anyParallel, everyParallel, countParallel

Aggregation

sumParallel, injectParallel, minParallel, maxParallel, groupByParallel

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

callAsync / asyncFun

Not planned

Superseded by async/await keywords

getParallel (PAWrapper)

Not planned

Legacy JSR-166 ParallelArray API; obsolete since Java 8 streams

makeConcurrent / makeSequential / asConcurrent

Not planned

Only if sufficient subsequent user demand

DataflowOperator / Pipeline

Deferred

Explicit async + channel receive covers the multi-input join use case; add if demand warrants declarative operator graphs

KanbanFlow

Deferred

Only if sufficient subsequent user demand

SyncDataflowVariable / SyncDataflowQueue

Deferred

Rendezvous semantics primarily for testing; low demand expected

Remote actors

Deferred

Requires networking dependencies (serialization, transport, discovery); belongs in a separate optional module

BlockingActor

Not planned

Legacy blocking pattern; virtual threads and async/await provide better alternatives

GPars fork/join (AbstractForkJoinWorker)

Not planned

JDK’s ForkJoinPool and RecursiveTask/RecursiveAction are the standard API

Dataflow.task { }

Not planned

Superseded by async { } keyword

Promise interface

Not planned

Superseded by Awaitable interface

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 AsyncChannel with composition provides equivalent channel-based communication

Compatibility

GPars migration

GPars Groovy 6

GParsPool.withPool(n) { }

ParallelScope.withPool(n) { }

eachParallel, collectParallel, etc.

Same method names on Collection

new Agent(initialValue)

Agent.create(initialValue)

new DataflowVariable()

new DataflowVariable()

new Dataflows()

new Dataflows()

Dataflow.task { }

async { }

reactor { }

Actor.reactor { }

@ActiveObject / @ActiveMethod

Same annotations (in groovy.transform)

promise.get()

await(awaitable)

whenAllBound(a, b, c, handler)

await(a, b, c)

References