Skip to content

← Fearless Concurrency: Threads, Channels, Shared State step 19 of 24

Hard Framework

Condvar: a wakeup is a hint, the predicate is the truth

Two threads take strict turns appending to a shared log.

pub fn ping_pong(rounds: usize) -> Vec<String>
rounds = 3  ->  ["ping:0", "pong:0", "ping:1", "pong:1", "ping:2", "pong:2"]
rounds = 0  ->  []

Share an Arc<(Mutex<(usize, Vec<String>)>, Condvar)>: a turn counter and the log under one lock, plus a condition variable. Thread 0 ("ping") may act only when turn % 2 == 0, thread 1 ("pong") only when turn % 2 == 1. After rounds * 2 turns both threads exit and you return the log.

Do not spin. A loop { if my_turn { ... } } burns a core to wait, and on a single-core machine may not even make progress. The whole point of a Condvar is to sleep until there is something to do.

The mental model that transfers everywhere

A condition variable is always three things together:

  1. a Mutex protecting some state,
  2. a predicate over that state — a plain boolean expression,
  3. the Condvar itself, which carries no state at all.

The Condvar is only a doorbell. It holds no value, remembers no signal, and proves nothing. A wakeup is a hint that the predicate may now be worth re-reading. The predicate is the truth.

That is why the wait always goes in a loop:

while !predicate(&guard) {
    guard = cv.wait(guard).unwrap();
}

Two reasons, and only one of them is exotic. The ordinary one: several threads may be woken, and by the time you re-acquire the mutex another thread may have consumed the condition. The exotic one: wakeups can be spuriouswait is permitted to return with nobody having notified anything, and on real platforms it does. Condvar::wait_while packages the loop for you.

This model transfers directly to async: a Waker is exactly this doorbell, and “poll the future again to find out” is exactly “re-check the predicate”.

What wait does, and the starter’s error

pub fn wait<'a, T>(&self, guard: MutexGuard<'a, T>)
    -> LockResult<MutexGuard<'a, T>>

It takes the guard by value and gives you a new one back. That signature is the API telling you the truth about the operation, and it is why the starter fails:

error[E0382]: use of moved value: `guard`
   |     cv.wait(guard).expect("state poisoned");
   |             ^^^^^ value moved here, in previous iteration of loop

wait atomically releases the mutex, sleeps, and re-acquires it before returning. It must consume the guard, because while you are asleep you do not hold the lock — and the type system will not let you keep a guard that no longer guards anything. The atomicity matters: releasing and sleeping as two separate steps would let a notification slip through the gap and be lost forever.

So the fix is guard = cv.wait(guard)?.

notify_all, and the lost wakeup

Use notify_all() here, not notify_one().

notify_one wakes an unspecified single waiter. With two waiters, one condvar, and per-thread predicates, it can wake the thread whose predicate is false — that thread re-checks, goes back to sleep, and the notification is gone. The thread that could have made progress was never woken. Everything stops, and the program hangs with no error.

This is the classic missed-wakeup bug. notify_one is only safe when every waiter is interchangeable — a pool of identical workers on a job queue. When waiters are waiting for different conditions, use notify_all, or give each condition its own Condvar.

Details worth carrying

  • Drop the guard before you notify. Notifying while holding the lock wakes a thread that immediately blocks trying to acquire it. It is correct, just wasteful, and on some platforms measurably so.
  • One Condvar goes with exactly one Mutex. Using the same condvar with two different mutexes may panic at runtime — std checks.
  • Both threads must also be able to exit. Termination is a condition like any other: the predicate has to say “my turn, or we are finished”, and a final notify_all after the loop wakes anyone still asleep.

::: question clippy::let_and_return fires on let v = guard.1.clone(); v and suggests deleting the binding. When is following that suggestion actively dangerous? When the binding is a lock guard, or holds one.

let v = m.lock().unwrap().clone(); v and m.lock().unwrap().clone() do not release the lock at the same moment. Collapsing the binding changes when the temporary guard is dropped, and in the wrong context that turns working code into a deadlock.

Clippy’s own documentation flags this — it is one of the rare lints whose known-problems section says applying it can introduce a deadlock. Worth remembering as a general calibration: a lint is a strong prior, not a proof, and around lock guards the timing it changes is exactly the thing that matters. :::

Loading visualization…