Rust's let _ = Lock Drop Trap: The Mutex You Acquired But Didn't Hold

2026-06-19

This Logger uses a Mutex<()> as a coarse lock to serialize multi-part log lines so they don't interleave across threads. It compiles cleanly, the unwraps never trip, and a quick test on one thread looks fine. Under load with four threads, the output is jumbled — words from different lines spliced together.

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

struct Logger {
    lock: Mutex<()>,
}

impl Logger {
    fn log(&self, parts: &[&str]) {
        let _ = self.lock.lock().unwrap();  // serialize the prints
        for p in parts {
            print!("{} ", p);
        }
        println!();
    }
}

fn main() {
    let logger = Arc::new(Logger { lock: Mutex::new(()) });
    let mut handles = vec![];
    for i in 0..4 {
        let l = Arc::clone(&logger);
        handles.push(thread::spawn(move || {
            for j in 0..200 {
                let a = format!("thread{}", i);
                let b = format!("iter{}", j);
                l.log(&[&a, &b, "done"]);
            }
        }));
    }
    for h in handles { h.join().unwrap(); }
}

The Bug

let _ = self.lock.lock().unwrap(); looks like it binds the MutexGuard to _ and holds it for the rest of the function. It doesn't. In Rust, plain _ is not a binding — it's a wildcard pattern that matches and immediately discards the value. The MutexGuard is dropped before the next statement runs, releasing the lock before any of the print! calls execute.

This is fundamentally different from let _guard = .... A name that starts with an underscore (_guard, _x) is a real binding; it just silences the unused-variable warning. Its drop runs at end of scope, like any other local. The visual similarity between _ and _guard is what makes this so easy to miss in review.

The same trap fires with any RAII guard: File, scopeguard::defer, transaction handles, tracing::span::Entered. Writing let _ = file_lock.acquire()?; looks defensive; it does nothing. The borrow checker can't help — there's no borrow to track because the value is gone.

It's even worse than a no-op. If the data the lock protects is accessed through the guard (a normal Mutex<T>), the compiler stops you because the guard is gone. But with Mutex<()> used as a coarse external lock, or with re-locking like let _ = m.lock(); let g = m.lock();, the code compiles and runs — silently racing.

The fix is one character:

fn log(&self, parts: &[&str]) {
    let _guard = self.lock.lock().unwrap();  // held to end of scope
    for p in parts {
        print!("{} ", p);
    }
    println!();
}

A few defensive habits worth adopting:

Key Takeaway: let _ = expr; discards the value immediately — for any RAII guard, that means the resource is released before the "protected" code even starts; use let _name = … (a real binding) to hold it for the scope.

All newsletters