Skip to content

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

Hard Framework

Coordinate two threads with a condition variable

Use two threads to append alternating messages to a shared log. One thread writes ping, the other writes pong. Return the log after both finish.

pub fn ping_pong(rounds: usize) -> Vec<String>

Produce exactly 2 * rounds entries. For each zero-based round i, append ping:{i} followed by pong:{i}. Zero rounds returns an empty vector. Inputs are small enough to build the output in memory and satisfy rounds <= usize::MAX / 2, so the turn-count multiplication fits.

rounds = 3 -> ["ping:0", "pong:0", "ping:1", "pong:1", "ping:2", "pong:2"]
rounds = 0 -> []

Keep the starter's shared Arc<(Mutex<(usize, Vec<String>)>, Condvar)>: the mutex protects both a turn counter, initially zero, and the log. Worker 0 acts on even turns; worker 1 acts on odd turns. Each appends its message and increments the counter while holding the lock.

Practice blocking with a condition variable rather than repeatedly polling the counter. The harness checks the returned sequence; that alone does not prove an implementation used two threads or avoided spinning.

Store the condition in the protected state

The predicate is a boolean expression over the mutex-protected state. Here, a worker may proceed when it is that worker's turn or when all turns are complete. The condition variable provides waiting and notification; it does not store your application predicate or queue notifications for future waits.

Check the predicate while holding the lock, then wait while it is false. After every return from waiting, check again. A wakeup may be spurious. In programs with several consumers, another thread may also change the relevant state before the awakened thread reacquires the lock.

wait_while can perform this loop for you. Its closure returns true when the thread should keep waiting, not when it is ready to proceed.

Why the starter loses its guard

Condvar::wait takes ownership of a MutexGuard. It atomically unlocks the mutex and blocks the thread, then reacquires the lock before returning a guard. Releasing the lock and entering the wait must be coordinated so a notifier cannot slip through an unprotected check-to-wait gap.

The starter ignores the returned guard, then tries to use the moved guard again. This produces E0382. Assign the result back:

guard = cv.wait(guard).expect("state poisoned");

This matches the starter's panic-on-poison policy. Using ? would require a compatible return type; the worker closure here returns (). Wait returns a poisoning error if the mutex is poisoned when it reacquires the lock.

This complete program uses the same pattern to wait for a worker to set a flag. It works whether the worker updates the flag before or after the main thread starts waiting:

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

fn main() {
    let shared = Arc::new((Mutex::new(false), Condvar::new()));
    let worker_state = Arc::clone(&shared);
    let worker = thread::spawn(move || {
        let (lock, cv) = &*worker_state;
        let mut ready = lock.lock().expect("state poisoned");
        *ready = true;
        drop(ready);
        cv.notify_one();
    });

    let (lock, cv) = &*shared;
    let mut ready = lock.lock().expect("state poisoned");
    while !*ready {
        ready = cv.wait(ready).expect("state poisoned");
    }
    assert!(*ready);
    drop(ready);
    worker.join().expect("worker panicked");
}

Choosing a notification strategy

Keep notify_all() in the starter. It wakes all current waiters so each can recheck its condition. Waking a thread does not transfer the lock or guarantee which thread runs next.

notify_one() is also sufficient for this particular two-worker protocol. A worker is not waiting while it calls notify, so the only possible other waiter is its partner. If that partner has not started waiting, it checks the updated counter under the lock before deciding to wait. Both workers must still notify after updates and handle termination correctly.

Do not generalize that argument to several waiters with different predicates. A single notification can select a waiter whose condition remains false, leaving another eligible waiter asleep. Broadcasting, or using separate condition variables for different conditions, can make those protocols easier to reason about.

Finish without leaving a worker asleep

The waiting condition must include turn < total. When the counter reaches total, neither worker should wait for another turn. Wake any remaining waiter after the final update, then join both worker threads before taking the log out of the shared state.

The starter drops the guard before notifying. Notifying while holding the lock is also valid: a woken worker cannot inspect the state until it obtains that lock. The better timing depends on the protocol and platform; neither order is universally faster.

Associate this condition variable with the same mutex throughout its use. Rust's API permits a runtime panic if it is used with multiple mutexes; do not rely on every platform detecting misuse.

Keep lock lifetimes clear with named guards and explicit scope boundaries where needed. In particular, release a guard before waiting for a worker that needs that lock. Spinning while retaining the lock would prevent the other worker from changing the state at all.