July 25, 2024·6 min read·engineeringrustinitializationconcurrencycargo

Rust 1.80 gives lazy initialization a stored initializer

LazyCell and LazyLock pair deferred data with its initializer. Their synchronization and poisoning behavior define when the convenience fits, while Cargo gains automatic cfg checking.

A value that initializes itself on first access needs to carry both storage and a rule for producing its contents. Rust's existing once-initialization types provide the storage, but their callers supply an initializer when requesting the value. Rust 1.80, released on July 25, stabilizes LazyCell and LazyLock, which keep that initializer inside the lazy value.

That difference changes who needs to know how initialization works. A reader can dereference the lazy value without repeating its construction rule. The type handles the transition from an initializer to an initialized result, with a synchronized form for shared use and an unsynchronized form for local use.

I find this useful because it gives deferred initialization an ordinary library type. Rust's attraction for systems work includes precise ownership and concurrency boundaries. A lazy value should make those boundaries easier to state, including what happens when construction fails by panicking.

The initializer belongs to the value

The release announcement compares the new types with OnceCell and OnceLock, stabilized in Rust 1.70. Those types support one-time storage, while LazyCell and LazyLock additionally contain the function that creates the stored value. Accessing the lazy value can therefore trigger initialization without requiring the caller to supply that function.

The release-tag LazyLock implementation shows the state transition. Its storage initially contains the initializer. The first successful initialization consumes that function and replaces it with the resulting value. The internal Once state records whether initialization has completed.

The initialization function implements FnOnce, which fits a transition that consumes it. It may capture values needed to construct the result. Once the lazy value has initialized, later accesses borrow the stored result instead of invoking the initializer again.

This differs from a cache that recomputes after eviction. The type is designed around one initialization and subsequent reads. A caller that needs replacement or a retry policy has a different state-management problem to express.

LazyLock coordinates the first access across threads

LazyLock is the thread-safe form. Its documentation says dereferencing blocks if another thread is currently running initialization. The waiting reader receives access to the initialized result after that operation completes. It does not run a competing initialization and choose between the results.

The release announcement demonstrates a static time value that two threads access. Whichever thread reaches it first performs initialization, and both then observe the same stored instant. The relevant property is agreement on one initialized value, not an assumption about which thread wins.

The type's constructor can be used in a static declaration while the actual initializer runs later. This separates setting up the lazy container from computing its data. A value whose construction needs runtime work can therefore be stored behind a statically declared lazy container.

LazyLock::force provides an explicit initialization operation returning a reference. Ordinary dereferencing uses the same mechanism. This gives callers a way to state that initialization should happen at a particular point even when later consumers use ordinary dereferences.

That can matter when the first use lies on a latency-sensitive operation. Deferring work changes when its cost occurs. Calling force earlier can establish an explicit initialization point without changing the type's later read interface. The type controls one-time execution, while the application controls when it first requests the result.

LazyCell omits cross-thread synchronization

The release-tag LazyCell source provides the corresponding local state machine. It stores an uninitialized function, an initialized value, or a poisoned state. It does not provide the synchronization used by LazyLock.

The release announcement states that LazyCell does not implement Sync. It therefore cannot serve as the directly shared static value in the preceding example. It can be used within other data structures or in thread-local statics, where each thread has its own initialization.

That is a semantic choice, not merely a naming distinction. A thread-local lazy value initializes separately for each thread. A shared LazyLock coordinates readers around one result. Selecting the local form to avoid synchronization changes which accesses share an initialized object.

Neither type is restricted to global data. A struct can hold a lazy field, allowing the containing object to defer a computation until a caller needs it. The synchronization requirement follows how that containing value is shared. Deferred work alone does not imply a global singleton.

A panic consumes the initialization attempt

The implementations make initialization failure important. LazyLock uses Once, and its source comments describe the poisoned state after an initializer panics. LazyCell explicitly replaces its uninitialized state with poison before invoking the initializer. Successful construction then replaces poison with the value.

For the local type, that temporary poison also prevents reentrant access from attempting to use the initializer again while it is already running. If the initializer panics, the state remains poisoned. Later forced access panics instead of silently starting over.

This is why a panic should not be confused with a retryable initialization result. The initializer is an FnOnce value that the implementation has taken out of storage. A successful result can itself be a Result, if that is the application's chosen stored type. In that case, an ordinary error value is still a completed initialization result.

The distinction gives a practical way to reason about fallible construction. Returning an error stores an error under the application's type contract. Panicking interrupts the lazy type's construction and leaves poison. Automatically expecting another attempt would impose a policy that these lazy types do not provide.

Static lifetime has another consequence visible in the LazyLock documentation. Static items do not run Drop at program termination. A lazy static holding a resource should therefore not be treated as an automatic shutdown hook. A local owning value has a different destruction context from a static declaration.

For a locally owned LazyLock, its destructor distinguishes incomplete and complete initialization. Before initialization it drops the stored function, including its captured values. After successful initialization it drops the stored result. This follows the representation transition directly. Deferring a computation therefore does not require leaking the data captured to perform it when the containing lazy value is dropped before first use.

I prefer having those consequences available in the type's documented behavior. Convenience at the read site is useful only if it does not hide assumptions about retries or cleanup. One-time initialization and process shutdown are separate concerns.

Cargo now checks expected configuration names

Rust 1.80 also enables Cargo integration for conditional-compilation checking. The previous release stabilized rustc's --check-cfg flag. Cargo now supplies known configuration names and values, including feature names from Cargo.toml and declarations from build scripts.

The warn-by-default unexpected_cfgs lint identifies conditions outside those expected sets. The announcement's example misspells a feature name. A warning appears whether or not the correctly spelled feature is enabled, so the check does not depend on activating the branch containing the mistake.

That matters because conditional code can otherwise disappear quietly. A false condition may be intentional, or it may be a typo. Comparing conditions with the declared configuration vocabulary gives the compiler evidence to distinguish those cases without executing the branch.

Custom configuration remains possible. The announcement shows extending the accepted names and values through the manifest's lint configuration, and it names cargo::rustc-check-cfg for build-script output. The mechanism checks agreement between declared possibilities and source conditions. It does not choose the application's active features for it.

This complements the initialization additions in a useful way. Lazy types make an initialization rule available without repeating it at each read. Configuration checking makes the accepted build vocabulary available where conditions are checked. Both reduce the amount of agreement that must otherwise remain implicit between distant parts of a program.

The stable interface has a defined boundary

The tagged sources distinguish stable operations from additional experimental methods. Construction, dereferencing, and explicit forcing are available for the new lazy types in this release. The displayed consuming into_inner methods remain feature-gated. An example copied from current documentation needs to respect that boundary when targeting Rust 1.80.

For ordinary use, the released contract is enough to state the design. Store the initializer with the value, choose synchronization according to sharing, and request the value when construction should occur. Subsequent readers receive the same initialized result. Panics leave poison, while application error values remain ordinary stored results.