December 13, 2021·4 min read·engineeringnixgarbage-collectioninternals

Nix 2.5 lets new builds cooperate with garbage collection

A socket protocol lets new temporary roots reach an active collector. The release removes a broad wait while preserving coordination around paths being deleted.

A collector deciding which store paths are dead has to learn about paths a new build starts using. One way to avoid that race is to make the build wait for collection to finish. The Nix 2.5 release notes, dated December 13, announce that garbage collection no longer blocks new builds behind the global collector lock. The implementation replaces that broad wait with communication between the process registering roots and the active collector.

The collector source in the release tag makes the mechanism concrete. When adding a temporary root, a process first attempts to acquire a shared GC lock without blocking. If the lock is unavailable because collection is running, it connects to a Unix-domain socket and sends the path to the collector. It waits for an acknowledgement before completing registration.

I am interested in declarative builds because the dependency model makes resource management explainable. This change is useful for the same reason. It preserves an explicit account of what is live while removing a store-wide scheduling obstacle that prevented otherwise unrelated build work from starting.

Temporary roots can arrive during collection

The source maintains per-process temporary-root files. A process creates and locks its file, then appends registered paths to it. These files allow collection to find roots belonging to active work. The new socket path adds a way to communicate registrations after the collector has begun its own view of the roots.

During collection, Nix starts a server for those incoming registrations. The server records the hash part of each received store path in a shared temporary-root set. Collection reads the roots created before its exclusive lock and receives new roots through the socket. The two mechanisms cover different points in time.

This is why simply removing the lock acquisition would not explain a correct implementation. The collector could otherwise make a deletion decision using an outdated set of live paths. The socket protocol supplies the missing information. New build work can cooperate with the collector instead of waiting for the entire pass to end.

The client-side code also handles the collector finishing during connection or communication. A refused connection or a disconnected socket can cause registration to restart. This is necessary because failure to contact a collector can mean the collector has already exited. The process rechecks the situation instead of treating an old observation about the lock as permanently true.

The implementation still uses a global GC lock. The improvement does not mean all synchronization has been removed. Collection obtains its exclusive lock, while a registering process has a route for making progress when it cannot acquire the shared lock. Describing the change as deletion of the global lock would misrepresent the source.

Deletion of the same path still needs coordination

The shared state tracks a pending deletion as well as new temporary roots. When a registration refers to a path currently being deleted, the server waits until deletion finishes before acknowledging it. A comment in the source explains the reason. The client must not start recreating that path while the collector is still removing it.

That is a narrower wait than blocking every new build for the full collection pass. It concerns a conflicting path. Independent registrations can be communicated without treating the whole store as unavailable for build work. The design therefore improves concurrency by reducing the scope of coordination, while retaining coordination where concurrent operations would interfere.

On the deletion side, the collector checks the shared temporary-root set. A path recorded there becomes live, and its closure is marked accordingly. The pending marker and notification mechanism connect that decision to clients waiting for deletion to finish. These details make the change an update to the liveness protocol, not merely an adjustment to progress messages.

The release notes do not quantify a universal build speedup. Avoiding a wait improves responsiveness when a build would previously encounter active collection. It does not eliminate disk activity from the collector or guarantee that build throughput increases on every machine. Both operations still use shared host resources even when a global scheduling dependency no longer serializes them.

I prefer this explanation to calling collection nonblocking without qualification. The source identifies where a request can still wait and why. That makes it possible to distinguish expected same-path coordination from the old broad delay reported by the global-lock message.

Small language additions reduce expression work

Nix 2.5 also adds builtins.groupBy. The implementation's embedded documentation specifies a function returning a string key for each element. The result is an attribute set whose values collect elements mapped to the same key. Its release notes compare its functionality with Nixpkgs' lib.groupBy and describe the builtin as faster, without publishing a general benchmark factor.

builtins.groupBy
  (builtins.substring 0 1)
  [ "foo" "bar" "baz" ]

The documented result groups bar and baz under b, with foo under f. This gives the evaluator a direct operation for a grouping task previously available through library code. The callback still defines what constitutes a group. Making the grouping builtin does not establish that the chosen classification matches a package policy.

The string returned by the callback supplies the attribute name, while each attribute value remains a list of matching input elements.

Lists also gain lexicographic comparison through <. That is another expression-level addition, separate from the collector change. A release can improve evaluation conveniences while also changing how processes coordinate store activity. They affect different layers of the system and should not be combined into a claim about a single performance improvement.

The notes additionally identify a binary-cache compression-level setting and nix develop --unpack, which runs unpackPhase. The latter exposes another build phase directly during development. These additions make specific operations selectable. The major concurrency change remains the ability to register new live paths with an active collector, including an acknowledgement protocol that preserves ordering when a deletion conflicts with that registration.