1. Introduction
Groovy provides native async/await support, enabling developers to write
concurrent code in a sequential, readable style. Rather than dealing with
callbacks, CompletableFuture chains, or manual thread management, you express
concurrency with two constructs:
-
async { … }— start a closure on a background thread, returning anAwaitable -
await expr— block until an asynchronous result is available
On JDK 21+, async tasks automatically leverage virtual threads for optimal scalability. On JDK 17–20, a cached thread pool is used as a fallback.
The examples throughout this guide use an online multiplayer card game as a running theme — dealing hands, racing for the fastest play, streaming cards, and managing tournament rounds.
2. Getting Started
2.1. Your first async/await
async { … } starts work immediately on a background thread and returns an
Awaitable. Use await to collect the result:
def deck = ['2♠', '3♥', 'K♦', 'A♣']
def card = async { deck.shuffled()[0] }
println "You drew: ${await card}"
2.2. Exception handling
await unwraps CompletionException and ExecutionException automatically.
Standard try/catch works exactly as with synchronous code:
def drawFromEmpty = async {
throw new IllegalStateException('deck is empty')
}
try {
await drawFromEmpty
} catch (IllegalStateException e) {
// Original exception — no CompletionException wrapper
assert e.message == 'deck is empty'
}
2.3. CompletableFuture interop
await works directly with CompletableFuture, CompletionStage, and Future
from Java libraries:
// await works with CompletableFuture from Java libraries
def future = CompletableFuture.supplyAsync { 'A♠' }
assert await(future) == 'A♠'
3. Running Tasks in Parallel
The real power of async/await appears when you need to run several tasks concurrently and coordinate their results.
3.1. Waiting for all: Awaitable.all()
Deal cards to multiple players at the same time and wait for all hands:
// Deal cards to three players concurrently
def deck = [*1..9,'J','Q','K','A'].collectMany { rank ->
['♠','♥','♦','♣'].collect { suit -> "$rank$suit" }
}.shuffled()
def index = new AtomicInteger()
def draw5 = { -> def i = index.getAndAdd(5); deck[i..i+4] }
def alice = async { draw5() }
def bob = async { draw5() }
def carol = async { draw5() }
def (a, b, c) = await Awaitable.all(alice, bob, carol)
assert a.size() == 5 && b.size() == 5 && c.size() == 5
Multi-argument await is syntactic sugar for Awaitable.all():
def a = async { 1 }
def b = async { 2 }
def c = async { 3 }
// Parenthesized multi-arg await (sugar for Awaitable.all):
def results = await(a, b, c)
assert results == [1, 2, 3]
3.2. Racing: Awaitable.any()
Like JavaScript’s Promise.race() — settles as soon as the first task
settles, returning its result or propagating its error. A task that fails
first therefore fails the whole any, even if a later task would have
succeeded (use first if you want the first success instead). Useful for
fallback patterns or latency-sensitive code:
// Race two servers — use whichever responds first
def primary = async { Thread.sleep(200); 'primary-response' }
def fallback = async { 'fallback-response' }
def response = await Awaitable.any(primary, fallback)
assert response == 'fallback-response'
3.3. First success: Awaitable.first()
Like JavaScript’s Promise.any() — returns the first successful result,
silently ignoring individual failures. Only fails when every task fails:
// Try multiple sources — use the first that succeeds
def failing = async { throw new RuntimeException('server down') }
def succeeding = async { 'card-data-from-cache' }
def result = await Awaitable.first(failing, succeeding)
assert result == 'card-data-from-cache'
3.4. Inspecting all outcomes: Awaitable.allSettled()
Waits for all tasks to finish (succeed or fail) without throwing. Returns an
AwaitResult list where each entry has success, value, and error fields:
def save1 = async { 42 }
def save2 = async { throw new RuntimeException('db error') }
def results = await Awaitable.allSettled(save1, save2)
assert results[0].success && results[0].value == 42
assert !results[1].success && results[1].error.message == 'db error'
3.5. Combinator summary
| Combinator | Completes when | On failure | Use case |
|---|---|---|---|
|
All succeed |
Fails immediately on first failure (fail-fast) |
Gather results from independent tasks |
|
All complete (success or fail) |
Never throws; failures captured in |
Inspect every outcome, e.g. partial-success reporting |
|
First task settles (success or failure) |
Propagates the first settlement’s result or error (JS |
Latency-sensitive races, fastest-response wins |
|
First task succeeds, or all fail |
Throws only when every source fails (aggregate error) |
Hedged requests, graceful degradation with fallbacks |
all and allSettled accept an empty source list and complete with an
empty result. any and first require at least one source (and reject null
elements), throwing IllegalArgumentException otherwise — a "race" or "first
success" with no participants has no meaningful result.
|
4. Generators and Streaming
4.1. Producing values with yield return
An async closure containing yield return becomes a generator — it lazily
produces a sequence of values. The generator runs on a background thread and
blocks on each yield return until the consumer is ready, providing natural
back-pressure:
def dealCards = async {
def suits = ['♠', '♥', '♦', '♣']
def ranks = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
for (suit in suits) {
for (rank in ranks) {
yield return "$rank$suit"
}
}
}
def cards = dealCards.collect()
assert cards.size() == 52
assert cards.first() == 'A♠'
assert cards.last() == 'K♣'
Generators return a standard Iterable, so regular for loops and Groovy
collection methods (collect, findAll, take, etc.) work out of the box:
def scores = async {
for (s in [100, 250, 75]) { yield return s }
}
// Generators return Iterable — regular for and collect work
assert scores.collect { it * 2 } == [200, 500, 150]
5. Channels
Channels provide Go-style inter-task communication. A producer sends values into a channel; a consumer receives them as they arrive. The channel handles synchronization and optional buffering — no shared mutable state needed:
def cardStream = AsyncChannel.create(3)
// Dealer — sends cards concurrently
async {
for (card in ['A♠', 'K♥', 'Q♦', 'J♣']) {
await cardStream.send(card)
}
cardStream.close()
}
// Player — receives cards as they arrive
def hand = []
for await (card in cardStream) {
hand << card
}
assert hand == ['A♠', 'K♥', 'Q♦', 'J♣']
Channels support unbuffered (rendezvous, create()) and buffered (create(n))
modes. Sending blocks when the buffer is full; receiving blocks when empty.
With virtual threads, this blocking is essentially free.
Since channels implement Iterable, they also work with regular for loops
and Groovy collection methods.
6. Channel Composition
AsyncChannel supports composable pipeline operations. Each
composition method returns a new channel connected to the source
by a background task.
6.1. filter
def source = AsyncChannel.create(10)
def evens = source.filter { it % 2 == 0 }
async {
(1..10).each { source.send(it) }
source.close()
}
for await (val in evens) { println val } // 2, 4, 6, 8, 10
6.2. map
def doubled = source.map { it * 2 }
6.3. Chaining
Operations compose naturally:
def pipeline = source
.filter { it > 50 }
.map { it * 2 }
for await (val in pipeline) { process(val) }
6.4. merge
Combines two channels into one, interleaving values as they arrive:
def merged = channelA.merge(channelB)
for await (event in merged) { handle(event) }
6.5. split
Partitions a channel into two based on a predicate:
def (highPriority, normal) = orders.split { it.priority > 5 }
6.6. tap
Sends a copy of each value to a monitoring channel while passing through unchanged:
def monitor = AsyncChannel.create(100)
def output = source.tap(monitor)
// output gets all values; monitor gets copies for logging
7. Channel Select
ChannelSelect waits for the first available value from multiple
channels — like Go’s select statement:
import groovy.concurrent.ChannelSelect
def prices = AsyncChannel.create(10)
def alerts = AsyncChannel.create(10)
def sel = ChannelSelect.from(prices, alerts)
def result = await sel.select()
println "Channel ${result.index}: ${result.value}"
A select takes exactly one value from exactly one channel; the other
channels are left untouched. When several channels already have a value
waiting, the one listed first is taken. If every channel is closed and
drained, select() fails with ChannelClosedException.
That priority by list order means a channel that always has a value
waiting starves the channels listed after it. fair() returns a select
that rotates instead — each call starts at the channel after the one
that last won, like JCSP’s fairSelect — so keep the instance across
calls:
def sel = ChannelSelect.from(requests, control).fair()
while (true) {
def result = await sel.select()
// control is served even while requests never runs dry
}
random() chooses uniformly at random among the ready channels instead,
as Go’s select does. It keeps no state between calls, so it is equally
fair from any number of threads, but it gives no bound on how long a
ready channel may be passed over; fair() guarantees every ready channel
is taken within n calls.
7.1. Mixed offers
A select may also offer to send — the mixed choice of the CSP
literature: "I will send my opener, or take my peer’s if it sends
first". Offers are built with the static factories send and receive
and combined with offers, mirroring the send cases of Go’s select:
import static groovy.concurrent.ChannelSelect.*
def result = await offers(send(ping, 1), receive(pong)).select()
if (result.send) {
// my opener committed: continue the ping branch
} else {
// my peer opened first: result.value is the pong opener
}
Exactly one offer commits; a committed send behaves exactly like
ping.send(1), while a retired one leaves no trace — no buffered
residue, no waiting sender — just as a losing receive leaves its channel
untouched. fair() and random() order the scan over offers exactly as
they do over channels, and send offers require channels created by
AsyncChannel.create.
A send offer commits as soon as its channel can accept the value: when a receiver is waiting, or when buffer space holds it. Two peers racing a mixed choice against each other therefore agree on a single opener only over rendezvous (capacity-0) channels, where a send cannot complete unilaterally; over buffered channels with space, each peer’s send offer commits at once under its own select and the peers proceed down different branches of the same conversation.
7.2. Preconditions
Each branch may also carry a precondition — the guarded choice of CSP.
when writes the guard onto the offer itself, as occam, Ada, Erlang and
Kotlin do:
def sel = offers(receive(input).when { held.size() < capacity }, (1)
receive(request).when { !held.isEmpty() }) (2)
while (true) {
def result = await sel.select()
if (result.index == 0) {
held.addLast(result.value)
} else {
await reply.send(held.removeFirst())
}
}
| 1 | take input only while there is room |
| 2 | answer a request only while there is something to hand over |
A guarded-off offer is not registered on its channel, so nothing is taken
from it and nothing is sent to it, but it keeps its position, so
result.index denotes the same branch either way. Dropping an offer from
the argument list instead would renumber every branch after it, silently
changing what an index denotes.
The guard is a closure rather than a boolean because it is consulted
afresh on every select call — that is what lets one select be built
once and reused as the state it guards on changes. when returns a
guarded copy, leaving the original offer alone, and guards conjoin: over
a second when, both must hold.
select also accepts the same preconditions positionally, one flag per
offer, which suits a mask computed as a whole:
def result = await sel.select(held.size() < capacity, !held.isEmpty())
A flag is conjoined with whatever guard its offer carries — both must
hold for the offer to be registered — and passing a number of flags other
than the number of offers is an IllegalArgumentException. Enabling no
branch at all is a deadlock rather than a wait, so select fails with
IllegalStateException.
A branch can also be recognised by identity instead of by position:
result.channel is the channel the winning offer transferred over.
7.3. Timeouts
A deadline can be a branch of the select, as it is in Go, Rust, Clojure
and Kotlin, instead of an exception thrown around the whole select by
orTimeout. There are two forms, one for a timeout that re-arms on every
round and one for a fixed deadline.
The timer offer after arms a fresh timer each time select is called
and commits, with the Instant it fired, if no channel offer could
commit first. It is the "wait for work, but not forever" branch of a
select that is built once and reused, and because the timer lives inside
the call, the held instance keeps its fair() rotation from round to
round:
import static groovy.concurrent.ChannelSelect.*
def sel = offers(receive(work), after(100)).fair()
while (true) {
def result = await sel.select()
if (result.timeout) {
// nothing arrived within 100 ms of this call: result.value is the Instant
} else {
handle(result.value)
}
}
A timer offer that loses its round is cancelled with the other losing
branches, so it leaves nothing armed. It reports result.timeout rather
than a channel: result.channel is null for a timeout, and result.index
still names the branch. So a select that carries a timer offer and
dispatches on result.channel must test result.timeout first, as the
loop above does. Like any offer it can be guarded with when, and
a guarded-off timer offer arms nothing. Several timer offers give
per-branch deadlines.
A timer offer with a positive delay is never ready at the moment the
select registers its offers, so the choice policy considers only the
channel offers that are ready at once; the timer commits only when none
was. A delay that is not positive has already elapsed, so that offer is
ready at registration and the policy treats it like any other ready
branch: priority takes whichever is listed first, and fair() rotates
over it. Listed last, after(0) is the default clause of Go’s
select — take a transfer if one can complete at once, otherwise
proceed without waiting:
def result = await offers(receive(work), after(0)).select()
if (result.timeout) {
// nothing was ready; do something else and come back
}
The timer channel AsyncChannel.after is the fixed-deadline form: a
capacity-1 channel that delivers one value, the Instant it fired, once
the given delay has elapsed from its creation, and then closes, as Go’s
time.After does. Received from within a select, it is a deadline shared
by every round that selects on it:
def deadline = AsyncChannel.after(5000)
def sel = ChannelSelect.from(work, deadline)
while (true) {
def result = await sel.select()
if (result.index == 1) break // five seconds since the deadline was set
handle(result.value)
}
Because it is an ordinary channel, a timer channel composes with
everything a channel does: it can be guarded with when, and once fired
fair() and random() treat it like any other ready channel. A timer
channel that loses a select keeps its value: it fires all the same and
holds the instant until received. Once that value has been taken, further
receives fail with ChannelClosedException rather than waiting forever,
and isClosed() reports that the timer has fired. Closing a timer channel
before it fires cancels it.
Both forms accept a java.time.Duration in place of milliseconds, and in
both a delay that is not positive has already elapsed: the timer channel
is created already holding its instant, and the timer offer is ready at
registration. That suits a deadline computed from an absolute time that
has already passed.
8. BroadcastChannel
A BroadcastChannel delivers every value to all subscribers
(one-to-many), unlike AsyncChannel which is point-to-point:
import groovy.concurrent.BroadcastChannel
def broadcast = BroadcastChannel.create()
def sub1 = broadcast.subscribe()
def sub2 = broadcast.subscribe()
async {
broadcast.send('hello')
broadcast.send('world')
broadcast.close()
}
// Both subscribers receive both messages
for await (msg in sub1) { println "Sub1: $msg" }
for await (msg in sub2) { println "Sub2: $msg" }
Late subscribers only receive messages sent after they subscribe.
8.1. Flow.Publisher interop
A BroadcastChannel can also be exposed as a java.util.concurrent.Flow.Publisher
via asPublisher(). Each call to subscribe(Flow.Subscriber) on the returned
publisher creates a new per-subscriber AsyncChannel under the hood and respects
standard Reactive Streams demand (request(n)):
def broadcast = BroadcastChannel.create()
def publisher = broadcast.asPublisher()
// Compose with any Reactive Streams operator, or consume with `for await`:
for await (msg in publisher) {
println "Got: $msg"
}
|
Backpressure policy. The publisher bridge uses lossless, sender-gated
backpressure: This matches the point-to-point semantics of |
|
Terminal signals. Per Reactive Streams §1.7, terminal completion is
not gated by demand: once the underlying |
9. Deferred Cleanup with defer
The defer keyword schedules a cleanup action to run when the enclosing
async closure completes, regardless of success or failure. Multiple
deferred actions execute in LIFO order — last registered, first to run —
making it natural to pair resource acquisition with cleanup:
def log = []
def task = async {
log << 'open connection'
defer { log << 'close connection' }
log << 'open transaction'
defer { log << 'close transaction' }
log << 'save game state'
'saved'
}
assert await(task) == 'saved'
// Deferred actions run in LIFO order — last registered, first to run
assert log == ['open connection', 'open transaction', 'save game state',
'close transaction', 'close connection']
Deferred actions always run, even when an exception occurs:
def cleaned = false
def task = async {
defer { cleaned = true }
throw new RuntimeException('save failed')
}
try {
await task
} catch (RuntimeException e) {
assert e.message == 'save failed'
}
// Deferred actions run even when an exception occurs
assert cleaned
If a deferred action returns an Awaitable or Future, the result is awaited
before the next deferred action runs, ensuring orderly cleanup of asynchronous
resources.
10. Structured Concurrency
Structured concurrency ensures that concurrent tasks have clear ownership and
bounded lifetimes — no orphaned background work, no silent failures leaking
across your application. This idea is gaining momentum across the industry
(Java’s JEP 453, Go’s errgroup, Kotlin’s
coroutine scopes) because it makes concurrent code easier to reason about,
test, and debug. Groovy’s AsyncScope provides these guarantees today, even
on JDK versions before Project Loom’s StructuredTaskScope ships as a final API.
AsyncScope binds the lifetime of child tasks to a scope. When the scope
exits, all children are guaranteed complete (or cancelled). This prevents
orphaned tasks and silent failures:
// Run a tournament round — all tables play concurrently
def results = AsyncScope.withScope { scope ->
def table1 = scope.async { [winner: 'Alice', score: 320] }
def table2 = scope.async { [winner: 'Bob', score: 280] }
def table3 = scope.async { [winner: 'Carol', score: 410] }
[await(table1), await(table2), await(table3)]
}
// All tables guaranteed complete when withScope returns
assert results.size() == 3
assert results.max { it.score }.winner == 'Carol'
By default, the scope uses fail-fast semantics — if any child fails, all siblings are cancelled immediately:
try {
AsyncScope.withScope { scope ->
scope.async { Thread.sleep(5000); 'still playing' }
scope.async { throw new RuntimeException('player disconnected') }
}
} catch (RuntimeException e) {
// First failure cancels all siblings and propagates
assert e.message == 'player disconnected'
}
The scope waits for every child to finish, even without explicit await calls:
def completed = new AtomicInteger(0)
AsyncScope.withScope { scope ->
3.times { scope.async { Thread.sleep(50); completed.incrementAndGet() } }
}
// All children have completed — even without explicit await
assert completed.get() == 3
On JDK 25+, scope tracking uses ScopedValue for optimal virtual thread
performance (no per-thread storage, automatic inheritance). On JDK 17–24,
a ThreadLocal fallback is used transparently.
11. Advanced Topics
11.1. Consuming with for await
for await iterates over any async source. For generators and plain collections,
it works identically to a regular for loop:
def topCards = async {
for (card in ['A♠', 'K♥', 'Q♦']) {
yield return card
}
}
def hand = []
for await (card in topCards) {
hand << card
}
assert hand == ['A♠', 'K♥', 'Q♦']
The key value of for await is with reactive types (Reactor Flux, RxJava
Observable, java.util.concurrent.Flow.Publisher) where it automatically
converts the source to a blocking iterable via the adapter SPI. Without
for await, you would need to call the conversion manually (e.g.,
flux.toIterable()). For generators and plain collections, a regular for
loop works identically.
The JDK’s Flow.Publisher is supported out of the box — no adapter module
needed. This means streams exposed by built-in sources such as
Agent.changes() (see Agent) and BroadcastChannel.asPublisher()
(see BroadcastChannel), or any third-party publisher using the JDK
interfaces, compose directly with for await and await:
import groovy.concurrent.Agent
def agent = Agent.create(0)
try {
async {
3.times { agent.send { it + 1 } }
agent.shutdown()
}
for await (v in agent.changes()) {
println "value is now $v"
}
} finally {
agent.shutdown()
}
11.2. Timeouts and Delays
Apply a deadline to any task. If it doesn’t complete in time, a
TimeoutException is thrown:
def slowPlayer = async { Thread.sleep(5000); 'finally played' }
try {
await Awaitable.orTimeoutMillis(slowPlayer, 100)
} catch (TimeoutException e) {
// Player took too long — turn forfeited
assert true
}
Or use a fallback value instead of throwing:
def slowPlayer = async { Thread.sleep(5000); 'deliberate move' }
def move = await Awaitable.completeOnTimeoutMillis(slowPlayer, 'auto-pass', 100)
assert move == 'auto-pass'
Awaitable.delay() pauses without blocking a thread:
long start = System.currentTimeMillis()
await Awaitable.delay(100) // pause without blocking a thread
assert System.currentTimeMillis() - start >= 90
11.3. Framework Adapters
await natively understands Awaitable, CompletableFuture, CompletionStage,
and Future. for await natively understands java.util.concurrent.Flow.Publisher
in addition to regular Iterable sources. Third-party async types can be
supported by implementing the AwaitableAdapter SPI and registering it via
META-INF/services/groovy.concurrent.AwaitableAdapter.
Drop-in adapter modules are provided for:
-
groovy-reactor—awaitonMono,for awaitoverFlux -
groovy-rxjava—awaitonSingle/Maybe/Completable,for awaitoverObservable/Flowable
For example, without the adapter you must manually convert RxJava types:
// Without groovy-rxjava — manual conversion
def result = Single.just('hello').toCompletionStage().toCompletableFuture().join()
With groovy-rxjava on the classpath, the conversion is transparent:
// With groovy-rxjava — adapter handles the plumbing
def result = await Awaitable.from(Single.just('hello'))
11.4. Executor Configuration
By default, async uses:
-
JDK 21+: a virtual-thread-per-task executor
-
JDK 17–20: a cached daemon thread pool (max 256 threads, configurable via
groovy.async.parallelismsystem property)
You can override the executor:
import org.apache.groovy.runtime.async.AsyncSupport
import java.util.concurrent.Executors
AsyncSupport.setExecutor(Executors.newFixedThreadPool(4))
AsyncSupport.setExecutor(null) // restore platform default
// or equivalently:
AsyncSupport.resetExecutor()
11.5. Integration with JDK Classes
await works with any JDK API that returns a CompletableFuture, CompletionStage,
or Future. This means you can combine process execution, asynchronous file I/O,
and HTTP calls in a single async block — all running concurrently:
// Task 1: Run a process and await its completion
def proc = async {
def p = echoCmd.execute() // echo Hello from Groovy
await p.onExit()
p.text.trim()
}
// Task 2: Read a file asynchronously
def fileContent = async {
await etcPassword.textAsync
}
// Task 3: Fetch a web page using Groovy's HttpBuilder
def webContent = async {
def http = HttpBuilder.http { baseUri bankUrl }
http.get('/withdraw/100').body
}
// All three tasks run concurrently — collect results
var (echo, file, html) = await proc, fileContent, webContent
assert echo == 'Hello from Groovy'
assert file =~ /sEcrEt/
assert html == '<html>SUCCESS</html>'
Task 3 uses Groovy’s HttpBuilder (from groovy-http-builder), which wraps
JDK HttpClient with a concise DSL. For lower-level control, you can also use
HttpClient directly:
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
def webContent = async {
def client = HttpClient.newHttpClient()
def req = HttpRequest.newBuilder()
.uri(URI.create('https://api.example.com/data'))
.build()
def resp = await client.sendAsync(req, HttpResponse.BodyHandlers.ofString())
resp.body()
}
Key JDK classes that work with await out of the box:
| JDK class | Method returning async result |
|---|---|
|
|
|
|
|
|
|
|
|
|
11.6. Async Closure Factories
Because async { … } is just an expression that returns an Awaitable, you
can wrap it in an ordinary method or closure to create a reusable factory:
// A method that returns an async task — a simple factory
def dealCard(List deck) {
async { deck.shuffled()[0] }
}
def numPlayers = 3
// each player gets a card from their own deck
def cards = (1..numPlayers).collect { dealCard(['A♠', 'K♥', 'Q♦', 'J♣']) }
def hands = await Awaitable.all(*cards)
assert hands.size() == 3
assert hands.every { it in ['A♠', 'K♥', 'Q♦', 'J♣'] }
This is a natural way to build reusable async building blocks without any special framework support.
12. Best Practices
12.1. Prefer returning values over mutating shared state
Async closures run on separate threads. Mutating shared variables from multiple closures is a race condition:
// UNSAFE — shared mutation without synchronization
var count = 0
def tasks = (1..100).collect { async { count++ } }
tasks.each { await it }
// count may not be 100!
Instead, return values and collect results:
// SAFE — no shared mutation
def tasks = (1..100).collect { n -> async { n } }
def results = await Awaitable.all(*tasks)
assert results.sum() == 5050
When shared mutable state is unavoidable, use the appropriate concurrency-aware
type, e.g. AtomicInteger for a shared counter, or thread-safe types from
java.util.concurrent for players concurrently drawing cards from a shared deck.
12.2. Choosing the right tool
| Feature | Use when… |
|---|---|
|
You have sequential steps involving I/O or blocking work and want code that reads top-to-bottom. |
|
You need to launch independent tasks and collect all results, race them, or take the first success. |
|
You’re producing or consuming a stream of values — paginated APIs, card dealing, log tailing. |
|
You acquire resources at different points and want guaranteed cleanup without nested |
|
Two or more tasks need to communicate — producer/consumer, fan-out/fan-in, or hand-off. |
|
You want child task lifetimes tied to a scope with automatic cancellation on failure. |
Framework adapters |
You’re already using Reactor or RxJava and want |
13. Quick Reference
| Construct | Description |
|---|---|
|
Start a closure on a background thread. Returns |
|
Block until the result is available. Rethrows the original exception. |
|
Wait for all — syntactic sugar for |
|
Produce a value from an async generator. Consumer blocks until ready. |
|
Iterate over an async source (generator, channel, Flux, Observable, etc.). |
|
Schedule a cleanup action (LIFO order) inside an |
|
Create a buffered (or unbuffered) channel for inter-task communication. |
|
Timer offer: a |
|
Timer channel: delivers the |
|
Structured concurrency — all children complete (or are cancelled) on scope exit. |
|
Apply a deadline. Throws |
|
Apply a deadline with a fallback value instead of throwing. |
|
Non-blocking pause. |
All keywords (async, await, defer) are contextual — they can still be
used as variable or method names in existing code.
14. Monadic comprehensions (Incubating)
The groovy-macro-library module provides DO, a comprehension macro that
rewrites a sequence of name in source generators followed by a body into a
chain of bind operations on the carrier type. It gives Scala-style
for-comprehension / Haskell-style do-notation ergonomics to any type with
monadic shape — Optional, Stream, CompletableFuture, Groovy’s
Awaitable and DataflowVariable, and user-defined types that opt in.
DO is incubating (since Groovy 6.0) and may change. The macro is
compile-time only; the generated code calls the Comprehensions runtime
support in core, so a program using DO needs only the core groovy jar at
runtime — groovy-macro-library is a compile-time dependency.
|
def result = DO(a in Optional.of(2),
b in Optional.of(3)) {
Optional.of(a + b)
}
assert result.get() == 5
14.1. Desugaring
Every generator becomes a bind; the body is the innermost closure body and must itself yield a value of the carrier type (the do-notation rule — there is no implicit lifting in this version). The example above expands to:
Comprehensions.bind(Optional.of(2)) { a ->
Comprehensions.bind(Optional.of(3)) { b ->
Optional.of(a + b)
}
}
Because the macro expands before type information is available, it does not
emit a carrier-specific method name directly. Comprehensions.bind resolves
the right operation at runtime; under @CompileStatic the
groovy.typecheckers.MonadicChecker extension supplies the static types (see
Static type checking).
For all but the most trivial uses, DO under @CompileStatic or
@TypeChecked requires the MonadicChecker extension
(@CompileStatic(extensions = 'groovy.typecheckers.MonadicChecker')). The
runtime dispatcher signature Comprehensions.bind(Object, Closure):Object
erases the carrier’s element type and the comprehension’s result type, so
without the extension each generator’s bound name and the whole DO
expression both fall through to Object — which the static type
checker will reject as soon as the body or downstream code does anything
type-specific with either. See Static type checking.
|
A name bound by an earlier generator is in scope in the source expression of every later generator and in the body:
def result = DO(a in Optional.of(10),
b in Optional.of(a * 2)) { // b's source depends on a
Optional.of(a + b)
}
assert result.get() == 30
Short-circuiting is delivered by the carrier, not by the macro: an empty or failed carrier simply propagates and the body is never evaluated.
def result = DO(a in Optional.empty(),
b in Optional.of(3)) {
Optional.of(b) // never reached
}
assert result.isEmpty()
14.2. Participating carriers
A type participates as a carrier when, in order, the first match winning:
-
it is on the standard allow-list —
java.util.Optional,java.util.stream.Stream,java.util.concurrent.CompletionStage(coveringCompletableFuture), andgroovy.concurrent.Awaitable(coveringDataflowVariable); -
it is structural — it has a single-argument
flatMap(and, for the map role,map); -
it is annotated
@groovy.transform.Monadic, optionally declaring non-conventional method names.
The allow-list also recognises common Functional Java carriers by name —
fj.data.Option, fj.data.List, fj.data.Stream, fj.data.Validation and
fj.P1 — using that library’s bind/map convention. Groovy takes no
dependency on Functional Java; the names are matched reflectively, and the
generator closure is coerced to fj.F automatically. fj.data.Either is not
directly monadic in Functional Java (bind lives on its .right()/.left()
projections) and is not a carrier; use a projection explicitly.
Awaitable and DataflowVariable bind via thenCompose; their then
method is the map operation, not bind. DO over Awaitable therefore
composes asynchronous values without an imperative await at each step:
|
import groovy.concurrent.Awaitable
import static org.apache.groovy.runtime.async.AsyncSupport.await
def total = DO(a in Awaitable.of(2),
b in Awaitable.of(40)) {
Awaitable.of(a + b)
}
assert await(total) == 42
Stream yields the usual cartesian composition:
import java.util.stream.Stream
def pairs = DO(x in Stream.of(1, 2),
y in Stream.of('a', 'b')) {
Stream.of("$x$y".toString())
}
assert pairs.toList() == ['1a', '1b', '2a', '2b']
A user type opts in with @Monadic, which may name a non-conventional bind
and map method:
import groovy.transform.Monadic
import java.util.function.Function
@Monadic(bind = 'chain', map = 'transform', unit = 'of')
class Result {
final Object value
Result(Object value) { this.value = value }
static Result of(value) { new Result(value) } // unit: not used by DO, declared for law tooling
Result chain(Function f) { (Result) f.apply(value) }
Result transform(Function f) { new Result(f.apply(value)) }
boolean equals(o) { o instanceof Result && value == o.value } // results compare by value...
int hashCode() { value == null ? 0 : value.hashCode() }
}
def r = DO(a in Result.of(3),
b in Result.of(4)) {
Result.of(a * b)
}
assert r == new Result(12) // ...so == means "same wrapped value"
The monad laws (left identity, right identity, associativity) are not enforced
by the compiler; as with @Reducer/@Associative, lawful behaviour is the
participating type’s responsibility. Because a comprehension composes monadic
values, a data-style carrier should provide structural equals/hashCode
(Groovy’s == dispatches to equals) — otherwise results compare by identity
and laws such as right identity and functor identity silently fail to hold. This
does not apply to effectful carriers like Stream, CompletableFuture and
Awaitable, whose laws hold only up to observational equivalence. A carrier may
optionally name its unit (of/pure) factory via @Monadic(unit = '…'); DO
does not use it — the body lifts explicitly — but it lets law-deriving tooling
synthesize the unit-dependent laws (left and right identity).
14.3. Static type checking
Activate the MonadicChecker type-checking extension to use DO under
@CompileStatic or @TypeChecked. It types each generator’s bound name as
the carrier’s element type (so the body type-checks), restores the
comprehension’s result type, and rejects a non-participating carrier with a
compile error naming the type and the missing shape:
import groovy.transform.CompileStatic
@CompileStatic(extensions = 'groovy.typecheckers.MonadicChecker')
class Calc {
static int sum() {
DO(a in Optional.of(2),
b in Optional.of(3)) {
Optional.of(a + b)
}.get()
}
}
assert Calc.sum() == 5
The typical symptom of forgetting the extension is a static type
checking error reporting that some operation cannot be found on Object
— either inside the body (a generator’s bound name has erased to
Object) or on the DO expression itself (the result has erased to
Object). Adding extensions = 'groovy.typecheckers.MonadicChecker' to
the @CompileStatic/@TypeChecked annotation resolves it.
|
14.3.1. Lint for hand-written chains: MonadicShapeChecker
If you write flatMap / map chains by hand (without DO) over the same
participating carriers, the sister MonadicShapeChecker extension provides
a compile-time lint:
@TypeChecked(extensions = 'groovy.typecheckers.MonadicShapeChecker')
It catches the two classic shape mistakes — a bind (i.e. `flatMap`)
closure that returns a non-carrier value, and a map closure that returns
the same carrier where flatMap was meant. A strict option tightens
what is flagged; the default lenient mode reports only high-confidence
violations. Both checkers are documented from the type-checker side in the
Built-in auxiliary type checkers chapter.
14.4. When to use DO
DO is a value-composition notation. It complements, rather than competes
with, the concurrency constructs: use imperative async/await when code
reads as a sequence of dependent steps you intend to run now (see
Prefer returning values over mutating shared state and Choosing the right tool); reach for DO
when the composed value is the deliverable — an Awaitable to combine
further, an Optional/validation result, a parser result, or a custom
Result type — and you want one uniform notation across carriers.
This version is deliberately narrow. The body must yield a carrier
value (no implicit pure/unit lifting); there are no if guard clauses;
and each DO works over a single carrier (nest DO blocks for more).
break/continue are not valid in the body, and return follows the
standard closure rule. These are the same closure-body constraints the
@Parallel for-loop transform documents.
|