Skip to content

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

Hard Framework

Guard lifetimes: still holding the lock, still not meaning to

A shared RwLock<Option<u64>> is lazily initialised and then accumulated into, by several workers at once.

pub fn get_or_init_shared(initial: Option<u64>, ops: Vec<u64>, workers: usize) -> u64

The cell starts as initial. Each op x does one of two things:

  • if the cell is still None, initialise it to x;
  • otherwise, add x to what is there.

Return the final contents, with None reported as 0. Treat workers == 0 as 1.

Notice that both branches produce the same total — initial.unwrap_or(0) + ops.iter().sum::<u64>() — regardless of which thread gets there first. That is deliberate: it makes the answer schedule-independent so it can be tested at all, while the structure you have to write is the genuinely racy read-then-write one.

Deadlock in Rust is a guard-lifetime problem

Rust eliminates data races completely. It eliminates deadlocks not at all — and real Rust deadlocks are almost never the textbook “two threads, two locks”. They are one thread, one lock, held longer than the author believed. Guard lifetime is Rust’s deadlock story, which is why it gets a problem of its own.

A MutexGuard or RwLockReadGuard releases the lock in its destructor. So the question “when is this lock released?” is exactly the question “when does this temporary die?” — and temporaries have rules most people have never had to think about precisely.

The starter’s line, and why it is denied

let _ = shared.read().expect("lock poisoned");
error: non-binding let on a synchronization lock
   = help: consider immediately dropping the value using `drop(..)`
           after the `let` statement
   = note: `#[deny(let_underscore_lock)]` on by default

This is deny-by-default in rustc itself — a hard error, not a lint you could argue about. It is one of very few lints promoted to that status, and the reason is that the two lines below look identical and behave in opposite ways:

let _  = m.lock().unwrap();   // guard dropped IMMEDIATELY. lock not held.
let _g = m.lock().unwrap();   // guard lives to end of scope. lock held.

_ is not a binding, it is a wildcard pattern: nothing is bound, so the temporary dies at the end of the statement. _g is a binding, so the guard lives to the end of the block. One character, and in a “lock this around the critical section” context the wildcard version silently protects nothing at all.

Which is exactly why it is denied rather than warned: the compiler cannot tell which you meant, and both possible intentions are better written another way. If you want the lock held, name the guard. If you want it released now, do not take it, or drop(...) it explicitly.

Now the trap the lint is protecting you from

Suppose you “fix” the error by naming the guard:

let _g = shared.read().expect("lock poisoned");   // read lock held
let mut w = shared.write().expect("lock poisoned"); // ...and now ask for write

That self-deadlocks. A writer must wait for all readers to leave, and this thread is one of the readers, and it is currently blocked waiting to become a writer. It will wait forever. std::sync locks are not reentrant and make no promise here — the docs say behaviour when a thread tries to acquire a lock it already holds is unspecified, and in practice you hang.

So the fix is structural: the read guard must be gone before you ask for the write lock. In the reference solution the read is a temporary of its own statement:

let needs_init = shared.read().expect("lock poisoned").is_none();
// guard died at the semicolon. only the bool survives.
let mut w = shared.write().expect("lock poisoned");

The bug hiding in that fix

Between the read guard dropping and the write guard being acquired, another thread can change the value. needs_init is not a fact about the present, it is a fact about the past.

So the write branch re-checks under the write lock:

if needs_init && w.is_none() { *w = Some(x); } else { ... }

This is double-checked locking done correctly, and the correction is the whole point: a cheap read-lock check as a fast path, and an authoritative re-check under the lock that actually decides. Skipping the second check is one of the most common concurrency bugs in any language.

Two more traps, one of which the 2024 edition deleted

match scrutinees still hold their temporaries. This holds the lock for the entire body:

match *m.lock().unwrap() { ... }   // guard alive until the match ends

If any arm tries to lock m again, you hang.

if let used to do the same, and no longer does. Before the 2024 edition, if let Some(x) = *m.lock().unwrap() { .. } else { .. } kept the guard alive through the else branch, so re-locking there deadlocked — clippy::if_let_mutex exists for exactly that. Edition 2024 rescoped if let temporaries and fixed it, and this grader compiles with --edition 2024, so the same program that deadlocked under 2021 runs correctly here. Worth knowing as history, because you will read code and advice written before the change — and because it makes the edition system concretely valuable rather than an abstraction.

Note the asymmetry: if let was rescoped, match was not. That gap is now the live trap.

And: never hold a guard across handle.join(). If the thread you are waiting on needs the same lock, neither of you moves again.

If your submission hangs

A hang is a deadlock, and the grader can only report it as a timeout, which is a terrible error message. Go looking for a guard that is still alive: a named guard, a match scrutinee, a lock taken inside a branch of an expression whose value has not been consumed yet.

Loading visualization…