Part 1 · The runtime machine
Where values die
Drop order, temporaries, and the two-line deadlock that catches every Rust programmer exactly once. This is the lesson that makes you sound experienced.
What this lesson is trying to break (4)
- Values are dropped in the order they were declared.
- A temporary lives until the end of the enclosing block.
- `let _ = guard` and `let _guard = guard` do the same thing.
- Drop timing is a runtime detail I do not need to reason about.
Part 1 has been about what values are and how they move. This lesson is about the last verb: exactly when a value dies.
It is usually taught as a footnote. It should not be — drop timing is where a surprising amount of real Rust behaviour lives, and being fluent in it is one of the clearest signals that someone has actually used the language rather than read about it.
Two orders, and they are opposite
struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) { println!("drop {}", self.0); }
}
struct Pair { first: Noisy, second: Noisy }
fn main() {
let a = Noisy("a");
let b = Noisy("b");
let p = Pair { first: Noisy("p.first"), second: Noisy("p.second") };
println!("--- end of main ---");
}--- end of main ---
drop p.first
drop p.second
drop b
drop aTwo different rules are visible here, and they run in opposite directions:
- Locals drop in reverse declaration order.
p, thenb, thena. This is a stack: last in, first out. It has to be, because a later variable may borrow from an earlier one, so the later one must die first. - Struct fields drop in declaration order.
p.firstbeforep.second.
If you predicted p.second before p.first, you assumed the reverse rule
applied everywhere. It does not, and there is no deep principle behind the
difference — fields were simply specified to drop in order.
The reverse-order rule for locals is not arbitrary. Consider:
let data = String::from("hello");
let view = &data; // view borrows data
view must be dead before data is dropped, or the reference would dangle
during the drop. Reverse declaration order gives that for free.
Temporaries die at the semicolon
Now the part that produces real bugs.
A temporary is a value with no name — the result of an expression that is used and then discarded. The rule is:
A temporary is dropped at the end of the enclosing statement, not the enclosing block.
let n = compute().len(); // the value returned by compute()
// is dropped HERE, at the semicolon
println!("{n}"); // ...not here
This is usually what you want, and usually invisible. Then you meet the exceptions.
The deadlock
use std::collections::HashMap;
use std::sync::Mutex;
fn main() {
let m = Mutex::new(HashMap::from([(1, "one")]));
match m.lock().unwrap().get(&1) {
Some(v) => {
println!("found {v}");
m.lock().unwrap().insert(2, "two");
}
None => {}
}
println!("done");
}It compiles cleanly, prints found one, and then hangs forever.
Here is the chain, and every link is a rule from this lesson:
m.lock().unwrap()produces aMutexGuard— a temporary, with no name..get(&1)returns a reference borrowed from that guard.- For that borrow to be valid inside the arms, the guard must still be alive.
- And it is: temporaries in a
matchscrutinee live until the end of the entirematch, not until the end of the scrutinee expression. This is the documented exception to the semicolon rule. - So the lock is still held when the arm body runs.
std::sync::Mutexis not reentrant. Locking it again on the same thread deadlocks.
The cruel part is step 4. That extension is exactly what makes the code compile — without it you would get a borrow error and you would fix it in thirty seconds. The convenience is what buys you the hang.
The standard fix is to end the statement before you use the result, forcing the guard to drop:
let found = m.lock().unwrap().get(&1).copied(); // guard dies at this `;`
match found {
Some(v) => {
println!("found {v}");
m.lock().unwrap().insert(2, "two"); // fine
}
None => {}
}Note that .copied() is doing real work here: it turns the borrowed &&str
into an owned &str, so nothing depends on the guard after the semicolon.
Edition note. Rust 2024 tightened the related if let rule — scrutinee
temporaries there now drop before the else block runs, which removed a similar
class of bug. The match behaviour above is unchanged. If a colleague says
“I thought they fixed this”, that is what they are remembering.
The one-character bug
This one deserves its own heading because it is pure gotcha and it is worth a lot in a code review.
Varies: only the pattern on the left of `=`
| Code | Is the lock held after this line? | Why |
|---|---|---|
let _g = m.lock().unwrap(); | Yes, until end of scope | _g is a real binding, so the guard has a place and lives in it. |
let _ = m.lock().unwrap(); | No — released immediately | _ is a wildcard pattern that binds nothing. The guard stays a temporary and dies at the semicolon. |
m.lock().unwrap(); | No — released immediately | Same as above: no binding, so the temporary dies at the semicolon. |
A single underscore is the difference between a critical section and no critical section at all. The compiler will not warn you.
let _guard = ...;
Someone is holding a guard for its side effect. The value is never read — it
exists purely so that its Drop runs at the end of the scope. Common for mutex
guards, span guards in tracing, and profiling timers.
When you see it, the interesting question is always where does this scope end?
— because that is the extent of the critical section. And if you ever see
let _ = where a guard was intended, you have found a bug.
Drop flags
One last mechanism, briefly, because it explains a cost people ask about.
What if a value is moved on one branch but not another?
let s = String::from("hi");
if condition {
consume(s); // moved here
} // ...so is `s` dropped at the end or not?
The compiler cannot know at compile time. So it inserts a hidden boolean — a drop flag — on the stack, sets it when the move happens, and checks it at the end of the scope.
This is one of very few places where Rust’s ownership system costs anything at runtime, and it is worth knowing for two reasons. First, it is usually optimised away when the branch is predictable. Second, when a colleague claims ownership is “entirely zero cost”, this is the honest footnote — it is nearly free, and the exception is conditional moves.
A colleague sends you this for review:
fn handle(&self, req: Request) -> Response {
let _ = self.metrics.lock().unwrap();
let cache = self.cache.lock().unwrap();
expensive_work(&req, &cache)
}Name two separate problems with the locking here, and say what you would ask the author. One is a bug; the other is a design smell about how long a lock is held.
What you should now be able to say
- Locals drop in reverse declaration order; struct fields drop in declaration order.
- Temporaries drop at the semicolon, except in a
matchscrutinee, where they live to the end of the match. let _ =drops immediately.let _g =holds to end of scope.- Guards are held for their
Drop, so the scope is the critical section. - Drop flags are the small runtime cost of conditionally-moved values.
Next: 1.5 — Reading a stack frame, which puts all of Part 1 together on one function.