How to Use Barrier and Condvar in Rust

Use Mutex to protect shared state and Condvar to signal threads when that state changes.

Coordinating threads with Barrier and Condvar

You're building a multi-threaded image processor. You split the image into tiles and spawn threads to process them. Before you can composite the result, every thread must finish its tile. Or you have a worker pool where threads sleep until a job arrives. Threads need to coordinate. Rust gives you Barrier for "wait until everyone is here" and Condvar for "wait until something changes."

Barrier synchronizes a group of threads at a specific point. Condvar lets a thread sleep until shared data meets a condition. Both are low-level synchronization primitives. They give you precise control over thread timing. They also require you to manage the logic carefully.

Barrier: the group gate

Barrier is a synchronization point. You create it with a count of threads that must arrive. Threads call wait(). The first count - 1 threads block. The last thread to arrive unblocks everyone. All threads then continue execution together.

Think of a relay race start. The runners stand at the line. The starter waits until all runners are set. Only then does the gun fire. Barrier is that starter.

use std::sync::{Arc, Barrier};
use std::thread;

fn main() {
    // Create a barrier for 3 threads.
    // Arc allows sharing the barrier across threads.
    let barrier = Arc::new(Barrier::new(3));
    let mut handles = vec![];

    for i in 0..3 {
        let b = barrier.clone();
        handles.push(thread::spawn(move || {
            println!("Thread {i} doing work before barrier");
            
            // Blocks until 3 threads call wait().
            // Returns a BarrierWaitResult.
            let result = b.wait();
            
            // Only one thread gets is_leader() == true.
            // Use this to run cleanup or start the next phase.
            if result.is_leader() {
                println!("Thread {i} is the leader, coordinating next step");
            } else {
                println!("Thread {i} passed barrier");
            }
        }));
    }

    for h in handles {
        h.join().unwrap();
    }
}

wait() returns a BarrierWaitResult. This result tells you if your thread was the last one to arrive. The leader thread can perform actions that only one thread should do, like logging the phase completion or starting a timer. The other threads just continue.

Barriers are reusable. After wait() returns, threads can call wait() again to synchronize at a second point. This pattern fits iterative algorithms where threads compute a phase, synchronize, compute the next phase, and repeat.

The barrier doesn't care who arrives first. It only cares that everyone arrives.

Condvar: waiting for state changes

Condvar stands for condition variable. It lets a thread sleep until a condition becomes true. You always pair Condvar with a Mutex. The mutex protects the shared data. The condvar handles the waiting and signaling.

The pattern is strict. You lock the mutex, check the condition, and if the condition is false, you call wait(). wait() atomically unlocks the mutex and puts the thread to sleep. When the thread wakes up, wait() re-locks the mutex and returns. This atomicity prevents race conditions. If you unlocked the mutex, checked the condition, and then slept, a notification could arrive and get lost.

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

fn main() {
    // Wrap the mutex and condvar together.
    // Arc shares them across threads.
    let pair = Arc::new((Mutex::new(false), Condvar::new()));
    let pair2 = pair.clone();

    // Producer thread sets the condition and notifies.
    thread::spawn(move || {
        let (lock, cvar) = &*pair2;
        let mut ready = lock.lock().unwrap();
        
        // Simulate work
        thread::sleep(std::time::Duration::from_millis(100));
        
        *ready = true;
        cvar.notify_one(); // Wake up one waiting thread
    });

    // Consumer thread waits for the condition.
    let (lock, cvar) = &*pair;
    let mut ready = lock.lock().unwrap();
    
    // Loop is mandatory. Spurious wakeups can occur.
    while !*ready {
        // Atomically unlocks mutex, sleeps, then re-locks.
        ready = cvar.wait(ready).unwrap();
    }
    
    println!("Condition is true, proceeding");
}

The loop around wait() is not optional. Operating systems can wake threads for reasons unrelated to notify. This is called a spurious wakeup. The thread wakes up, re-locks the mutex, and returns from wait(). If you used an if statement, the thread would proceed even though the condition is still false. The loop checks the condition again. If it's still false, the thread calls wait() again.

Trust the loop. The spurious wakeup is real, and the compiler won't save you from it.

