How to use condvar for thread synchronization

Use Condvar with a Mutex to block threads until a specific condition is signaled by another thread.

When a thread needs to sleep until something happens

You have a worker thread waiting for a job. Spinning in a loop checking a flag wastes CPU cycles doing nothing. Sleeping for a fixed duration introduces latency and still wastes cycles if you wake up too early. You need a way to say "I'm waiting for this specific condition to become true, and wake me up exactly when it happens."

That's what Condvar does. It lets a thread release a lock and go to sleep until another thread signals that the condition has changed. The waiting thread wakes up, re-acquires the lock, and checks the condition again. This pattern is the backbone of efficient thread synchronization in Rust.

The condition variable and the mutex

Condvar stands for condition variable. It is a synchronization primitive that manages a set of waiting threads. It does not hold data. It only handles sleeping and waking.

You always use a Condvar with a Mutex. The mutex guards the shared state you are checking. The condvar handles the blocking. The two must work together to prevent race conditions.

Imagine a waiter at a restaurant. The waiter checks the kitchen window for food. If there is no food, the waiter sleeps. When the cook finishes a dish, the cook rings a bell. The waiter wakes up, checks the window, and takes the food.

The kitchen window is the shared state. The lock on the window is the Mutex. The bell is the Condvar. The waiter cannot ring the bell without holding the lock on the window. This ensures the waiter checks the window and goes to sleep in one atomic step. If the waiter could check the window, decide to sleep, and then the cook added food and rang the bell before the waiter actually slept, the waiter would miss the signal and sleep forever. The mutex prevents this gap.

Minimal example

Here is the smallest working pattern. We pair a Mutex and a Condvar inside an Arc so multiple threads can share them.

use std::sync::{Arc, Condvar, Mutex};
use std::thread;

fn main() {
    // Pair the mutex and condvar together.
    // They must travel as a unit to avoid race conditions.
    let pair = Arc::new((Mutex::new(false), Condvar::new()));
    
    // Convention: use Arc::clone explicitly.
    // pair.clone() looks like a deep clone but isn't.
    // Arc::clone makes the intent clear.
    let pair_clone = Arc::clone(&pair);

    // Spawn a thread that waits for the flag to become true.
    let waiter = thread::spawn(move || {
        // Deref the Arc to get a reference to the tuple.
        let (lock, cvar) = &*pair_clone;
        
        // Lock the mutex before waiting.
        // wait_while requires a MutexGuard.
        let guard = lock.lock().unwrap();
        
        // Wait until the flag is true.
        // wait_while releases the lock, sleeps, and re-acquires the lock.
        // It loops internally to handle spurious wakeups.
        let guard = cvar.wait_while(guard, |flag| !*flag).unwrap();
        
        println!("Woke up! Flag is now: {}", *guard);
    });

    // Give the waiter a moment to start waiting.
    thread::sleep(std::time::Duration::from_millis(100));

    // Main thread changes the flag and notifies the waiter.
    let (lock, cvar) = &*pair;
    
    // Lock, update state, then notify.
    *lock.lock().unwrap() = true;
    cvar.notify_one();

    waiter.join().unwrap();
}

Always use wait_while. Spurious wakeups are real, and the loop costs nothing.

What happens under the hood

When the waiter thread calls wait_while, three things happen atomically. The method releases the mutex lock, puts the thread to sleep, and registers the thread with the condvar. The atomicity is crucial. If the lock release and the sleep were separate steps, another thread could acquire the lock, update the state, and notify between those steps. The waiter would sleep and miss the notification.

The main thread acquires the lock, sets the flag to true, and calls notify_one. The notification wakes one waiting thread. The waiter thread wakes up, re-acquires the mutex lock, and evaluates the predicate |flag| !*flag. Since the flag is now true, the predicate returns false. wait_while returns the guard, and the waiter continues execution.

If the predicate returned true, wait_while would go back to sleep. This handles spurious wakeups. The operating system might wake a thread for reasons unrelated to notify_one, such as a signal or an internal scheduler quirk. The predicate ensures the thread only proceeds when the condition is actually met.

