Worker API & Build Services
|
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 Gradle 9.8.1 (released 2026-10-07). Every example was run through the 9.8.1 wrapper on 2026-10-16, on macOS, with a JDK 26 daemon (the task classes
were compiled by that JDK in buildSrc; no toolchain was involved), once with Kotlin DSL build files and once with Groovy DSL build files. The two builds share the same Java classes, so
the output is the same apart from the order of lines that are produced by parallel work. Each example was run with --configuration-cache and is compatible with it; the one
that is not (a task calling Task.project while it runs) is shown on purpose. Where a listing shows a line with only ..., output was left out. The listings leave out blank lines and the "Calculating task graph", "Reusing configuration cache" and "Configuration cache entry" lines; the
daemon process id in the output (91866 here) and the ports and process ids of worker processes differ from run to run. Gradle 8.14 was not available here, so what the text says about 8.14 comes from the
release notes and the user manual and is labelled "not run here".
A build is parallel at three levels, and this page is about the two lowest ones. Gradle’s --parallel option runs tasks of different projects at the same time; the
Build Environment & Properties page lists it with org.gradle.workers.max. The Worker API
runs the independent pieces of one task action at the same time, optionally in an isolated classloader or in a separate process. Shared build services are the
objects that such concurrent tasks and workers share, and that can limit how many of them use a resource at once. A third API, dataflow actions, runs small
pieces of work that are not tasks, such as reacting to the build result. The task side is on
Writing Custom Tasks and the lazy Property types used for parameters on
Lazy Configuration & Providers.
Source: Gradle: Worker API
The example build
The tidepool build used on all the Gradle pages predicts tide heights from harmonic constituents. For this page the build has a root project, which holds the data file and a tideTables
task, and three small subprojects, tides-a, tides-b and tides-c, that apply a plugin from buildSrc. The classes live in buildSrc (see
Writing Custom Tasks for the other places task classes can go). The data file is the one the other pages use:
id,name,latitude,longitude,constituents
SF,San Francisco,37.8063,-122.4659,M2:0.55:120;S2:0.13:150;K1:0.38:210
BOS,Boston,42.3539,-71.0503,M2:1.35:15;S2:0.21:42;N2:0.28:350
HNL,Honolulu,21.3067,-157.8645,M2:0.14:90;K1:0.17:250;O1:0.10:230
The buildSrc build only needs the java-gradle-plugin plugin, which gives the classes the Gradle API and registers the io.tidepool.archive plugin used further down:
plugins {
`java-gradle-plugin`
}
gradlePlugin {
plugins {
register("archive") {
id = "io.tidepool.archive"
implementationClass = "io.tidepool.build.ArchivePlugin"
}
}
}
plugins {
id 'java-gradle-plugin'
}
gradlePlugin {
plugins {
archive {
id = 'io.tidepool.archive'
implementationClass = 'io.tidepool.build.ArchivePlugin'
}
}
}
The settings file and the root build file apply the base plugin (for clean) and the plugin:
rootProject.name = "tidepool"
include("tides-a", "tides-b", "tides-c")
rootProject.name = 'tidepool'
include 'tides-a', 'tides-b', 'tides-c'
plugins {
base
id("io.tidepool.archive")
}
tasks.register<io.tidepool.build.TideTables>("tideTables") {
stations = layout.projectDirectory.file("data/stations.csv")
isolation = "none"
outputDir = layout.buildDirectory.dir("tides")
}
plugins {
id 'base'
id 'io.tidepool.archive'
}
tasks.register('tideTables', io.tidepool.build.TideTables) {
stations = layout.projectDirectory.file('data/stations.csv')
isolation = 'none'
outputDir = layout.buildDirectory.dir('tides')
}
Each subproject has a two-line build file that applies io.tidepool.archive: plugins { id("io.tidepool.archive") } in the Kotlin DSL and plugins { id 'io.tidepool.archive' } in the Groovy DSL.
The Worker API
A task action runs on one thread. If the action is a loop whose iterations do not depend on each other, such as one tide table per station, it can submit each iteration as a unit of work to
a WorkerExecutor and let Gradle run them concurrently. Three types describe a unit of work:
-
WorkParametersis an interface with the inputs of one unit, declared as getters of lazyProperty,RegularFilePropertyorDirectoryPropertytypes. Gradle generates the implementation, as it does for task classes. The parameters must be Gradle-managed properties, not plain fields. -
WorkAction<P>is an interface with anexecute()method; you write an abstract class that implements it. It reads its inputs throughgetParameters(), which Gradle implements, and is created by Gradle for each unit. -
WorkQueue(since 5.6) is whatnoIsolation(),classLoaderIsolation()orprocessIsolation()return; every unit submitted to one queue has the same isolation requirements.
The task gets the WorkerExecutor service by declaring an abstract getter annotated with @Inject. Here are the parameters, the action and the task:
package io.tidepool.build;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.Property;
import org.gradle.workers.WorkParameters;
/** The parameters of one unit of work: one station, one output file. */
public interface TideParameters extends WorkParameters {
Property<String> getStationLine();
RegularFileProperty getTableFile();
}
package io.tidepool.build;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import org.gradle.workers.WorkAction;
/** One unit of work: a 24-hour tide table for one station (harmonic sum, as in the tidepool engine). */
public abstract class TideTableAction implements WorkAction<TideParameters> {
@Override
public void execute() {
String[] f = getParameters().getStationLine().get().split(",");
String id = f[0];
System.out.printf("%s: thread=%s pid=%d classLoader=%s%n", id, Thread.currentThread().getName(),
ProcessHandle.current().pid(), getClass().getClassLoader().getClass().getSimpleName());
try {
Thread.sleep(2000); // stands in for an expensive computation
List<String> lines = new ArrayList<>();
for (int hour = 0; hour < 24; hour++) {
double sum = 0;
for (String c : f[4].split(";")) {
String[] p = c.split(":");
double speed = switch (p[0]) {
case "M2" -> 28.984104;
case "S2" -> 30.0;
case "N2" -> 28.43973;
case "K1" -> 15.041069;
case "O1" -> 13.943036;
default -> 0;
};
sum += Double.parseDouble(p[1]) * Math.cos(Math.toRadians(speed * hour - Double.parseDouble(p[2])));
}
lines.add(String.format(java.util.Locale.ROOT, "%02d:00 %.3f", hour, sum));
}
Files.write(getParameters().getTableFile().get().getAsFile().toPath(), lines);
} catch (IOException | InterruptedException e) {
throw new RuntimeException(e);
}
}
}
package io.tidepool.build;
import java.io.IOException;
import java.nio.file.Files;
import java.util.List;
import javax.inject.Inject;
import org.gradle.api.DefaultTask;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.OutputDirectory;
import org.gradle.api.tasks.PathSensitive;
import org.gradle.api.tasks.PathSensitivity;
import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.options.Option;
import org.gradle.workers.WorkQueue;
import org.gradle.workers.WorkerExecutor;
/** Writes one tide table per station, one work item per station. */
public abstract class TideTables extends DefaultTask {
@InputFile
@PathSensitive(PathSensitivity.NONE)
public abstract RegularFileProperty getStations();
@Input
@Option(option = "isolation", description = "none, classloader or process")
public abstract Property<String> getIsolation();
@OutputDirectory
public abstract DirectoryProperty getOutputDir();
@Inject
protected abstract WorkerExecutor getWorkerExecutor();
@TaskAction
public void run() throws IOException {
WorkQueue queue = switch (getIsolation().get()) {
case "none" -> getWorkerExecutor().noIsolation();
case "classloader" -> getWorkerExecutor().classLoaderIsolation();
case "process" -> getWorkerExecutor().processIsolation(spec ->
spec.forkOptions(options -> options.setMaxHeapSize("64m")));
default -> throw new IllegalArgumentException("unknown isolation " + getIsolation().get());
};
List<String> lines = Files.readAllLines(getStations().get().getAsFile().toPath());
for (String line : lines.subList(1, lines.size())) {
String id = line.substring(0, line.indexOf(','));
queue.submit(TideTableAction.class, parameters -> {
parameters.getStationLine().set(line);
parameters.getTableFile().set(getOutputDir().file(id + ".txt"));
});
}
}
}
The action prints which thread, process and class loader it runs on, and sleeps for two seconds to stand in for a computation that is worth parallelising. The task chooses the isolation mode from an --isolation command-line option
(@Option, covered on Writing Custom Tasks). getOutputDir() is declared as @OutputDirectory, so Gradle knows what the task produces. The task action itself only
submits; Gradle waits for every submitted unit before it reports the task as finished. WorkQueue.await() (since 5.6) waits explicitly, for a task that needs the results before it continues.
Isolation modes
The three methods of WorkerExecutor trade isolation against start-up cost:
| Method | Where the unit runs | Class path | Cost and use |
|---|---|---|---|
|
a thread of the build daemon |
the class loader the task class was loaded from; shared by all units, so static state is shared |
the cheapest; units run concurrently but cannot be given a different library version |
|
a thread of the build daemon |
a new class loader with the class path of the action plus the entries added with |
a little more set-up; use it to run a different, compatible version of a library, or to keep static state of different units apart |
|
a separate Java process, a worker daemon |
the class path of the action plus the entries added to the spec |
the most expensive; use it for a different JVM, heap size, system properties or a library that is not safe to run in the Gradle JVM |
Source: Gradle: Isolation modes
Running the task in the three modes, with clean first so that the task is not up to date, shows where the units ran. The thread names are Gradle’s; the class loader name tells the modes
apart, and the process id tells whether a unit ran in the build daemon (91866 here) or in its own process:
$ ./gradlew --configuration-cache clean tideTables --isolation=none
> Task :clean
> Task :tideTables
SF: thread=WorkerExecutor Queue pid=91866 classLoader=InstrumentingVisitableURLClassLoader
BOS: thread=WorkerExecutor Queue Thread 2 pid=91866 classLoader=InstrumentingVisitableURLClassLoader
HNL: thread=WorkerExecutor Queue Thread 3 pid=91866 classLoader=InstrumentingVisitableURLClassLoader
BUILD SUCCESSFUL in 3s
6 actionable tasks: 4 executed, 2 up-to-date
$ ./gradlew --configuration-cache clean tideTables --isolation=classloader
> Task :clean
> Task :tideTables
BOS: thread=WorkerExecutor Queue Thread 2 pid=91866 classLoader=VisitableURLClassLoader
SF: thread=WorkerExecutor Queue pid=91866 classLoader=VisitableURLClassLoader
HNL: thread=WorkerExecutor Queue Thread 3 pid=91866 classLoader=VisitableURLClassLoader
BUILD SUCCESSFUL in 3s
6 actionable tasks: 2 executed, 4 up-to-date
$ ./gradlew --configuration-cache clean tideTables --isolation=process
> Task :clean
> Task :tideTables
BOS: thread=/127.0.0.1:53479 to /127.0.0.1:53476 workers pid=13659 classLoader=VisitableURLClassLoader
SF: thread=/127.0.0.1:53481 to /127.0.0.1:53477 workers pid=13661 classLoader=VisitableURLClassLoader
HNL: thread=/127.0.0.1:53480 to /127.0.0.1:53478 workers pid=13655 classLoader=VisitableURLClassLoader
BUILD SUCCESSFUL in 3s
6 actionable tasks: 2 executed, 4 up-to-date
All three units start at the same time (the build takes about three seconds, not six). In noIsolation() mode the loader is the one that loaded the task class (InstrumentingVisitableURLClassLoader). In classLoaderIsolation() it is a fresh
loader. In processIsolation() mode there are three different process ids, none of them the daemon’s: Gradle started three worker daemons because the three units were submitted at the same time and one worker process runs one unit at a time.
The same commands with the Groovy DSL build files print the same lines, with the stations in a different order. Both builds were run.
The number of units that run at the same time is limited by the max workers setting, which is also what limits --parallel. It is --max-workers=n on the command line and org.gradle.workers.max in gradle.properties
(see Command-Line Interface); the default is the number of processors. With one worker the same build runs the three units one after the other:
$ ./gradlew --configuration-cache clean tideTables --isolation=none --max-workers=1
> Task :clean
> Task :tideTables
SF: thread=WorkerExecutor Queue pid=91866 classLoader=InstrumentingVisitableURLClassLoader
BOS: thread=WorkerExecutor Queue pid=91866 classLoader=InstrumentingVisitableURLClassLoader
HNL: thread=WorkerExecutor Queue pid=91866 classLoader=InstrumentingVisitableURLClassLoader
BUILD SUCCESSFUL in 6s
2 actionable tasks: 2 executed
Source: Gradle: org.gradle.workers.max
Worker daemons
With processIsolation() Gradle starts a JVM for the unit, or reuses an idle one that is compatible with what the unit asked for. The compatibility rules compare what is set on the worker spec with
what each running worker was started with:
-
the Java executable must be the same;
-
the class path of the worker must contain exactly the requested entries;
-
the heap size of the worker must be at least the requested size; the default maximum heap is 512 MB, and
forkOptions { maxHeapSize = "64m" }(as theTideTablestask does) is a request; -
the worker must have all requested JVM arguments, system properties, environment variables and bootstrap class path entries (it may have more);
-
debug, enable-assertions and the default character encoding must be equal.
A worker daemon is session-scoped: it lives for one build and can be reused only inside it. Gradle stops idle workers when the build finishes or when memory runs low. Running the same processIsolation() build a second time shows it: the worker processes of the first run are gone and three new ones (new process ids) do the work, while the build daemon is the
same one:
$ ./gradlew --configuration-cache clean tideTables --isolation=process
> Task :clean
> Task :tideTables
SF: thread=/127.0.0.1:53642 to /127.0.0.1:53640 workers pid=16769 classLoader=VisitableURLClassLoader
HNL: thread=/127.0.0.1:53644 to /127.0.0.1:53641 workers pid=16771 classLoader=VisitableURLClassLoader
BOS: thread=/127.0.0.1:53643 to /127.0.0.1:53639 workers pid=16770 classLoader=VisitableURLClassLoader
BUILD SUCCESSFUL in 3s
2 actionable tasks: 2 executed
A task that does not react to an interrupt of its thread within ten seconds makes Gradle shut the build daemon down, so work should respond to thread interruption to support Ctrl+C and task timeouts.
Source: Gradle: Worker daemons
A failing unit of work
A failure in a unit fails the task, and the build output names the action class and the cause. Changing one constituent in the data file to K1:abc:250 and running the same build:
> Task :clean
> Task :tideTables
SF: thread=WorkerExecutor Queue pid=91866 classLoader=InstrumentingVisitableURLClassLoader
BOS: thread=WorkerExecutor Queue Thread 2 pid=91866 classLoader=InstrumentingVisitableURLClassLoader
HNL: thread=WorkerExecutor Queue Thread 3 pid=91866 classLoader=InstrumentingVisitableURLClassLoader
> Task :tideTables FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':tideTables' (registered in build file 'build.gradle.kts').
> A failure occurred while executing io.tidepool.build.TideTableAction
> For input string: "abc"
BUILD FAILED in 2s
2 actionable tasks: 2 executed
The task never called await(), yet the failure of a unit surfaced as the failure of the task: all three units had started (their lines are in the output) before it was reported. The data file was restored before the other examples were run.
Build services
A build service is an object that Gradle creates and owns for the duration of one build. Tasks, other services, artifact transforms and work actions use it to share
state or a resource: a cache of pre-computed values, a web server or database used by the tests, a rate limiter. Gradle creates the service on first use, closes it when the last
user is done if it is AutoCloseable, and can limit how many tasks use it at the same time. Build services are compatible with the configuration cache. They are also the configuration-cache-compatible place for build-wide state that older builds kept in a static field or in a
gradle.taskGraph listener.
Source: Gradle: Shared build services
Defining a service
A service is an abstract class that implements BuildService<P>, where P extends BuildServiceParameters (or is BuildServiceParameters.None). The parameters follow the Gradle-managed property
rules: JavaBeans getters of Property types. Gradle implements getParameters(). A service must be thread safe, because several tasks may call it at once.
package io.tidepool.build;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.gradle.api.provider.Property;
import org.gradle.api.services.BuildService;
import org.gradle.api.services.BuildServiceParameters;
/** A shared, thread-safe resource: pretends to be a single-writer archive. */
public abstract class ArchiveService implements BuildService<ArchiveService.Params>, AutoCloseable {
public interface Params extends BuildServiceParameters {
Property<String> getName();
}
private final long created = System.nanoTime();
private final AtomicBoolean opened = new AtomicBoolean();
private final AtomicInteger writes = new AtomicInteger();
public void write(String who) throws InterruptedException {
if (opened.compareAndSet(false, true)) {
System.out.println("[archive] opened " + getParameters().getName().get());
}
System.out.printf(java.util.Locale.ROOT, "[archive] %s start at %.1fs%n", who, seconds());
Thread.sleep(1500);
writes.incrementAndGet();
System.out.printf(java.util.Locale.ROOT, "[archive] %s end at %.1fs%n", who, seconds());
}
public int getWrites() {
return writes.get();
}
private double seconds() {
return (System.nanoTime() - created) / 1e9;
}
@Override
public void close() {
if (opened.get()) {
System.out.println("[archive] closed after " + writes.get() + " writes");
}
}
}
The write method stands in for an operation that must not be run by too many tasks at once: it announces its start, waits 1.5 seconds and announces its end, with times counted from the creation of the service.
Because the service implements AutoCloseable, Gradle calls close() when it discards the service, sometime between the last task that used it and the end of the build.
Registering a service and connecting it to a task
A service is registered once with gradle.sharedServices.registerIfAbsent(name, type) { ... }, usually by a plugin. Registering does not create the instance. The same name registered
again (by the plugin applied in another project) returns the existing registration, so every project shares one instance; a build service is scoped to a build, not to a project. There are two ways for a task to
receive the service:
-
a property annotated with
@ServiceReference("archive"), as the example below does; Gradle assigns the service registered under that name (or of that type, when the name is left out and only one matches) and records that the task uses it; -
a property annotated with
@Internal, assigned with the provider thatregisterIfAbsentreturned, plus a call tousesService(provider)on the task. Both are needed with@Internal: without the declaration, Gradle does not know the task uses the service and cannot apply the limit described below.
Any other annotation on a service property is not supported; a service cannot be a task input.
package io.tidepool.build;
import org.gradle.api.DefaultTask;
import org.gradle.api.provider.Property;
import org.gradle.api.services.ServiceReference;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.TaskAction;
/** Writes to the shared archive; Gradle wires the service through the reference. */
public abstract class ArchiveTask extends DefaultTask {
@ServiceReference("archive")
protected abstract Property<ArchiveService> getArchive();
@Input
public abstract Property<String> getLabel();
@TaskAction
public void run() throws InterruptedException {
getArchive().get().write(getLabel().get());
}
}
package io.tidepool.build;
import javax.inject.Inject;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.flow.FlowProviders;
import org.gradle.api.flow.FlowScope;
public abstract class ArchivePlugin implements Plugin<Project> {
@Inject
protected abstract FlowScope getFlowScope();
@Inject
protected abstract FlowProviders getFlowProviders();
@Override
public void apply(Project project) {
int max = project.getProviders().gradleProperty("archive.maxParallelUsages")
.map(Integer::parseInt).getOrElse(1);
project.getGradle().getSharedServices().registerIfAbsent("archive", ArchiveService.class, spec -> {
spec.getParameters().getName().set("tide-archive");
if (max > 0) {
spec.getMaxParallelUsages().set(max);
}
});
project.getTasks().register("archive", ArchiveTask.class, task -> task.getLabel().set(project.getPath()));
if (project == project.getRootProject()) {
getFlowScope().always(BuildSummary.class, spec ->
spec.getParameters().getStatus().set(getFlowProviders().getBuildWorkResult()
.map(result -> result.getFailure().isPresent() ? "FAILED" : "OK")));
}
}
}
The plugin reads the Gradle property archive.maxParallelUsages (default 1) and sets getMaxParallelUsages() on the registration when it is greater than zero. With a limit of one, the four archive tasks
(one in the root project and one in each subproject) cannot overlap, although the configuration cache lets Gradle run them all at once, as a later section explains. Without a limit, they run together:
$ ./gradlew --configuration-cache archive
> Task :archive
[archive] opened tide-archive
[archive] : start at 0.0s
[archive] : end at 1.5s
> Task :tides-a:archive
[archive] :tides-a start at 1.5s
[archive] :tides-a end at 3.0s
> Task :tides-b:archive
[archive] :tides-b start at 3.0s
[archive] :tides-b end at 4.5s
> Task :tides-c:archive
[archive] :tides-c start at 4.5s
[archive] :tides-c end at 6.0s
[summary] build OK, archive writes: 4
[archive] closed after 4 writes
BUILD SUCCESSFUL in 6s
8 actionable tasks: 4 executed, 4 up-to-date
$ ./gradlew --configuration-cache -Parchive.maxParallelUsages=0 archive
Calculating task graph as configuration cache cannot be reused because Gradle property 'archive.maxParallelUsages' has changed.
> Task :tides-a:archive
[archive] :tides-a start at 0.0s
[archive] :tides-a end at 1.5s
> Task :archive
[archive] opened tide-archive
[archive] : start at 0.0s
[archive] : end at 1.5s
> Task :tides-c:archive
[archive] :tides-c start at 0.0s
[archive] :tides-c end at 1.5s
> Task :tides-b:archive
[archive] :tides-b start at 0.0s
[archive] :tides-b end at 1.5s
[summary] build OK, archive writes: 4
[archive] closed after 4 writes
BUILD SUCCESSFUL in 2s
8 actionable tasks: 4 executed, 4 up-to-date
The start and end times are counted from the creation of the service: with one usage at a time the four writes follow each other (0.0 to 6.0 seconds, six seconds for the build), without a limit the four
run at the same time (1.5 seconds of work, two seconds for the build). Changing the Gradle property invalidated the configuration cache entry, which is why the second run recalculates the task graph. The limit applies when the
consuming property is @ServiceReference, or @Internal with usesService. Gradle does not discover indirect use: a service used only by another service must be declared on the task as well, with
another @ServiceReference property or usesService.
Using a service from a worker, a configuration action or a listener
A work action receives a service the same way as other inputs: a Property<ServiceType> in its parameters, set from the task’s own reference. The user manual states two limits: this works with noIsolation() only, because the
service cannot be passed to a classloader-isolated or process-isolated worker, and the service should be used at configuration time (provider.get()) only when that is really needed, because services are usually expensive
to create (not run here; both limits are quoted from the manual).
A service that also implements OperationCompletionListener can receive task-finish events when it is registered with the BuildEventsListenerRegistry service (onTaskCompletion(serviceProvider)), which is the replacement the configuration cache documentation gives for the gradle.taskGraph.afterTask callback (not run here; see the manual page).
Build services, tasks and the configuration cache
Build services are serialised into the configuration cache entry as their parameters, and the instance is created again in every build, so a service must not keep state that has to survive from one build to
the next. Task classes must not reach into the project at execution time. This is a classic mistake in a task that wants to know where it belongs; the example below uses getProject() in @TaskAction:
@TaskAction
public void run() throws InterruptedException {
getArchive().get().write(getProject().getPath());
}
> Task :tides-a:archive FAILED
1 problem was found storing the configuration cache.
- Task `:tides-a:archive` of type `io.tidepool.build.ArchiveTask`: invocation of 'Task.project' at execution time is unsupported with the configuration cache.
See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:use_project_during_execution
...
* What went wrong:
Execution failed for task ':tides-a:archive' (registered by plugin 'io.tidepool.archive').
> Invocation of 'Task.project' by task ':tides-a:archive' at execution time is unsupported with the configuration cache.
...
BUILD FAILED in 1s
5 actionable tasks: 5 executed
Configuration cache entry discarded with 1 problem.
The fix is the one ArchiveTask uses: copy the value into an @Input property at configuration time (the plugin sets getLabel() from project.getPath()) and read the property in the action.
The configuration cache has a second effect on the example. With it enabled, Gradle runs the tasks of a build in parallel, even within one project, subject to the declared dependencies; the --parallel option no longer controls this, and
--no-parallel or org.gradle.parallel=false has no effect on a build that uses the cache. The build services, not the order of tasks, then have to be what keeps tasks apart:
that is why maxParallelUsages is useful. The user manual says builds that depend on sequential tasks should wire inputs and outputs, or use build services to limit concurrency
(see Lazy Configuration & Providers for the properties involved).
Source: Gradle: Parallel Task Execution
Dataflow actions
Some work does not fit a task, such as reacting to the outcome of the whole build. A dataflow action is a small, isolated piece of work that Gradle runs as soon as all its input parameters are available. It
replaces the gradle.buildFinished callback, which does not work with the configuration cache. Dataflow actions are incubating in 9.8.1 (see
Versions, Compatibility & Release History for what incubating means) and the API may change.
You implement FlowAction<P>, where P extends FlowParameters; every parameter must be annotated, with @Input or with @ServiceReference for a build service. You do not create the action; you ask the FlowScope
service to run it with always(actionClass, spec -> ...), in the scope of the build. A parameter that should wait for the build result is derived from the provider
FlowProviders.getBuildWorkResult(), which makes Gradle run the action after the work is done. The example’s BuildSummary takes the result and the archive service, and prints only if the archive was used:
package io.tidepool.build;
import org.gradle.api.flow.FlowAction;
import org.gradle.api.flow.FlowParameters;
import org.gradle.api.provider.Property;
import org.gradle.api.services.ServiceReference;
import org.gradle.api.tasks.Input;
/** A dataflow action: runs once the build result is known. */
public abstract class BuildSummary implements FlowAction<BuildSummary.Params> {
public interface Params extends FlowParameters {
@ServiceReference("archive")
Property<ArchiveService> getArchive();
@Input
Property<String> getStatus();
}
@Override
public void execute(Params parameters) {
int writes = parameters.getArchive().get().getWrites();
if (writes > 0) {
System.out.println("[summary] build " + parameters.getStatus().get() + ", archive writes: " + writes);
}
}
}
The plugin shown earlier registers it in the root project, which is the line starting getFlowScope().always(. It produced the [summary] line at the end of the two archive runs above, after the last task,
and [archive] closed after 4 writes follows it as Gradle closes the service. A failed build calls the action as well, with status FAILED. FlowScope and FlowProviders can be injected into project and
settings plugins.
Source: Gradle: Dataflow actions
Which one to use
| You need | Use |
|---|---|
split one task’s work into independent parts that run at the same time |
the Worker API with |
a different version of a library, or separate static state, per task |
|
a different JVM, heap or system properties, or protection from a crash or leak of a library |
|
state or a resource shared between tasks, workers or other services |
a build service |
at most n tasks using a resource at once |
a build service with |
to react to task completion |
a build service that implements |
to react to the result of the build |
a dataflow action with |
Gradle 8.14 and Gradle 9
The APIs have been stable for a long time; the notes below come from the release notes and the manual. Nothing here was run on 8.14.
| Release | Change |
|---|---|
5.6 (2019) |
|
6.0 (2019-11-08) |
services such as |
7.0 (2021-04-09) |
the Worker API leaves incubation |
7.4 (2022-02-08) |
shared build services are promoted to stable |
8.12 |
service reference properties ( |
8.14 (2025-04-25) |
the last 8.x minor; the problems API can report from a worker task (not run here) |
9.0.0 (2025-07-31) |
the release notes do not mention these APIs; Gradle runs on JVM 17 or later, which is also the JVM of worker processes unless the fork options select another executable |
9.8.1 (2026-10-07) |
fixes an unauthenticated worker-to-daemon channel that deserialised untrusted Java objects, one of the three high-severity security fixes of that release; upgrade to it |
The same idea in Ant and Maven
| Concept | Ant | Maven | Gradle |
|---|---|---|---|
parallel work |
the |
|
|
shared state between tasks |
properties and references in the project; no service lifecycle |
plugin components and the session; see Writing Plugins |
a build service, with parameters, a lifecycle and a concurrency limit |
isolating work |
the |
plugins run in the Maven JVM, in a class realm of their own |
class loader or process isolation of each unit of work |
Where the book is outdated
Gradle in Action (Gradle 1.7, 2014) has neither of these APIs. The Worker API arrived in 3.5 (WorkerExecutor is annotated "Since: 3.5" in the API docs), in its current WorkAction form in 5.6 and stable in 7.0; shared build services became stable in 7.4; dataflow actions are newer
still. The book’s mechanisms for the same needs, gradle.buildFinished, gradle.taskGraph listeners and static fields, either have been removed or do not work with the configuration cache; the replacements are the build services and dataflow actions above.
The book’s idea of parallel execution is the --parallel option across projects; running the tasks of one project in parallel, which the configuration cache does, did not exist.
Summary
-
The Worker API splits one task action into units that run concurrently:
WorkParametersandWorkActiondescribe a unit,WorkerExecutorgives aWorkQueuein one of three isolation modes. -
noIsolation()is a thread with a shared class loader,classLoaderIsolation()a thread with its own,processIsolation()a worker daemon; worker daemons live for one build and are reused only inside it. -
The number of concurrent units is limited by
--max-workers/org.gradle.workers.max. -
A build service is a thread-safe, parameterised, lazily created object shared by a build; register it with
registerIfAbsentand consume it with@ServiceReferenceor@InternalplususesService. -
maxParallelUsageslimits how many tasks use a service at once, which matters because the configuration cache runs tasks in parallel. -
Services are not available to class-loader or process-isolated workers.
-
A task must not call
Task.projectwhile it runs; copy the value into an@Inputproperty. -
Dataflow actions (incubating) replace
buildFinished: derive the parameters fromFlowProviders.getBuildWorkResult().