Skip to content

gc: don't hold the roots lock while marking a closure alive - #769

Open
czawistowski-nvidia wants to merge 1 commit into
DeterminateSystems:mainfrom
czawistowski-nvidia:upstream/gc-mark-alive-unlocked
Open

czawistowski-nvidia wants to merge 1 commit into
DeterminateSystems:mainfrom
czawistowski-nvidia:upstream/gc-mark-alive-unlocked

Conversation

@czawistowski-nvidia

Copy link
Copy Markdown

Motivation

While the garbage collector computes the closure of a temporary root, every other process that registers a temporary root waits for it.

When the referrers walk in collectGarbage() reaches a temporary root, it calls markAlive(), which computes the root's whole closure, while still holding the lock on the collector's shared state. The thread that receives new temporary roots from other processes takes the same lock before it acknowledges a root. So addTempRoot() in every evaluation, build or nix copy on the machine blocks until that closure has been computed, and the larger the closure, the longer they wait. The same happens in the deletion loop when a path became a temporary root after the walk.

markAlive() computes the closure from scratch with one database query per path (see NixOS#15712), so the wait grows with the size of the root's closure, e.g. an evaluation's .drv closure or a system closure that is only held by a temporary root.

Change

Look up the temporary root and set pending under the lock as before, then release it before calling markAlive(). markAlive() only touches state that the collector thread owns (alive, the database), so it doesn't need the lock.

For the test, this adds a _NIX_TEST_GC_SYNC_3 synchronisation point at the first markAlive() call, next to the existing _NIX_TEST_GC_SYNC_1/2.

Testing

New functional test gc-mark-alive-unlocked.sh: it starts a build so that its .drv is held only by a temporary root, runs nix-store --gc until it stops in markAlive() for that .drv, and then runs nix-store --add under a 20 s timeout.

  • Without the change (with only the synchronisation point added) the test fails: nix-store --add blocks until the timeout.

  • With it: gc-mark-alive-unlocked (10 of 10 sequential runs), gc, gc-auto, gc-closure, gc-concurrent, gc-non-blocking, gc-runtime, nix-collect-garbage-d, export-graph, check-reqs and nix-store-tests pass.

  • How long a client waits, measured on current main with and without this change (only libnixstore swapped). Setup: a local store on tmpfs with a synthetic closure, where each path refers to the previous one and up to 7 random earlier ones, held only by a temporary root (builtins.storePath in a waiting evaluation). nix-store --gc runs while another process sends temporary roots over the GC socket back to back and times each acknowledgement. Reported: the longest wait per GC run, n = 10 runs per variant, interleaved, on a shared and loaded VM.

    Closure held by the temporary root Longest wait, before (p50 / max) After (p50 / max)
    2,000 paths 62 ms / 65 ms 0.36 ms / 3.6 ms
    20,000 paths 1.2 s / 1.5 s 3.2 ms / 8.9 ms

    Before the change, the wait is about as long as markAlive() takes; at 20,000 paths that is most of the 1.5 s GC run. A store whose database is on disk, rather than tmpfs, would be slower (not measured). The GC's own run time is unchanged (p50 1.5 s both ways at 20,000 paths).

When the collector finds a temporary root, it computes the root's
closure (`markAlive()`) while still holding the lock on its shared state.
The thread that receives new temporary roots from other processes takes
that same lock, so every `addTempRoot()` in the system (evaluations,
builds, `nix copy`) waits until the closure has been computed, which
takes longer the larger the closure is.

Release the lock before calling `markAlive()`, both in the referrers
walk and when a path became a temporary root before its deletion. The
lock is only needed to look up the root and to set `pending`.

Add a synchronisation point and a test that registers a temporary root
while the collector is inside `markAlive()`.
@coderabbitai

coderabbitai Bot commented Nov 6, 2026

Copy link
Copy Markdown

Warning

Review limit reached

  • Run on-demand review

This review includes 3 billable files and costs up to $0.75.

Or wait 59 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: ff644cb0-9aae-441a-b232-95086fdebcab
Commits

Reviewing files that changed from the base of the PR and between 7fa39d9 and 35a8736.

Files selected for processing: 3

  • src/libstore/gc.cc
  • tests/functional/gc-mark-alive-unlocked.sh
  • tests/functional/meson.build
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant