skip to content

With --parallel enabled, how do task and project dependencies still serialize work, and how does Gradle decide what may run at the same time?

level: middleimportance: should knowfreq 45%

answer

  1. single execution DAG across all projects
  2. task runs when all deps finished
  3. ready frontier dispatched onto worker leases
  4. critical path bounds speedup
  5. same output dir / project lock serializes

basics

~20 s

Gradle builds one merged task graph (a DAG). A task only runs once all its dependencies finish, so dependency edges force ordering even in parallel mode. Independent branches with no edges between them run concurrently up to the worker-lease limit.

solid answer

~40 s

Parallelism doesn't ignore dependencies — it respects them. Gradle computes a single **directed acyclic graph** of all requested tasks across all projects (the *execution graph*), including edges from `dependsOn`, declared inputs/outputs (implicit dependencies), and project dependencies (`:app` needs `:lib:jar`). With `--parallel`, the scheduler repeatedly looks for tasks whose dependencies are all finished and dispatches them onto free **worker leases** (`org.gradle.workers.max`). So a dependency chain `A → B → C` still runs strictly in order, while two independent chains run side by side. Additional constraints further serialize work: tasks sharing the same **output directory** or holding the same project lock can't overlap, and ordering rules like `mustRunAfter`/`shouldRunAfter` add edges without creating dependencies. The result is maximum safe concurrency bounded by the graph's critical path and the lease count.

code

kotlin · 3 lines
kotlin
tasks.register("compileFoo")
tasks.register("compileBar")            // independent -> can run in parallel
tasks.register("link") { dependsOn("compileFoo", "compileBar") } // waits for both

go deeper

for a junior

Know that dependencies still force order and only independent tasks run together.

for a middle

Explain the merged DAG, the ready-frontier scheduling onto leases, and implicit input/output edges.

for a senior

Reason about critical path vs core count and the extra serialization from shared outputs/locks.

for a principal

Shape the module graph (flatten/widen, split god-modules) to maximize the parallel frontier across the codebase.

## One graph to rule the build After configuration, Gradle has a set of requested tasks. It expands them into a single **execution graph** — a directed acyclic graph (DAG) — by following every ordering constraint: - **Explicit:** `dependsOn` / `finalizedBy`. - **Implicit:** if task B consumes a file that task A declares as an output, Gradle infers `B dependsOn A`. This is why correctly declared `@OutputFile`/`@InputFiles` matter for both correctness and parallelism. - **Project dependencies:** `implementation(project(":lib"))` makes `:app:compileJava` depend on `:lib:jar`. - **Ordering-only:** `mustRunAfter` / `shouldRunAfter` add ordering edges that don't pull a task into the graph. ## How the scheduler runs it in parallel The daemon holds a pool of **worker leases**, sized by `org.gradle.workers.max` (default = CPU cores). The scheduler: 1. Finds *ready* tasks — every dependency completed. 2. Acquires a free lease for each and starts it on a worker thread. 3. As tasks finish and release leases, newly-ready tasks are dispatched. So concurrency = (number of independent ready tasks) capped by (free leases). A linear chain pins throughput to its **critical path** no matter how many cores you have. ## Extra serialization beyond dependency edges Even independent tasks may not overlap if they conflict: - **Same output location:** two tasks writing the same directory/file are serialized to avoid corruption. - **Project lock:** mutating a project's state requires a lock; mutating tasks serialize on it. - **Destroyables / `localState`:** declared destroy relationships force ordering so a clean doesn't race a producer. ## Practical implications ```kotlin tasks.register("b") { dependsOn("a") } // A then B, always tasks.register("report") { mustRunAfter("test") } // ordering only, no dependency ``` - A *flat, wide* module graph parallelizes well; a *deep, narrow* one is throttled by its critical path. - Missing input/output declarations can both break correctness and accidentally let conflicting tasks run together — declare them precisely. - More cores help only up to the width of the ready frontier; past that, `workers.max` and the critical path dominate.

  • Why might adding more CPU cores not speed up a build?
    If the graph is a deep dependency chain, throughput is bounded by the critical path; extra cores have no independent ready tasks to run.
  • How can two tasks with no declared dependsOn still be serialized?
    If they write the same output directory, share a project lock, or have destroy/local-state relationships, Gradle serializes them to avoid races.
  • How does Gradle infer an implicit task dependency?
    When one task's declared input is another task's declared output, Gradle adds an edge automatically — making accurate @Input/@Output annotations essential.

saying these in an interview costs you the question

  • Claiming --parallel ignores dependency order — it strictly respects the DAG.
  • Assuming N cores gives N× speedup regardless of graph shape.
  • Thinking undeclared inputs/outputs are harmless — they break implicit ordering and can cause races.

context