#149 May 19, 2026

149. OnceLock::wait — Block a Thread Until Another One Initializes the Value

You have one thread loading a config and a handful of workers that can’t start until it’s ready. OnceLock::wait blocks until the value lands — no Condvar, no Mutex, no spin loop.

OnceLock<T> is a write-once cell: any number of threads can race to set it, but only the first wins. The usual reader API is get, which returns None until something has been stored:

1
2
3
4
5
6
use std::sync::OnceLock;

let cell: OnceLock<u32> = OnceLock::new();
assert_eq!(cell.get(), None);
cell.set(42).unwrap();
assert_eq!(cell.get(), Some(&42));

That’s fine when the reader can keep working without the value. But what if the reader genuinely needs it now? Before Rust 1.86 you’d reach for a Mutex<Option<T>> plus a Condvar, or spin in a loop calling get — both more code and more bugs than the problem deserves.

OnceLock::wait parks the calling thread until the cell is initialized, then hands back &T:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
use std::sync::OnceLock;
use std::thread;
use std::time::Duration;

static CONFIG: OnceLock<String> = OnceLock::new();

thread::scope(|s| {
    // Producer: pretend this is loading config from disk.
    s.spawn(|| {
        thread::sleep(Duration::from_millis(20));
        CONFIG.set("rustbites=on".into()).unwrap();
    });

    // Consumers: block until the producer is done, then read.
    for _ in 0..3 {
        s.spawn(|| {
            let cfg: &String = CONFIG.wait();
            assert_eq!(cfg, "rustbites=on");
        });
    }
});

Every consumer gets back the same &StringOnceLock only ever holds one value, so the borrow is shared and lives as long as the cell does. No cloning, no Arc wrapping.

wait plays nicely with the existing init helpers. If you have a fallible initializer that some threads might run and others just want to await, mix get_or_init with wait:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
use std::sync::OnceLock;
use std::thread;

static GREETING: OnceLock<String> = OnceLock::new();

thread::scope(|s| {
    s.spawn(|| {
        // First thread here pays the cost; others get the cached value.
        GREETING.get_or_init(|| "hello, bites".into());
    });
    s.spawn(|| {
        // This thread doesn't care who initialized — just wants the value.
        assert_eq!(GREETING.wait(), "hello, bites");
    });
});

A few things worth knowing:

  • wait blocks forever if nobody ever calls set (or get_or_init succeeds). It’s a synchronization primitive, not a timeout — pair it with thread::spawn for a producer you actually control.
  • It’s &self, so any number of threads can wait on the same cell at once.
  • OnceLock<T> requires T: Send + Sync to be shared across threads, same as Arc.

For lazy-init that runs on first read, LazyLock is still the right tool. But when initialization happens elsewhere and other threads need to pause until it’s done, wait turns a Condvar dance into one method call.

← Previous 148. Result::is_ok_and — Test the Variant and the Value in One Call Next → 150. Vec::spare_capacity_mut — Fill a Vec From a Callback Without Zeroing It First