Async/Await
|
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). Native async/await is new in Groovy 6.0 and is still incubating in that release, which means the
API under groovy.concurrent may change in a minor release. Two keywords do the work: async { ... } starts a closure
on a background thread and returns an Awaitable immediately, and await suspends the caller until that result is
available, so concurrent code can be written top to bottom instead of as callback or CompletableFuture chains. On JDK 21
and later every task runs on its own virtual thread; on JDK 17 to 20 a cached daemon thread pool is the fallback. The
running scenario is the same greenhouse used elsewhere in this section: sensors that must be polled concurrently because
each reading takes time. Generators, channels and structured concurrency build on the same keywords and live on
Generators, Channels & Structured Concurrency;
parallel collections and dataflow are on
Parallel Collections & Dataflow. For the same idea
in other languages see
Kotlin Coroutines Basics and, for the JDK building block Groovy
uses on JDK 21 and later,
Java Virtual Threads.
A practical note on imports: the official guide omits them, but with Groovy 6.0.0 the async and await keywords work
without any import while the types Awaitable, AwaitResult, AsyncChannel, ChannelSelect, BroadcastChannel and
AsyncScope need import groovy.concurrent.*. The examples below include it whenever they name one of those types.
Your first async and await
async { ... } evaluates its closure on a background thread and returns at once with an Awaitable, a promise-like handle
for the eventual result. await blocks the calling thread (cheaply, when it is a virtual thread) until the value arrives, and
returns it.
import groovy.concurrent.Awaitable
// Greenhouse sensors: poll one sensor in the background
def poll = { String sensor ->
async {
Thread.sleep(100) // pretend to talk to the device
[sensor: sensor, celsius: 21.5, thread: Thread.currentThread().virtual]
}
}
def task = poll('gh-1-temp') // starts immediately, returns an Awaitable
println task.getClass().simpleName
def reading = await task // suspend the caller until the value is ready
println reading
assert reading.celsius == 21.5
println "JDK ${Runtime.version().feature()}, virtual threads available: ${Awaitable.virtualThreadsAvailable}"
GroovyPromise
[sensor:gh-1-temp, celsius:21.5, thread:true]
JDK 26, virtual threads available: true
Three details are worth remembering. The task starts the moment async is evaluated, not when it is awaited, so calling
poll three times starts three polls. The concrete class behind an Awaitable is an internal implementation
(GroovyPromise here), so program against the Awaitable interface. And thread:true shows that the closure really ran on a
virtual thread. async, await and defer are contextual keywords: code that already uses them as variable or method names
keeps compiling.
Async methods are methods that return an Awaitable
Groovy 6.0.0 has no async method modifier: async def read() { ... } and async read() { ... } are both syntax errors.
An "async method" is simply a method or closure whose body is (or returns) an async { ... } expression, which makes it a
reusable factory. Inside an async closure, further await calls read like ordinary sequential code.
// "Async methods": an ordinary method that returns an Awaitable
class SensorHub {
def readTemperature(String sensor) {
async { Thread.sleep(50); sensor == 'gh-2' ? 24.0 : 19.5 }
}
def readHumidity(String sensor) {
async { Thread.sleep(50); 0.62 }
}
// sequential style: each await suspends this task, not the caller
def summary(String sensor) {
async {
def t = await readTemperature(sensor)
def h = await readHumidity(sensor)
"$sensor: ${t}C, ${(h * 100) as int}% RH"
}
}
}
def hub = new SensorHub()
println await(hub.summary('gh-2'))
gh-2: 24.0C, 62% RH
await is not restricted to async code: the script above awaits at the top level, and a normal method may await too. It then
simply blocks its own thread, so prefer to do the awaiting inside an async closure when the caller itself must stay responsive.
Exception handling
A failure inside an async closure is stored in the Awaitable. When someone awaits it, await rethrows the original
exception, unwrapping the CompletionException or ExecutionException that the java.util.concurrent classes would add.
Ordinary try/catch therefore works exactly as in synchronous code, and catch (SensorOffline e) matches the type you
threw. Chain methods such as exceptionally recover without try.
import groovy.concurrent.*
// await rethrows the original exception, not CompletionException
class SensorOffline extends RuntimeException {
SensorOffline(String m) { super(m) }
}
def broken = async { throw new SensorOffline('gh-3 is offline') }
try {
await broken
} catch (SensorOffline e) {
println "caught ${e.class.simpleName}: ${e.message}"
}
// a failed Awaitable can also be recovered without try/catch
def safe = (async { throw new SensorOffline('gh-3 is offline') }).exceptionally { 0.0 }
println await(safe)
// failures only surface when somebody awaits
def forgotten = async { throw new SensorOffline('nobody listens') }
Thread.sleep(100)
println "forgotten failed: ${forgotten.isCompletedExceptionally()}"
caught SensorOffline: gh-3 is offline
0.0
forgotten failed: true
The last case is the trap: an Awaitable nobody awaits fails silently. Always either await every task you start or start it
inside a scope that waits for it, as described in
Generators, Channels & Structured Concurrency.
Groovy’s general exception model is covered on
Exceptions & Error Handling.
When one Awaitable has to be transformed rather than awaited, the interface offers the familiar chain methods then,
thenCompose, thenAccept, exceptionally, handle and whenComplete, plus isDone, isCancelled,
isCompletedExceptionally and cancel(). await is almost always clearer than a chain, but the methods are useful for a
one-line recovery as above.
CompletableFuture interop
await accepts more than Awaitable: out of the box it understands CompletableFuture, CompletionStage and
Future, so any Java library that returns one of them can be awaited directly. In the other direction
Awaitable.toCompletableFuture() hands an Awaitable to Java code, and Awaitable.from(x) wraps any supported object.
import groovy.concurrent.*
import java.util.concurrent.*
// await works with CompletableFuture, CompletionStage and Future
def cf = CompletableFuture.supplyAsync { 'cf-reading' }
println await(cf)
def pool = Executors.newSingleThreadExecutor()
Future fut = pool.submit({ 'future-reading' } as Callable)
println await(fut)
pool.shutdown()
// the other way: Awaitable -> CompletableFuture, plus chaining
def a = async { 20 }
CompletableFuture<Integer> asCf = a.toCompletableFuture()
println asCf.thenApply { it + 1 }.get()
def chained = a.then { it * 2 }.thenCompose { v -> async { v + 2 } }
println await(chained)
// wrap any supported object explicitly
println await(Awaitable.from(CompletableFuture.completedFuture('wrapped')))
println await(Awaitable.of(7))
cf-reading
future-reading
21
42
wrapped
7
await on a type that nothing adapts, such as an Integer, fails with IllegalArgumentException: No Awaitable adapter found
for type: java.lang.Integer. The adapter registry behind this is the extension point used by the Reactor and RxJava modules
described below.
Running tasks in parallel
The point of async/await is to overlap waiting. Awaiting each task right after starting it serialises the work; starting all tasks first and awaiting afterwards runs them side by side. The following example polls three sensors that each take 300 ms:
import groovy.concurrent.*
long t0 = System.nanoTime()
def poll = { String s -> async { Thread.sleep(300); "$s=ok" } }
def sequential = [await(poll('a')), await(poll('b')), await(poll('c'))]
long seqMs = (System.nanoTime() - t0) / 1_000_000
t0 = System.nanoTime()
def a = poll('a'); def b = poll('b'); def c = poll('c') // all started
def parallel = await Awaitable.all(a, b, c)
long parMs = (System.nanoTime() - t0) / 1_000_000
def sugar = await(poll('x'), poll('y')) // multi-argument await
println sequential
println parallel
println sugar
println "sequential >= 900 ms: ${seqMs >= 900}, parallel < 600 ms: ${parMs < 600}"
assert seqMs >= 900 && parMs < 600
def (t, h, l) = await(poll('temp'), poll('humidity'), poll('light'))
println "$t $h $l"
[a=ok, b=ok, c=ok]
[a=ok, b=ok, c=ok]
[x=ok, y=ok]
sequential >= 900 ms: true, parallel < 600 ms: true
temp=ok humidity=ok light=ok
await(a, b, c) with several arguments is syntactic sugar for await Awaitable.all(a, b, c) and returns a list in argument
order, which destructures neatly into def (t, h, l) = .... The diagram shows why the second version is faster: the three
polls overlap, and the caller resumes when the slowest one finishes.
The order in which the polls finish does not matter: the result list always follows the argument order.
Combinators: all, any, first and allSettled
Four static methods on Awaitable coordinate a group of tasks. They differ in when they complete and what they do with
failures; the names and semantics follow the JavaScript Promise combinators.
| Combinator | Completes when | On failure | Typical use |
|---|---|---|---|
|
every task has succeeded |
fails fast with the first failure |
gather results of independent tasks |
|
every task has finished, successfully or not |
never throws; each outcome is an |
report partial success |
|
the first task settles, success or failure |
propagates that first outcome (JavaScript |
the fastest answer wins |
|
the first task succeeds |
throws only if every task fails (JavaScript |
hedged requests, fallbacks |
import groovy.concurrent.*
def probe = { String name, long ms, boolean fail = false ->
async {
Thread.sleep(ms)
if (fail) throw new IOException("$name unreachable")
"$name answered"
}
}
// all: fail-fast
try {
await Awaitable.all(probe('gw-a', 50), probe('gw-b', 10, true), probe('gw-c', 300))
} catch (IOException e) {
println "all failed fast: ${e.message}"
}
// any: first to settle wins, success or failure (like Promise.race)
println "any: " + await(Awaitable.any(probe('slow', 400), probe('quick', 20)))
try {
await Awaitable.any(probe('slow', 400), probe('broken', 20, true))
} catch (IOException e) {
println "any propagated the first settlement: ${e.message}"
}
// first: first *success* wins (like Promise.any)
println "first: " + await(Awaitable.first(probe('broken', 10, true), probe('backup', 80)))
try {
await Awaitable.first(probe('x', 10, true), probe('y', 20, true))
} catch (Throwable e) {
println "first with no success: ${e.class.name}"
}
// allSettled: never throws, one AwaitResult per task, in argument order
def results = await Awaitable.allSettled(probe('a', 10), probe('b', 10, true))
results.each { println it }
println results*.success
println results[1].error.message
println results[1].getOrElse { 'default' }
println results[0].map { it.toUpperCase() }
all failed fast: gw-b unreachable
any: quick answered
any propagated the first settlement: broken unreachable
first: backup answered
first with no success: java.io.IOException
AwaitResult.Success[a answered]
AwaitResult.Failure[java.io.IOException: b unreachable]
[true, false]
b unreachable
default
AwaitResult.Success[A ANSWERED]
Points to note:
-
Awaitable.allandallSettledaccept an empty argument list and complete with an empty list.anyandfirstneed at least one source and throwIllegalArgumentException("sources must not be empty") for none, because a race without runners has no result. -
Awaitable.firstwith only failures rethrows a failure rather than a special wrapper: in the run above it was the first task’sIOException. Do not rely on which failure is reported. -
An
AwaitResulthassuccess/failureflags,value,error,getOrElse(a function from the error to a default) andmap(transform a success, keep a failure), so partial results can be processed withouttry. -
The losers of
anyandfirstare not cancelled automatically; callcancel()on them, or start them inside anAsyncScope, if they must stop.
Cancelling a task is cooperative and explicit: task.cancel() returns true, isCancelled() then reports it, and awaiting a
cancelled task throws java.util.concurrent.CancellationException.
Timeouts and delays
A deadline can be put on any awaitable, and delay pauses without holding a platform thread. The static forms accept any
supported object (an Awaitable, a CompletableFuture…) and the instance forms are available on Awaitable itself. The
names carry the unit: orTimeoutMillis and completeOnTimeoutMillis take milliseconds, while orTimeout and
completeOnTimeout take a TimeUnit.
import groovy.concurrent.*
import java.util.concurrent.TimeoutException
def slowPoll = { async { Thread.sleep(2000); 21.5 } }
try {
await Awaitable.orTimeoutMillis(slowPoll(), 150)
} catch (TimeoutException e) {
println "timed out: ${e.class.simpleName}"
}
def value = await Awaitable.completeOnTimeoutMillis(slowPoll(), -1.0, 150)
println "fallback value: $value"
long t0 = System.nanoTime()
await Awaitable.delay(200)
long ms = (System.nanoTime() - t0) / 1_000_000
println "delay waited >= 190 ms: ${ms >= 190}"
// instance forms, and TimeUnit overloads
def p = slowPoll()
println await(p.completeOnTimeout(0.0, 100, java.util.concurrent.TimeUnit.MILLISECONDS))
try {
await slowPoll().orTimeoutMillis(100)
} catch (TimeoutException e) {
println 'instance form timed out'
}
timed out: TimeoutException
fallback value: -1.0
delay waited >= 190 ms: true
0.0
instance form timed out
A timeout makes the awaiting side give up; it does not stop the work already running, which is why the slow polls above keep
sleeping in the background until they finish. For a deadline that also cancels the children, use
AsyncScope.withScope(Duration) { ... } (see
Generators, Channels & Structured Concurrency).
The 6.0 release notes also list Awaitable.timeout(duration) and AsyncScope.run; neither method exists in the 6.0.0
binary used here, whose API is the one the user guide documents (orTimeoutMillis, completeOnTimeoutMillis and
AsyncScope.withScope). Check the API of the exact Groovy version you run.
Executors and virtual threads
By default each async task runs on a virtual-thread-per-task executor on JDK 21 and later, which is why a thousand blocked
tasks cost almost nothing. On JDK 17 to 20 a cached pool of daemon threads is used instead, capped at 256 threads by default
and configurable with the groovy.async.parallelism system property. Awaitable.virtualThreadsAvailable tells you which
mode the running JVM is in, and Awaitable.executor returns the executor in use. To use your own executor, install it with
AsyncSupport.setExecutor; setExecutor(null) or AsyncSupport.resetExecutor() restores the platform default.
import groovy.concurrent.*
import java.util.concurrent.Executors
import org.apache.groovy.runtime.async.AsyncSupport
def whoAmI = { async { Thread.currentThread().with { [virtual: it.virtual, name: it.name] } } }
println "default executor: " + await(whoAmI())
def pool = Executors.newFixedThreadPool(2) { r -> new Thread(r, 'sensor-pool') }
AsyncSupport.setExecutor(pool)
println "custom executor: " + await(whoAmI())
AsyncSupport.resetExecutor() // setExecutor(null) does the same
pool.shutdown()
println "restored: " + await(whoAmI())
println "Awaitable.virtualThreadsAvailable = ${Awaitable.virtualThreadsAvailable}"
default executor: [virtual:true, name:]
custom executor: [virtual:false, name:sensor-pool]
restored: [virtual:true, name:]
Awaitable.virtualThreadsAvailable = true
The executor is global to the JVM (a static setting), not per call, so change it once at start-up rather than around
individual tasks. A fixed pool also limits concurrency: with two threads, a third blocking task waits for a free thread,
which is the right trade-off for CPU-bound work and the wrong one for large numbers of blocked I/O waits. The
AsyncSupport class is in the implementation package org.apache.groovy.runtime.async, so treat its API as less stable than
groovy.concurrent.
Integration with JDK classes
Because await understands CompletableFuture, many JDK APIs plug in without a wrapper: Process.onExit(),
HttpClient.sendAsync(...), the Path methods getTextAsync(), getBytesAsync() and writeAsync(...) from groovy-nio
(see Files I/O & Paths), AsynchronousFileChannel and
CompletableFuture.supplyAsync/allOf/anyOf. The next script runs a process, reads a file asynchronously and calls a local
HTTP endpoint with Groovy’s HttpBuilder (module groovy-http-builder, incubating in 6.0; see
HTTP Client with HttpBuilder) concurrently. The
"gateway" is a throw-away JDK HttpServer on 127.0.0.1.
import groovy.concurrent.*
import groovy.http.HttpBuilder
import com.sun.net.httpserver.HttpServer
import java.nio.file.Files
// a throw-away local "gateway" on an ephemeral port
def server = HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0)
server.createContext('/status') { ex ->
def body = 'all sensors nominal'.bytes
ex.sendResponseHeaders(200, body.length)
ex.responseBody.withCloseable { it.write(body) }
}
server.start()
def baseUrl = "http://127.0.0.1:${server.address.port}"
def log = Files.createTempFile('sensors', '.log')
log.text = 'gh-1 21.5\ngh-2 24.0\n'
try {
def proc = async {
def p = ['echo', 'firmware 1.4'].execute()
await p.onExit() // Process.onExit() is a CompletableFuture
p.text.trim()
}
def file = async { await log.textAsync } // groovy-nio: Path.getTextAsync()
def http = async {
def client = HttpBuilder.http { baseUri baseUrl }
client.get('/status').body
}
def (fw, readings, status) = await(proc, file, http)
println fw
println readings.readLines()
println status
} finally {
server.stop(0)
Files.deleteIfExists(log)
}
firmware 1.4
[gh-1 21.5, gh-2 24.0]
all sensors nominal
Process handling is described on
External Processes. To call a real HTTP service with the JDK
client directly, await client.sendAsync(request, HttpResponse.BodyHandlers.ofString()) is all that is needed.
Framework adapters: Reactor and RxJava
await natively understands Awaitable, CompletableFuture, CompletionStage and Future, and for await (next page) also
understands java.util.concurrent.Flow.Publisher. Other asynchronous types are supported through the AwaitableAdapter
service-provider interface, registered in META-INF/services/groovy.concurrent.AwaitableAdapter. Two ready-made adapter modules
ship in the Groovy distribution and, as the lib directory of the binary shows, groovy-reactor and groovy-rxjava are
on the classpath of the groovy command; the reactive libraries themselves are separate dependencies.
| Module | Adds |
|---|---|
|
|
|
|
@Grab('io.projectreactor:reactor-core:3.7.11')
@Grab('io.reactivex.rxjava3:rxjava:3.1.10')
import groovy.concurrent.*
import reactor.core.publisher.Mono
import io.reactivex.rxjava3.core.Single
// groovy-reactor: await understands Mono
println await(Mono.just('reactor-reading').map { it.toUpperCase() })
// groovy-rxjava: await understands Single, Maybe and Completable
println await(Single.just('rx-reading'))
println await(Awaitable.from(Single.just('wrapped-rx')))
println await(Mono.just('a'), Single.just('b'), async { 'c' })
REACTOR-READING
rx-reading
wrapped-rx
[a, b, c]
The run also printed three SLF4J(W): No SLF4J providers were found lines from Reactor on standard error, which are harmless
and omitted above. Without the adapter you would convert by hand, for example with
Single.just('x').toCompletionStage().toCompletableFuture().join(). Since the adapters sit behind a registry, the three
different types in the last line combine in one await.
Best practices
Return values instead of mutating shared state
Async closures run on different threads, so updating a shared variable from many of them is a data race. The next script has 200 tasks each add 1000 to a counter in three ways:
import groovy.concurrent.*
import java.util.concurrent.atomic.AtomicInteger
// Unsafe: many tasks mutate one plain variable
int unsafe = 0
await Awaitable.all(*(1..200).collect { async { 1000.times { unsafe++ } } })
println "plain int: ${unsafe} (expected 200000)"
// Better: return values and combine them afterwards
def partials = await Awaitable.all(*(1..200).collect { async { 1000 } })
println "returned: ${partials.sum()}"
// When state really must be shared, use a thread-safe type
def counter = new AtomicInteger()
await Awaitable.all(*(1..200).collect { async { 1000.times { counter.incrementAndGet() } } })
println "AtomicInteger: ${counter.get()}"
plain int: 21484 (expected 200000)
returned: 200000
AtomicInteger: 200000
The first line differs from run to run, because lost updates are random; only the other two are reliable. Groovy also has
declarative alternatives for guarding shared state, such as @Synchronized and @WithReadLock (see
AST Transformations: Logging, Safety, Concurrency & Directives),
and message-passing models in
Actors, Agents & Active Objects.
Other guidelines
-
Start tasks first and await them together (
Awaitable.allor multi-argumentawait); awaiting inside the loop that starts them serialises the work. -
Await everything you start, or start it in a scope: an un-awaited failure is invisible.
-
Use blocking calls freely inside async closures on JDK 21 and later (virtual threads make it cheap), but remember that a fixed custom executor or the JDK 17 to 20 pool is finite.
-
Treat the API as incubating: pin the Groovy version and re-run your concurrency tests when you upgrade.
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 launch independent tasks and need all results, the fastest, the first success, or every outcome |
|
you produce or consume a stream of values, such as paginated APIs or log tailing |
|
resources are acquired at different points and cleanup must be guaranteed without nested |
|
tasks must communicate: producer/consumer, fan-out/fan-in, hand-off |
|
child task lifetimes should be bound to a scope with cancellation on failure |
framework adapters |
you already use Reactor or RxJava and want |
parallel collections |
the work is data-parallel over a collection; see Parallel Collections & Dataflow |
Monadic comprehensions
The same section of the official guide documents DO, an incubating macro from the groovy-macro-library module that
rewrites a sequence of name in source generators followed by a body into a chain of bind operations on the carrier type
(Optional, Stream, CompletableFuture, Awaitable, DataflowVariable and user types that opt in). It is a
compile-time facility, and under @CompileStatic it needs the MonadicChecker extension for static types. It belongs with
the functional constructs and is covered on
Functional Programming; the type checkers are listed on
Built-in Type Checkers.
Quick reference
| Construct | Description |
|---|---|
|
start a closure on a background thread; returns an |
|
block until the result is available; rethrows the original exception |
|
wait for all; sugar for |
|
combinators described above |
|
deadline with an exception, or with a fallback value |
|
pause without blocking a platform thread |
|
completed, failed, adapted or supplier-started awaitables |
|
replace the executor; |
|
Where the book is outdated
The book consulted for this section (see the section bibliography) was written when concurrency in Groovy meant plain
Thread.start, the java.util.concurrent classes and, as an add-on library, GPars. None of that is wrong, but Groovy 6.0
now has a native alternative: async/await keywords, Awaitable combinators, channels and structured scopes in
groovy.concurrent, with virtual threads doing the work on JDK 21 and later. The GPars-style toolkit (parallel collections,
dataflow, actors and agents) was folded into the same package; migration notes are on
Parallel Collections & Dataflow and
Actors, Agents & Active Objects. Because the new API is
incubating in 6.0, check the release notes of every minor release when you rely on it.