notify_one() wakes one waiting thread. notify_all() wakes all waiting threads. Use notify_one() when only one thread can make progress. Use notify_all() when multiple threads might be able to proceed, or when the condition change affects all waiters. If you use notify_one() and the woken thread finds the condition still false, it goes back to sleep. Another thread might need to be woken. In complex scenarios, notify_all() is safer.

Realistic example: phase-based computation

Barriers shine in parallel algorithms with distinct phases. Consider a parallel map-reduce. Threads map data in phase one. They synchronize. Then they reduce the results in phase two.

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

fn main() {
    let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
    let n_threads = 4;
    let barrier = Arc::new(Barrier::new(n_threads));
    
    // Shared accumulator protected by mutex.
    let sum = Arc::new(Mutex::new(0));
    
    let mut handles = vec![];
    
    for i in 0..n_threads {
        let b = barrier.clone();
        let s = sum.clone();
        let chunk = &data[i * 2..(i + 1) * 2];
        
        handles.push(thread::spawn(move || {
            // Phase 1: Map
            let local_sum: i32 = chunk.iter().sum();
            println!("Thread {i} computed local sum: {local_sum}");
            
            // Synchronize: all threads must finish mapping.
            b.wait();
            
            // Phase 2: Reduce
            // Only the leader aggregates to avoid contention,
            // or all threads contribute atomically.
            let mut total = s.lock().unwrap();
            *total += local_sum;
        }));
    }
    
    for h in handles {
        h.join().unwrap();
    }
    
    println!("Final sum: {}", *sum.lock().unwrap());
}

The barrier ensures no thread starts reducing before all threads finish mapping. This prevents partial results and race conditions. The leader pattern isn't used here, but you could use is_leader() to have one thread print the phase transition.

Barriers enforce order without busy-waiting. Threads sleep efficiently until the group is ready.

Pitfalls and compiler errors

Spurious wakeups are the most common Condvar bug. Always use a while loop. If you see code with if !condition { cvar.wait(guard) }, flag it immediately. That code breaks under spurious wakeups.

Deadlocks happen when threads hold locks while waiting. If thread A holds lock 1 and waits for lock 2, and thread B holds lock 2 and waits for lock 1, neither can proceed. Condvar::wait() releases the mutex, so it doesn't cause deadlocks by itself. But if you lock multiple mutexes in different orders, you create a deadlock risk. Lock mutexes in a consistent global order.

If you try to share a type that isn't Send across threads, the compiler rejects you with E0277 (trait bound not satisfied). Barrier and Condvar require Send types when shared via Arc. Most standard types are Send. Closures capturing non-Send data will fail. Check your captures.

Condvar requires a MutexGuard. You can't pass a raw value. The compiler enforces this with type errors. If you try to call wait() without the guard, you get a type mismatch. The guard ensures the mutex is locked when you check the condition and re-locked when you wake up.

The community expects Condvar loops. If you review code and see an if before wait, ask for the justification. The loop is the convention for a reason.

Decision: when to use Barrier vs Condvar vs alternatives

Use Barrier when you need a group of threads to synchronize at a specific point in execution. Use Barrier for phase-based algorithms where threads must complete one stage before starting the next. Use Barrier when you want one thread to act as a leader for coordination tasks.

Use Condvar when threads need to wait for a state change in shared data protected by a Mutex. Use Condvar for producer-consumer patterns where workers sleep until data arrives. Use Condvar when the condition depends on complex state that requires a lock to check safely.

Use channels when you want to pass data between threads rather than just signaling. Channels handle the queueing and synchronization automatically. They are safer and easier to use for message passing. Reach for std::sync::mpsc or async channels before reaching for Condvar for data transfer.

Use atomics when you have a simple flag and don't need complex state checks. AtomicBool or AtomicUsize can replace Condvar for simple readiness signals. Atomics avoid the overhead of mutexes and condvars. Use AtomicBool::wait() in newer Rust versions for efficient spinning with fallback to OS sleep.

Pick the tool that matches the shape of your coordination. Barriers for groups. Condvars for state. Channels for data. Atomics for flags.

Where to go next