Reading Rust

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.

40 minPOEcontrasting casesbeacon training
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

Predict firstcommit in writing, then look
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 ---");
}

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

Predict firstcommit in writing, then look
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");
}

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 `=`

CodeIs 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 immediatelySame 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.

Beaconsee this → think this, without decoding it

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.

The review questionwrite it — thinking it does not count

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 match scrutinee, 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.

In the wildgo read the real thing now, while it is fresh
  • The Reference — temporary scopesThe exact rules, stated normatively. Dense, but this is the page that settles arguments. Note the list of places that are NOT temporary scopes — that list is the deadlock in this lesson.
  • std::sync::MutexGuardRead the "must_not_suspend" note and the Drop impl description. The whole design of this type is "unlock when I die", which is why knowing exactly when it dies is not optional.
Scheduled for recall4 items added to your review queue

These are not shown to you now on purpose. They will surface in Review tomorrow, mixed in among items from other parts of the course — not grouped by topic. Sorting out which idea applies is the part that transfers.