skip to content

A plugin author uses a static field / Project extension to share a counter across tasks and it breaks under --parallel and the configuration cache. Why, and how does a BuildService fix it?

level: seniorimportance: should knowfreq 30%

answer

  1. statics race under --parallel
  2. worker processes don't share JVM statics
  3. config cache forbids live build-script captures
  4. BuildService = managed, build-scoped, Provider-wired
  5. internal fields still need thread-safety

basics

~10 s

Static/global state isn't safe across parallel tasks or worker processes and the configuration cache forbids capturing live build-script objects. A BuildService is a Gradle-managed, build-scoped, shareable holder that works under both.

solid answer

~50 s

Sharing state through a `static` field or a `Project`-attached property fails for three reasons. (1) **Parallelism**: with `--parallel`, tasks run concurrently and may mutate the shared field from multiple threads, causing races. (2) **Process isolation**: work can run in separate worker processes that don't share the same JVM statics at all, so the counter silently diverges. (3) **Configuration cache**: the cache serializes the task graph and forbids tasks from holding references to arbitrary live build-script objects (like a Project or a captured closure), so such code is rejected. A `BuildService` solves all three: Gradle owns a **single build-scoped instance**, hands it to tasks via a `Provider`, supports declaring a concurrency cap for safe parallel access, and is a first-class cache-compatible input. The service's internal state still must be thread-safe (e.g. an `AtomicInteger`), but the sharing, lifecycle, and cache-compatibility are handled by Gradle.

code

kotlin · 12 lines
kotlin
abstract class StatsService :
    BuildService<BuildServiceParameters.None>, AutoCloseable {
    private val processed = java.util.concurrent.atomic.AtomicInteger()
    fun add(n: Int) = processed.addAndGet(n)
    override fun close() = println("total=${processed.get()}")
}

val stats = gradle.sharedServices
    .registerIfAbsent("stats", StatsService::class.java) {}

tasks.register("a") { usesService(stats); doLast { stats.get().add(3) } }
tasks.register("b") { usesService(stats); doLast { stats.get().add(5) } }

go deeper

for a junior

Recognize that shared statics are unsafe and a BuildService is the right place for shared build state.

for a middle

Explain the three failure modes (parallel races, process isolation, config cache) at a high level and the Provider-wired fix.

for a senior

Detail why each approach breaks, why the Provider is a legal cache input, and the residual need for thread-safe internals.

for a principal

Set the convention that build-scoped cross-task state must use BuildService, eliminating cache- and parallelism-hostile global state across the plugin portfolio.

## Why the naive approaches break ### Static / global field ```kotlin object Stats { var processed = 0 } // BAD ``` Under `--parallel`, multiple tasks increment `Stats.processed` concurrently → lost updates / races. Worse, if any task runs in a **separate worker process**, that process has its own copy of the static — the counts never reconcile. There's also no defined teardown point and the value leaks between builds in a long-lived daemon. ### Project extension / captured closure Hanging the counter on `project.extensions` or capturing it in a `doLast` closure ties task execution to a **live build-script object**. The **configuration cache** stores the task graph between invocations and disallows tasks referencing such objects; the build fails the cache check (or warns) because that state can't be serialized/reused safely. ## Why BuildService is the fix A `BuildService`: 1. **One managed instance per build** — Gradle creates and shares it; no accidental per-process divergence within the model Gradle controls, and it's disposed at build end. 2. **Provider-based wiring** — tasks reference a `Provider<Service>`, which is a legal, serializable configuration-cache input. The provider, not a live closure, is what the task holds. 3. **Parallel-safe access** — the service can declare a maximum number of concurrent users, and tasks declare `usesService`, so Gradle throttles access. The service's own fields should still be thread-safe. 4. **Defined lifecycle** — `AutoCloseable.close()` gives a clean place to emit the final aggregated value. ```kotlin abstract class StatsService : BuildService<BuildServiceParameters.None>, AutoCloseable { private val processed = java.util.concurrent.atomic.AtomicInteger() fun add(n: Int) = processed.addAndGet(n) override fun close() = println("Processed total: ${processed.get()}") } val stats = gradle.sharedServices.registerIfAbsent("stats", StatsService::class.java) {} tasks.register("a") { usesService(stats); doLast { stats.get().add(3) } } tasks.register("b") { usesService(stats); doLast { stats.get().add(5) } } ``` Now `a` and `b` can run in parallel, share one instance, and the total is printed once at build end. ## The design takeaway The rule of thumb: **any state that must outlive a single task but live within one build, and be touched by more than one task, belongs in a BuildService — never in a static or a Project property.** That makes the build parallel-safe and configuration-cache-compatible by construction.

  • Does using a BuildService mean you no longer need any thread-safety in your own code?
    No. Gradle shares one instance that parallel tasks can touch, so the service's internal fields must still be thread-safe (e.g. AtomicInteger, synchronization).
  • Why specifically does the configuration cache reject the Project-extension approach?
    Tasks would hold a reference to a live, non-serializable build-script object; the config cache stores the task graph and forbids such captured state.
  • Where would you emit the aggregated counter once all tasks finish?
    In the service's AutoCloseable.close(), which Gradle runs once at build end.

A static field is like leaving a shared notepad on whatever desk happens to be free — if two people grab it at once, or someone takes it to another room (process), the notes desync. A BuildService is a single notepad Gradle hands out and collects, with rules for who can write at the same time.

saying these in an interview costs you the question

  • Saying 'just synchronize the static field' — it still fails across worker processes and the config cache.
  • Believing a BuildService removes the need for internal thread-safety.
  • Proposing gradle.buildFinished {} to print the total (cache-hostile).

context