Skip to content
AgileLogics
7 min read

Concurrency

Prerequisites: Closures in depth, Using Java libraries

Groovy runs on the JVM, so every Java concurrency tool works — and closures make them concise. Groovy 6 adds its own async/await on top (currently marked incubating, meaning the API may still change).

Threads and executors

import java.util.concurrent.*

def pool = Executors.newFixedThreadPool(4)
def futures = (1..5).collect { n ->
    pool.submit({ -> Thread.sleep(50); n * n } as Callable)
}
println futures*.get()
pool.shutdown()
println pool.awaitTermination(5, TimeUnit.SECONDS)
[1, 4, 9, 16, 25]
true

submit returns a Future; get() waits for the result. Results come back in submission order even though the tasks ran in parallel.

Virtual threads (Java 21+)

import java.util.concurrent.Executors

def start = System.currentTimeMillis()
def results = Executors.newVirtualThreadPerTaskExecutor().withCloseable { ex ->
    (1..1000).collect { i -> ex.submit { Thread.sleep(100); i } }*.get()
}
def seconds = (System.currentTimeMillis() - start) / 1000
println "${results.size()} tasks, sum ${results.sum()}"
println "finished in well under 100 s: ${seconds < 10}"
1000 tasks, sum 500500
finished in well under 100 s: true

A thousand tasks that each sleep for 100 ms finish together in a fraction of a second, because virtual threads are cheap enough to create one per task. They're ideal for I/O-bound work like HTTP calls and database queries.

CompletableFuture

import java.util.concurrent.CompletableFuture

def price = CompletableFuture.supplyAsync { 100 }
def rate = CompletableFuture.supplyAsync { 0.2 }
def total = price.thenCombine(rate) { p, r -> p * (1 + r) }
println total.get()

def failing = CompletableFuture.supplyAsync { throw new IllegalStateException('service down') }
println failing.exceptionally { e -> "fallback (${e.cause.message})" }.get()
120.0
fallback (service down)

async and await (Groovy 6)

def fetch(String name, long ms) { Thread.sleep(ms); "$name data" }

def users = async { fetch('users', 200) }
def orders = async { fetch('orders', 100) }
def (u, o) = await(users, orders)
println "$u + $o"

def total = await async {
    def a = await async { 20 }
    def b = await async { 22 }
    a + b
}
println total

def fibonacci = async {
    long a = 0, b = 1
    while (true) {
        yield return a
        (a, b) = [b, a + b]
    }
}
println fibonacci.take(10).collect()
users data + orders data
42
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
  • async { } starts a task (on a virtual thread on Java 21+).
  • await waits for one or several tasks and returns their results — the code reads top to bottom, with ordinary try/catch.
  • yield return inside async makes a lazy generator.

Shared state

import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.ConcurrentHashMap

def counter = new AtomicInteger()
def unsafe = 0
def words = new ConcurrentHashMap<String, Integer>()

def threads = (1..8).collect { t ->
    Thread.start {
        1000.times {
            counter.incrementAndGet()
            unsafe++
            words.merge(t % 2 ? 'odd' : 'even', 1, Integer::sum)
        }
    }
}
threads*.join()
println counter.get()
println words.sort()
// unsafe is often less than 8000: concurrent ++ loses updates
8000
[even:4000, odd:4000]

The atomic counter and concurrent map are always correct. The plain variable unsafe may come out right on one run and lose updates on the next — ++ isn't atomic, even though closures make sharing it easy. Share immutable data where you can, and use java.util.concurrent types for anything mutable.

Parallel collections

Groovy 6 also adds parallel versions of collection methods for CPU-bound work, such as collectParallel, findAllParallel, and eachParallel, plus agents, actors, and dataflow variables in the new groovy.concurrent package (the successor to the retired GPars library).