Condition variables

A mutex lets one thread at a time touch a value, and a condition variable adds the other half: a thread can sleep until the value changes instead of asking again and again. Condvar is always used with a Mutex, because the wait releases the lock and the wake has to look at the same state under the same lock.

This lesson signals between two threads, builds a queue with a waiting consumer, tries a timed wait, wakes two waiters at once, and ends with the compiler error for a guard that was passed to wait and then used.

A signal between two threads

The pair is a mutex holding a flag and a condition variable beside it. The waiter takes the lock, checks the flag and waits while it is false; the waiter loop is what makes a signal that arrives before the wait harmless.

The worker sets the flag and notifies. The main thread either sees the flag already set or is woken by the notification, and both orders print the same line.

use std::sync::{Arc, Condvar, Mutex}; use std::thread; fn main() { let pair = Arc::new((Mutex::new(false), Condvar::new())); let shared = Arc::clone(&pair); let worker = thread::spawn(move || { let (lock, condvar) = &*shared; let mut started = lock.lock().unwrap(); *started = true; condvar.notify_one(); }); let (lock, condvar) = &*pair; let mut started = lock.lock().unwrap(); while !*started { started = condvar.wait(started).unwrap(); } worker.join().unwrap(); println!("started: {}", *started); }

rustc --edition 2024 started.rs
./started

started: true

The destructuring let (lock, condvar) = &*shared is the idiom: the tuple is behind an Arc, so one dereference gives references to both halves without moving them. The lesson on Mutex and Arc sets up the shared pair.

The notification is usually sent while the lock is held, and the standard permits it either way; holding the lock avoids a wakeup that arrives between the change and the notification. A missed wakeup is not a correctness bug when the wait is inside a predicate loop, which is why the loop is not optional.

A queue and a waiting consumer

A queue is the standard use: the producer pushes and notifies, the consumer waits while the queue is empty and pops. The condition is the queue itself, so no separate flag can disagree with the data.

The producer sends three values and the consumer collects them until it has three. The vector is printed after the join, so the order is the order of the queue and not of the threads.

use std::collections::VecDeque; use std::sync::{Arc, Condvar, Mutex}; use std::thread; fn main() { let pair = Arc::new((Mutex::new(VecDeque::new()), Condvar::new())); let shared = Arc::clone(&pair); let producer = thread::spawn(move || { let (lock, condvar) = &*shared; for value in 1..=3 { let mut queue = lock.lock().unwrap(); queue.push_back(value * 10); condvar.notify_one(); } }); let (lock, condvar) = &*pair; let mut values = Vec::new(); let mut queue = lock.lock().unwrap(); while values.len() < 3 { while queue.is_empty() { queue = condvar.wait(queue).unwrap(); } if let Some(value) = queue.pop_front() { values.push(value); } } producer.join().unwrap(); println!("{values:?}"); }

rustc --edition 2024 queue.rs
./queue

[10, 20, 30]

The inner loop is the predicate, and the outer loop is the work. A wait that returns without the queue having anything — a spurious wakeup, which the standard allows — goes round the inner loop again instead of popping from an empty queue. The queue type comes from the lesson on VecDeque.

A timed wait

wait_timeout waits at most for a duration and reports whether the time ran out. The guard comes back either way, so the caller keeps the lock and can look at the state, which is what a loop with a deadline needs.

The program waits fifty milliseconds for a flag that nobody sets. The result says the wait timed out and the flag is still false.

use std::sync::{Condvar, Mutex}; use std::time::Duration; fn main() { let pair = (Mutex::new(false), Condvar::new()); let guard = pair.0.lock().unwrap(); let (guard, result) = pair.1.wait_timeout(guard, Duration::from_millis(50)).unwrap(); println!("timed out: {} value: {}", result.timed_out(), *guard); }

rustc --edition 2024 timeout.rs
./timeout

timed out: true value: false

The duration is the only part of this program that depends on the machine, and fifty milliseconds is far above the cost of the call. A real deadline loop passes the remaining time on each round, which is why Instant and Duration from the lesson on time are the pair to use with it.

Notifying several waiters

notify_one wakes one waiter and notify_all wakes every one of them. Two consumers that wait for the same value use the second, and each of them checks the predicate again after waking, because another waiter may have consumed the change.

Two workers wait for a value that starts at zero; the main thread sets it to seven and wakes both. Each worker returns the value it saw, and the collected result is the same number twice.

use std::sync::{Arc, Condvar, Mutex}; use std::thread; use std::time::Duration; fn main() { let pair = Arc::new((Mutex::new(0), Condvar::new())); let mut handles = Vec::new(); for _ in 0..2 { let shared = Arc::clone(&pair); handles.push(thread::spawn(move || { let (lock, condvar) = &*shared; let mut value = lock.lock().unwrap(); while *value == 0 { value = condvar.wait(value).unwrap(); } *value })); } thread::sleep(Duration::from_millis(50)); { let (lock, condvar) = &*pair; let mut value = lock.lock().unwrap(); *value = 7; condvar.notify_all(); } let mut results: Vec<i32> = handles.into_iter().map(|h| h.join().unwrap()).collect(); results.sort(); println!("{results:?}"); }

