Mehdi Akiki
Rust Failure Atlas / Runtime, memory, and library APIs

RFA-174 · Case file with fixtures · Case 146 of 694 · Runtime evidence

Why OnceLock::get Returns None During Initialization

OnceLock::get is an observation that never blocks, so initialization in progress still appears unavailable. Use wait when blocking is intended, or get_or_init when the caller owns the initialization path.

Reviewed
Rust
Rust 1.98.1, edition 2024
Targets
all targets with std threading
Profiles
dev, release, test

Direct answer

What this Rust failure means

Why it happens
get is deliberately non-blocking and treats in-progress initialization as unavailable; wait is the operation that blocks until initialization completes.
First discriminating check
Coordinate inside the initializer and call get before releasing it, separating uninitialized, initializing, and initialized states without sleeps.

OnceLock<T> gives me one value that can be initialized once and read many times. The surprising part is that “being initialized” and “available from get” are not the same state.

The failing program starts initialization on another thread and pauses inside the initializer using channels. At that exact point, the main thread calls get(). It receives None, even though initialization is actively running and will soon produce a value.

No sleep is involved, so the case tests a known state instead of hoping the scheduler creates the right timing.

get is deliberately non-blocking

OnceLock::get returns Some(&T) only after the cell is initialized. Its documentation explicitly says it never blocks. While initialization is in progress, it returns None.

I find it useful to model three observations:

uninitialized   get() -> None
initializing    get() -> None
initialized     get() -> Some(&T)

The first two states are intentionally indistinguishable through get. This keeps a read-only probe cheap and prevents an innocent status check from unexpectedly parking a thread.

wait expresses the blocking contract

If another part of the program is responsible for initialization and my thread must not proceed without the value, OnceLock::wait says that directly.

The repaired program releases the coordinated initializer and calls wait(). The call returns a shared reference after the value has been installed.

This is clearer than polling get() in a loop. Polling either burns CPU, adds arbitrary sleeping, or recreates a notification mechanism badly. wait provides the synchronization operation intended for this state transition.

Of course, blocking forever remains possible if no thread completes initialization. The ownership of startup and its failure policy must still be designed.

get_or_init has a different responsibility

OnceLock::get_or_init is appropriate when the caller can supply the initializer. It returns the existing value or performs initialization itself.

This can simplify lazy global data:

let configuration = CELL.get_or_init(load_configuration);

It is not interchangeable with waiting for a separate owner. Passing a second initializer changes the responsibility model and may duplicate side-effect planning, even though only one value is finally stored.

I choose the method from the caller's role:

  • get when absence is an acceptable immediate observation;
  • wait when another owner must publish the value;
  • get_or_init when this caller may initialize it.

Recursive initialization is not a safe shortcut

An initializer should not try to obtain the same cell again. Depending on the exact operation, this can deadlock or otherwise violate the intended initialization flow. I keep the initializer's dependency graph acyclic and pass required inputs directly when possible.

A hidden cycle is more likely when several global cells initialize one another. For startup-heavy systems, one explicit construction phase often gives clearer failure handling than many independently lazy globals.

Panic and retry need a deliberate policy

If initialization panics, the value is not published. Unlike a poisoned mutex mental model, the cell does not simply expose a partially initialized T. A later initialization attempt can occur.

That behaviour preserves memory safety, but retry may be wrong for a side-effecting initializer. It may already have opened a resource, written external state, or registered something before panicking. I keep fallible work outside the final publication step when I need transactional behaviour.

For recoverable initialization errors, an API and type that can represent the error explicitly is usually clearer than panicking inside get_or_init.

This is also a latency decision

Replacing get with wait is semantically correct only if blocking is acceptable on that thread. A request handler, async executor worker, or event loop may require a different architecture: initialize before serving traffic, propagate readiness asynchronously, or keep an explicit unavailable state.

The standard-library method tells me what the thread does; it cannot choose the service-level latency policy for me.

My concurrency checklist

When a OnceLock read unexpectedly returns None, I ask:

  1. Is the cell uninitialized or currently initializing?
  2. Is this caller observing, waiting, or allowed to initialize?
  3. Which thread guarantees that initialization completes?
  4. What happens if the initializer panics or never returns?
  5. Is blocking acceptable at this call site?
  6. Does the initializer depend directly or indirectly on the same cell?
  7. Are tests coordinated with channels instead of timing sleeps?

The broad principle is that observation and synchronization are separate operations. get observes only a completed value and promises not to wait. wait participates in the state transition. Choosing between them makes the concurrency contract visible in the code.