Lock poisoning¶
Level: 301 · deep dive
One line: Mutex::lock() returns a Result for exactly one reason — a thread panicked while holding the guard — and .lock().unwrap() is you deciding that this thread should die because that one did.
It is the most-typed .unwrap() in Rust, and the one most often defended with "it can only fail if a thread panicked." True, and the sentence stops one clause too early: it can only fail if a thread panicked while the data was under its exclusive control.
A mutex protects an invariant, not bytes¶
The lock is not there to stop two threads touching the same memory at the same instant — that is the mechanism, not the purpose. It is there so that other threads only ever see the data in a state you consider finished.
Take a list that holds (key, value) as adjacent pairs. Its invariant is "the length is even". A writer holds the lock, pushes the key, and panics before pushing the value:
let mut guard = shared.lock().unwrap();
guard.push(3); // the key...
panic!("failed before writing the score"); // ...and the score never comes
The Vec is perfectly valid as a Vec. Every byte is where it should be. It is also wrong, in the only sense the program cares about — and the next thread to lock has no way to notice, because nothing about [1, 5, 2, 4, 3] looks broken.
So std records it. When a guard is dropped during unwinding, the lock sets a poison flag, and every later lock() returns Err(PoisonError). That is the whole mechanism: not an error the mutex encountered, but a message from a thread that died mid-sentence.
The three honest answers¶
PoisonError carries the guard, so nothing is lost — the choice is entirely about what you do next:
shared.lock().unwrap() // (a) die with it
shared.lock().unwrap_or_else(|e| e.into_inner())// (b) carry on, deliberately
shared.lock().map_err(|_| MyError::Corrupt)? // (c) hand the choice to the caller
- (a) is a real decision, not a formality. It says: the state behind this lock may be nonsense, and continuing is worse than stopping. In an application that is often right and always defensible.
- (b) says the opposite — I know a writer died in here and I am proceeding anyway. That is a claim about your data. It is right when the invariant cannot actually have been broken (see below), or when you are about to repair it.
- (c) is the library answer. A panic inside your crate is a failure your caller cannot catch; deciding on their behalf that the process should end is the one version of this that is simply rude.
The tell for a misplaced (a) is a library whose docs never mention that it can panic, in a codebase where nothing else panics. Your caller's process now ends because of a thread they did not write.
Poisoning is sticky, and clearing it is two steps¶
The flag stays set forever, in every thread. Since Rust 1.77 you can clear it — and the order matters more than the call:
{
let mut data = shared.lock().unwrap_or_else(|e| e.into_inner());
data.push(0); // repair the invariant FIRST
}
shared.clear_poison(); // then retract the warning
clear_poison() clears the flag, not the data. Calling it first, or calling it because the Err was inconvenient, converts a loud problem into a silent one — which is the trade nobody ever means to make.
What poisons, exactly¶
Mechanical rules, all three verified by the program below:
| Event | Poisons? |
|---|---|
A thread panics while holding a Mutex guard |
yes |
| A thread panics with no guard held | no |
A thread panics holding an RwLock read guard |
no |
A thread panics holding an RwLock write guard |
yes |
The RwLock split is the rule in miniature: only an exclusive guard can leave a half-written invariant, so only an exclusive guard poisons. Nothing is inspecting your data or judging whether it is really broken — the flag follows the guard, not the damage.
When poisoning is noise¶
A single += 1 on a u64 is complete or it never ran; there is no half-state for a panic to leave behind. The lock poisons anyway, because std cannot know that. This is the case the parking_lot crate has in mind when it drops poisoning altogether, and it is worth being honest that a large share of real Mutexes are exactly this shape — a counter, a flag, a cache where a stale entry is survivable.
Which gives a better question than "should I unwrap the lock?":
Could a panic between two of my writes leave this data saying something untrue? If yes, .unwrap() is the right instinct and you should say so in an .expect("…"). If no, the poison flag is telling you about a thread that died, not about your data, and unwrap_or_else(|e| e.into_inner()) is the honest response — with a comment saying which of the two you decided.
If you are coming from another language¶
- Python.
threading.Lockhas no equivalent. A thread that raises mid-update releases the lock in itsfinallyand the next thread reads the half-written dict with nothing to warn it; the traceback appears on stderr and the other thread carries on computing with data nobody vouched for. Rust's poison flag is not protection — it is the notification Python never sends you. - ABAP. The closest relative is not
ENQUEUEbut the update task:CALL FUNCTION … IN UPDATE TASKbundles the writes so that a failing update rolls the whole bundle back, and SM13 keeps the wreckage for you to look at. Both systems refuse to let a half-finished write pass silently — SAP by undoing it, Rust by recording it and making the next reader decide. The Rust one is weaker and cheaper: your data is still half-written, and all you are guaranteed is that nobody reads it unknowingly.
Practice¶
The Result the lock hands you. Have a thread panic while holding a Mutex guard, halfway through an update that leaves the data's invariant false. Then lock it from the main thread and answer the Err three ways: propagate it, recover through into_inner(), and treat it as fatal.
Look at what into_inner() hands back before you decide recovery is easy — it is exactly the half-finished state the panic left, and recovering means repairing it rather than getting past the Err. Then check is_poisoned() after your repair and find out why clearing it is a separate, deliberate call.
Solution
mutex_poisoning_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: the lock returns a Result for exactly one reason.
//!
//! rustc --edition 2024 mutex_poisoning_kata.rs -o /tmp/mpk && /tmp/mpk
use std::panic;
use std::sync::{Arc, Mutex};
use std::thread;
/// The invariant: `total` is the sum of `counted`. A panic halfway through an
/// update leaves that false — which is the thing poisoning is warning about.
#[derive(Debug, Default)]
struct Tally {
counted: Vec<u32>,
total: u32,
}
fn main() {
let tally = Arc::new(Mutex::new(Tally::default()));
// A worker that dies holding the guard, mid-update.
let worker = Arc::clone(&tally);
let prior = panic::take_hook();
panic::set_hook(Box::new(|_| {})); // keep this demo's stderr clean
let handle = thread::spawn(move || {
let mut t = worker.lock().unwrap();
t.counted.push(12); // half of the update...
panic!("row 3 was unreadable"); // ...and the other half never happens
});
let died = handle.join().is_err();
panic::set_hook(prior);
println!("worker thread panicked while holding the lock: {died}");
println!("\nAnswer 1 — propagate. The invariant is broken; say so:");
match tally.lock() {
Ok(_) => println!(" lock() -> Ok (not what happens here)"),
Err(e) => println!(" lock() -> Err: {e}"),
}
println!("\nAnswer 2 — recover, having looked at the damage:");
let recovered = match tally.lock() {
Ok(guard) => guard,
Err(poisoned) => {
let guard = poisoned.into_inner(); // the data is still there
println!(" into_inner() -> {:?}", *guard);
println!(" counted has one entry, total is 0 — exactly the broken");
println!(" state the panic left. Recovering means REPAIRING this,");
println!(" not merely getting past the Err.");
guard
}
};
let repaired_total: u32 = recovered.counted.iter().sum();
drop(recovered);
{
let mut t = tally.lock().unwrap_or_else(|p| p.into_inner());
t.total = repaired_total;
println!(" repaired -> {:?}", *t);
}
println!("\nPoisoning is sticky:");
println!(" still poisoned? {}", tally.is_poisoned());
println!(" Repairing the DATA does not clear the flag. Clearing it is");
println!(" `clear_poison()`, and it is a separate, deliberate step — so");
println!(" that no other thread quietly gets an Ok it did not earn.");
tally.clear_poison();
println!(" after clear_poison() -> {}", tally.is_poisoned());
println!(" lock() now -> {:?}", tally.lock().map(|g| g.total));
println!("\nAnswer 3 — `.lock().unwrap()`, which is a decision, not a shortcut:");
println!(" It says: another thread broke this invariant, so this thread");
println!(" should die too. Often right. Worth writing as .expect(\"…\")");
println!(" with the reason, since that is the sentence you will read.");
}
Verified output of mutex_poisoning_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
worker thread panicked while holding the lock: true
Answer 1 — propagate. The invariant is broken; say so:
lock() -> Err: poisoned lock: another task failed inside
Answer 2 — recover, having looked at the damage:
into_inner() -> Tally { counted: [12], total: 0 }
counted has one entry, total is 0 — exactly the broken
state the panic left. Recovering means REPAIRING this,
not merely getting past the Err.
repaired -> Tally { counted: [12], total: 12 }
Poisoning is sticky:
still poisoned? true
Repairing the DATA does not clear the flag. Clearing it is
`clear_poison()`, and it is a separate, deliberate step — so
that no other thread quietly gets an Ok it did not earn.
after clear_poison() -> false
lock() now -> Ok(12)
Answer 3 — `.lock().unwrap()`, which is a decision, not a shortcut:
It says: another thread broke this invariant, so this thread
should die too. Often right. Worth writing as .expect("…")
with the reason, since that is the sentence you will read.
The verified output¶
Verified output of mutex_poisoning.rs — regenerated by tools/run_examples.py, never hand-typed.
──── Step 1: lock() returns a Result — and normally it is Ok
lock().is_ok() -> true
is_poisoned() -> false
data -> [1, 5, 2, 4]
The Result has exactly one possible cause: a previous holder
panicked. Nothing else makes lock() fail.
──── Step 2: A holder panics: the invariant breaks and the lock remembers
is_poisoned() -> true
lock() -> Err(PoisonError)
the data behind it -> [1, 5, 2, 4, 3]
invariant holds? -> false
The Vec is not corrupt in a memory sense — it is corrupt in the
sense that matters: a key with no value. That is what the
Err is warning the next thread about.
──── Step 3: Three honest responses, and the one you write by accident
(a) .unwrap() -> panicked: true
(b) .unwrap_or_else(into_inner) -> [1, 5, 2, 4, 3] consistent? false
(c) map_err + ? -> Err("the score list was left inconsistent by a panicking thread")
(a) is the default everyone types. It is a real decision: this
thread dies because a different one did. Fine in an application,
rude in a library — the caller never got to choose.
──── Step 4: Poisoning is sticky, and clearable
poisoned now? -> true
every later lock() -> Err, forever, in every thread
repaired to -> [1, 5, 2, 4, 3, 0] consistent? true
after clear_poison() -> poisoned? false, lock() ok? true
clear_poison() only clears the FLAG. Fixing the data is your job,
and doing it in the other order tells the next thread a lie.
──── Step 5: What actually poisons: the guard's Drop during unwinding
panic while NOT holding -> poisoned? false
RwLock, reader panicked -> poisoned? false
RwLock, writer panicked -> poisoned? true
Only an exclusive guard can leave a half-written invariant, so only
an exclusive guard poisons. The rule is mechanical, not a judgement.
──── Step 6: When poisoning is noise
counter after the panic -> 1, poisoned? true
Nothing here can be half-done: one += 1 is complete or it never ran.
The flag is still set, because std cannot know that. This is the case
the parking_lot crate has in mind when it drops poisoning entirely.
Run it yourself:
Traps¶
.lock().unwrap()in a library. You have decided your caller's process should end. Return the error or recover explicitly.clear_poison()before repairing the data. The flag is the only thing telling anyone the invariant may be broken; clearing it first just deletes the warning.- Treating
PoisonErroras data loss. Nothing is lost —into_inner()andget_ref()both hand you the value. TheErris advice, not a wall. - Holding a guard across an
.awaitor a long call. Not a poisoning bug, but it is how a panic elsewhere ends up happening inside your critical section in the first place. - Assuming a panicking reader poisons an
RwLock. It does not, and code that "defensively" handles that case is handling something that cannot happen.
See also¶
unwrap: the bet you are making — the general form of the decision this page is one instance ofunwrap_or— andunwrap_or_else, which is what|e| e.into_inner()isOptionvsResult— whylock()returns aResultrather than anOption: the caller can absolutely ask why not?std::sync::Mutex::lock↗ ·PoisonError↗ ·clear_poison↗- The Rust Book, ch. 16.3 — Shared-State Concurrency ↗
Po polsku¶
Zatrucie blokady (lock poisoning) ma bardzo mylącą nazwę i to jest pierwsza rzecz, którą trzeba po polsku powiedzieć wprost: „zatrute” brzmi jak uszkodzenie danych, a nic się nie uszkodziło i nic nie przepadło — PoisonError trzyma wartość i odda ją przez into_inner(). Flaga nie mówi „dane są zepsute”, tylko „wątek, który miał te dane wyłącznie dla siebie, spanikował w połowie zdania”. Dlatego lock() zwraca Result z dokładnie jednego powodu, a zdanie, które zwykle urywa się o jedno dopowiedzenie za wcześnie, brzmi w całości: bo jakiś wątek spanikował, trzymając guarda — czyli obiekt zwrócony przez lock().
Kluczowe słowo jest znane każdemu z algorytmiki: niezmiennik (invariant). Blokada nie chroni bajtów, tylko niezmiennik. Wektor po panice jest zupełnie poprawnym wektorem — każdy bajt na swoim miejscu — a mimo to kłamie: jest w nim kandydat bez wyniku, i następny wątek nie ma szans tego zauważyć, bo [1, 5, 2, 4, 3] wygląda całkiem zdrowo. Sam mechanizm jest przy tym czysto mechaniczny: flaga zapala się wtedy, gdy guard jest zwalniany podczas odwijania stosu (unwinding). Stąd trzy reguły, które inaczej wyglądają na kaprys — panika bez trzymanego guarda nie zatruwa niczego, w RwLock zatruwa guard zapisu, a guard odczytu nie. Nikt nie ogląda danych i nie ocenia, czy naprawdę są zepsute; flaga idzie za guardem, nie za szkodą.
Skutek jest taki, że .lock().unwrap() to decyzja, a nie formalność: „tamten wątek zginął, więc ten też ma zginąć”. W aplikacji często słuszna — wtedy warto ją zapisać jako .expect("…") z powodem, bo to zdanie ktoś kiedyś przeczyta. W bibliotece już nie, bo za wywołującego nie decyduje się o zakończeniu jego procesu; tam odpowiedzią jest map_err(…)?. Trzecia droga, unwrap_or_else(|e| e.into_inner()), to świadome „wiem, że ktoś tu umarł, i idę dalej” — wolno tak, jeśli danych naprawdę nie dało się zostawić w połowie (licznik += 1 albo flaga; dokładnie ten przypadek skłonił autorów crate'a parking_lot do porzucenia zatruwania w całości) albo jeśli zaraz je naprawiasz.
Przy naprawie liczy się kolejność: clear_poison() kasuje flagę, a nie dane. Najpierw napraw niezmiennik, dopiero potem gaś ostrzeżenie — odwrotna kolejność zamienia głośny problem w cichy, czyli robi zamianę, na którą nikt świadomie się nie godzi. I pytanie, które warto sobie zadawać zamiast „czy unwrapować blokadę”: czy panika pomiędzy dwoma moimi zapisami może zostawić te dane mówiące nieprawdę? Jeśli tak — flaga mówi o twoich danych. Jeśli nie — mówi tylko o cudzym wątku.
Szukaj po polsku: niezmiennik · odwijanie stosu · muteks wątki Rust · rust mutex poisoning · PoisonError into_inner · parking_lot no poisoning