Realistic pattern: a blocking queue

A common use case is a producer-consumer queue. One thread produces items. Multiple threads consume items. Consumers block when the queue is empty. Producers wake consumers when items arrive.

use std::collections::VecDeque;
use std::sync::{Arc, Condvar, Mutex};
use std::thread;

struct Queue {
    // The mutex protects the deque.
    data: Mutex<VecDeque<String>>,
    // The condvar wakes consumers when data arrives.
    condvar: Condvar,
}

impl Queue {
    fn new() -> Self {
        Queue {
            data: Mutex::new(VecDeque::new()),
            condvar: Condvar::new(),
        }
    }

    fn push(&self, item: String) {
        let mut queue = self.data.lock().unwrap();
        queue.push_back(item);
        
        // Wake one waiting consumer.
        // If multiple consumers are waiting, only one needs to wake up.
        self.condvar.notify_one();
    }

    fn pop(&self) -> String {
        let mut queue = self.data.lock().unwrap();
        
        // Wait while the queue is empty.
        // This loop is handled by wait_while internally.
        let item = loop {
            if let Some(item) = queue.pop_front() {
                break item;
            }
            
            // wait_while releases the lock, sleeps, and re-acquires.
            // The predicate checks if the queue is still empty.
            queue = self.condvar.wait_while(queue, |q| q.is_empty()).unwrap();
        };
        
        item
    }
}

fn main() {
    let queue = Arc::new(Queue::new());
    let queue_clone = Arc::clone(&queue);

    // Consumer thread.
    let consumer = thread::spawn(move || {
        for _ in 0..3 {
            let item = queue_clone.pop();
            println!("Consumed: {}", item);
        }
    });

    // Producer thread.
    for i in 0..3 {
        queue.push(format!("Item {}", i));
        thread::sleep(std::time::Duration::from_millis(50));
    }

    consumer.join().unwrap();
}

The queue pattern scales. Add more consumers, and the condvar handles the waking automatically.

Pitfalls and compiler errors

Spurious wakeups are the most common bug. If you use wait instead of wait_while, you must write a manual loop. Forgetting the loop means your thread might proceed even when the condition is false. wait_while encapsulates the loop and is the safe default.

Notifying before waiting causes a silent bug. If the producer calls notify_one before the consumer calls wait_while, the signal is lost. The consumer sleeps forever. Always initialize the shared state and spawn waiter threads before sending notifications. If you need to signal immediately, set the condition first, then notify, or use a flag to indicate the system is ready.

The compiler enforces the lock requirement. Condvar::wait takes a MutexGuard. You cannot call wait without holding the lock. If you try to pass a raw Mutex or the wrong guard type, the compiler rejects the code with E0308 (mismatched types). This prevents the race condition where you check state and sleep without holding the lock.

Thundering herd is a performance issue. If you use notify_all when only one thread can proceed, all waiting threads wake up, re-acquire the lock, check the condition, and go back to sleep. This wastes CPU cycles. Use notify_one when only one waiter can make progress. Use notify_all when the state change might satisfy multiple waiters, or when you are signaling a shutdown that requires all workers to wake up and check the exit condition.

Notify before wait is a silent bug. The signal vanishes, and your thread sleeps forever. Initialize state before spawning waiters.

Decision: Condvar versus alternatives

Use Condvar when a thread must sleep until a shared state changes, and you want to wake it immediately without wasting CPU cycles.

Use Arc<AtomicBool> or atomics when you only need a simple flag and don't need to block a thread. Atomics let you check state without locking, but they don't provide a wait and wake mechanism. You would need to poll, which wastes CPU.

Use std::sync::mpsc channels when you are moving ownership of data between threads rather than sharing mutable state. Channels handle synchronization and queueing for you. They are often simpler than a mutex and condvar pair.

Use notify_all instead of notify_one when the state change might satisfy multiple waiting threads, or when you are signaling a shutdown that requires all workers to wake up and check the exit condition.

Pick the tool that matches the data flow. If you're moving data, use a channel. If you're waiting on state, use a condvar.

Where to go next