rustc --edition 2024 all.rs
./all

[7, 7]

The sleep is there to let both workers reach the wait before the notification, which makes the demonstration reliable without changing the logic: with notify_all the main thread does not need to know how many waiters there are. The lesson on scoped threads removes the Arc from the same program.

The guard goes into the wait

wait takes the guard by value, releases the lock and returns the guard when it wakes. A program that keeps the old binding and reads it afterwards has moved the guard into the call, and the compiler reports E0382.

The program waits and then prints the guard it passed. The compiler points at the move and at the use.

use std::sync::{Condvar, Mutex}; fn main() { let pair = (Mutex::new(false), Condvar::new()); let guard = pair.0.lock().unwrap(); let _ = pair.1.wait(guard).unwrap(); println!("{}", *guard); }

rustc --edition 2024 moved_err.rs

error[E0382]: borrow of moved value: `guard`
--> moved_err.rs:7:21
|
5 | let guard = pair.0.lock().unwrap();
| ----- move occurs because `guard` has type `MutexGuard<'_, bool>`, which does not implement the `Copy` trait
6 | let _ = pair.1.wait(guard).unwrap();
| ----- value moved here
7 | println!("{}", *guard);
| ^^^^^ value borrowed here after move
|
= note: borrow occurs due to deref coercion to `bool`

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0382`.

The fix is to keep the value that wait returns, which is the same guard with the lock taken again: let guard = pair.1.wait(guard).unwrap();. The move is the safety: while the thread is asleep it holds no lock, and the type system says so by taking the guard away. The next lesson replaces the lock with a single atomic instruction.

Exercises

  1. Add a second producer to the queue and check that both values arrive.
  2. Replace the flag with a counter and wake the waiter when it reaches three.
  3. Use wait_timeout in a loop with a deadline and print how many rounds it took.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. Why is a Condvar always paired with a Mutex?

    Answer

    The wait releases the lock and the state must be checked under it

    The predicate and the wake have to see the same state, so both live under one lock.

  2. What must a caller do after wait returns?

    Answer

    Check the predicate again in a loop

    A wake can be spurious or another thread may have taken the change.

  3. What does notify_all do?

    Answer

    Wakes every thread waiting on the condvar

    Every waiter wakes and each one checks the predicate again.

  4. What does wait_timeout return?

    Answer

    The guard and a result that says whether it timed out

    The caller keeps the lock and learns why the wait ended.

  5. What does this program print? use std::sync::{Condvar, Mutex}; fn main() { let pair = (Mutex::new(1), Condvar::new()); let guard = pair.0.lock().unwrap(); let (guard, result) = pair.1.wait_timeout(guard, std::time::Duration::from_millis(10)).unwrap(); println!("{} {}", result.timed_out(), *guard); }

    Answer

    true 1

    Nobody notifies, so the wait times out and the value is unchanged.

  6. What does this program print? use std::sync::{Arc, Condvar, Mutex}; use std::thread; fn main() { let pair = Arc::new((Mutex::new(false), Condvar::new())); let shared = Arc::clone(&pair); let worker = thread::spawn(move || { let (lock, condvar) = &*shared; *lock.lock().unwrap() = true; condvar.notify_one(); }); let (lock, condvar) = &*pair; let mut value = lock.lock().unwrap(); while !*value { value = condvar.wait(value).unwrap(); } worker.join().unwrap(); println!("{}", *value); }

    Answer

    true

    The worker sets the flag and notifies, and the predicate loop sees the new value.

  7. What does this program print? use std::sync::{Condvar, Mutex}; fn main() { let pair = (Mutex::new(0), Condvar::new()); let guard = pair.0.lock().unwrap(); let (mut guard, _) = pair.1.wait_timeout(guard, std::time::Duration::from_millis(5)).unwrap(); *guard += 5; println!("{}", *guard); }

    Answer

    5

    The timed wait returns the guard, which is used to add five to the value.

  8. What does this program print? use std::sync::{Arc, Condvar, Mutex}; use std::thread; fn main() { let pair = Arc::new((Mutex::new(0), Condvar::new())); let shared = Arc::clone(&pair); let worker = thread::spawn(move || { let (lock, condvar) = &*shared; let mut value = lock.lock().unwrap(); *value = 3; condvar.notify_one(); *value }); let value = worker.join().unwrap(); println!("{value}"); }

    Answer

    3

    The worker returns the value it stored, and the join prints it.

  9. What is the first line of the compiler output for this program? use std::sync::{Condvar, Mutex}; fn main() { let pair = (Mutex::new(false), Condvar::new()); let guard = pair.0.lock().unwrap(); let _ = pair.1.wait(guard).unwrap(); println!("{}", *guard); }

    Answer

    error[E0382]: borrow of moved value: guard

    wait takes the guard by value, so the binding cannot be read after the call.

  10. What is the first line of the compiler output for this program? use std::sync::Mutex; fn main() { let lock = Mutex::new(0); lock.notify_one(); }

    Answer

    error[E0599]: no method named notify_one found for struct std::sync::Mutex<T> in the current scope

    The notification belongs to the Condvar beside the mutex, not to the mutex itself.

The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev