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?
answer
- statics race under --parallel
- worker processes don't share JVM statics
- config cache forbids live build-script captures
- BuildService = managed, build-scoped, Provider-wired
- internal fields still need thread-safety
basics
~10 sStatic/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 sSharing 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 linesabstract 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
Recognize that shared statics are unsafe and a BuildService is the right place for shared build state.
Explain the three failure modes (parallel races, process isolation, config cache) at a high level and the Provider-wired fix.
Detail why each approach breaks, why the Provider is a legal cache input, and the residual need for thread-safe internals.
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).