Skip to content

Forgotten unlock

Level: 201 · for C and C++ programmers

One line: Every pthread_mutex_lock needs a matching unlock on every path out of the function, including the ones added later — and in Rust the lock is released by a value being dropped, so there is no path that can skip it.

The program

#include <pthread.h>
#include <stdio.h>

static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static int tally = 0;

static int record(int score) {
    pthread_mutex_lock(&lock);

    if (score < 0) {
        return -1;                        /* early return — still holding it */
    }

    tally += score;
    pthread_mutex_unlock(&lock);
    return tally;
}

int main(void) {
    printf("%d\n", record(5));
    printf("%d\n", record(-1));
    printf("%d\n", record(3));            /* never returns */
    printf("done\n");
    return 0;
}

The validation is the bug. Somebody added the score < 0 guard to a function that already locked, and the new exit path did not learn about the unlock at the bottom.

What it did

Real run — Apple clang 21.0.0, x86_64 macOS, -Wall (no warnings), -O0, on a terminal, killed after 4 seconds
5
-1

Then nothing, forever. The third call blocks in pthread_mutex_lock on a mutex this same thread already holds and will never release.

The failure has the two properties that make deadlocks expensive: it is not on the path that caused it — the bad call is record(-1) and the program stops on record(3) — and it needs both to happen, so a test suite exercising only valid scores passes. Add a second thread and it gets worse: the hang moves to whichever thread happens to ask next, which may be nowhere near the code anyone is looking at.

ThreadSanitizer did not help here. Running the same program under -fsanitize=thread printed nothing at all in the four seconds before the hung process was killed — which is the general shape of the problem, since a report that arrives at exit never arrives from a program that does not exit.

Why the standard allows it

POSIX says relocking a PTHREAD_MUTEX_DEFAULT mutex you already hold is undefined; in practice, on this platform, it blocks. The forgotten unlock itself is not undefined at all — it is a perfectly legal sequence of calls that leaves a lock held. There is nothing for the compiler to object to, because "this function returns while holding a lock" is only a bug in the light of an intention nothing wrote down.

What Rust does instead

lock() does not return () — it returns a guard, and the lock is held for exactly as long as that guard is alive. There is no unlock to call, so there is no path that can miss it:

static TALLY: Mutex<i32> = Mutex::new(0);

fn record(score: i32) -> i32 {
    let mut tally = TALLY.lock().unwrap();   // held while `tally` lives

    if score < 0 {
        return -1;                           // the guard drops on the way out
    }

    *tally += score;
    *tally
}

Same program, same early return:

Verified output of forgotten_unlock.rs — regenerated by tools/run_examples.py, never hand-typed.

record(5)  -> 5
record(-1) -> -1
record(3)  -> 8

record(3) returns 8. The return -1 did not need to know a lock existed, and neither will the next early return somebody adds.

This is RAII, and it is the one row on the whole benefits list that a C++ programmer already has: std::lock_guard is the same design. What differs is that lock_guard is a thing you must remember to reach for, sitting beside a bare mutex.lock() that still compiles; here the guard is the only thing lock() gives you, because that is where the data lives.

Which is the deeper difference: Mutex<T> in Rust contains the data it protects. There is no tally you could touch without going through lock(), so "forgot to take the lock" is not a mistake with a shape — the same way the guard removes "forgot to release it".

The one thing the compiler does object to

Dropping the guard immediately, which unlocks straight away and leaves the following lines unprotected:

Real rustc output — lock_bound_to_underscore.rs, --edition 2024, abridged to the error and its note
error: non-binding let on a synchronization lock
 --> lock_bound_to_underscore.rs:6:9
  |
6 |     let _ = TALLY.lock().unwrap();
  |         ^ this lock is not assigned to a binding and is immediately dropped
  |
  = note: `#[deny(let_underscore_lock)]` (part of `#[deny(let_underscore)]`) on by default

Deny-by-default, so it is an error rather than a warning. It is a narrow rule about one specific way to write the mistake — worth knowing precisely because it is the exception, not a general "you are holding the lock too long" analysis.

What is still yours to get right

The guard removes the forgotten unlock. It does not remove deadlock. Take two locks in one order here and the opposite order there and the program deadlocks exactly as it would in C, with nothing objecting at compile time. Send and Sync rule out data races, which is a narrower claim than "no concurrency bugs" and is the one place the benefits list is most often overread.

Rust adds one wrinkle C does not have: a thread that panics while holding a lock poisons it, and every later lock() returns an Err. That is why the code above says .unwrap() — see mutex poisoning.

If you are coming from another language

  • C++ — you have this already, and the honest framing of this row is "enforced rather than available". lock_guard and scoped_lock are RAII in exactly this sense; what they cannot do is stop the member variable from being reachable without them, because the mutex and the data it guards are two separate members that a convention associates. Mutex<T> makes that association a type.
  • Pythonwith lock: is the same guarantee via the context-manager protocol, and an early return inside the with releases correctly. The bug you can still write is a bare lock.acquire() with the release() in the wrong place; the transferable idea is that Rust has no bare acquire to write.
  • ABAP — the nearest thing is ENQUEUE_* / DEQUEUE_* on a lock object, which is exactly the shape of the C bug: two calls with your control flow in between, and an early RETURN or an exception between them leaves the enqueue held until the update task or the session ends. There is no ABAP mechanism that ties the dequeue to a scope, so the discipline is entirely yours — which is what makes this the row with the clearest payoff if you move between the two.

Practice

The early return that holds the lock. Write a function that locks a Mutex and returns early on one path, then confirm the lock is free afterwards. Say what releases it, and what happens on a panic path.

Then two more: why is accessing the data without the lock not a mistake you can make in Rust, and what does let _ = m.lock(); do that let _guard = m.lock(); does not? Finish by comparing with the lock_guard of C++ and naming the gap it still has.

Solution

forgotten_unlock_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: no path can skip a drop.
//!
//!   rustc --edition 2024 forgotten_unlock_kata.rs -o /tmp/fuk && /tmp/fuk

use std::sync::Mutex;

fn total(m: &Mutex<Vec<u32>>, fail_early: bool) -> Result<u32, &'static str> {
    let guard = m.lock().unwrap();
    if fail_early {
        // The early return that causes the C bug. Here it releases the lock:
        // `guard` is dropped on the way out, on this path like any other.
        return Err("refused before summing");
    }
    Ok(guard.iter().sum())
}

fn main() {
    let m = Mutex::new(vec![1u32, 2, 3, 4]);

    println!("THE C SHAPE");
    println!("  pthread_mutex_lock(&m);");
    println!("  if (bad) return -1;            /* <- lock still held */");
    println!("  ...");
    println!("  pthread_mutex_unlock(&m);");
    println!("  Every path out needs its own unlock, including the paths added");
    println!("  next year by somebody who did not read the top of the function.");
    println!("  The failure is a deadlock in an unrelated thread, later.");
    println!();

    println!("THE RUST VERSION, BOTH PATHS");
    println!("  total(&m, false) = {:?}", total(&m, false));
    println!("  total(&m, true)  = {:?}", total(&m, true));
    println!("  ...and the lock is free afterwards: {:?}", total(&m, false));
    println!();
    println!("  `lock()` returns a GUARD, and the guard's Drop releases. There");
    println!("  is no unlock call to forget because there is no unlock call --");
    println!("  the early return drops the guard exactly as the normal return");
    println!("  does, and so does a panic unwinding through the function.");
    println!();

    println!("THE DETAIL THAT MAKES IT MORE THAN A CONVENTION");
    println!("  The data is inside the Mutex, not beside it. `m.lock()` is the");
    println!("  only way to a &mut Vec, so 'accessing without the lock' is not a");
    println!("  mistake you can make -- where C's mutex is a separate object");
    println!("  and nothing connects it to the data it protects.");
    println!();

    println!("AND THE ONE WAY TO STILL GET IT WRONG");
    println!("  let _ = m.lock().unwrap();      <- releases IMMEDIATELY");
    println!("  let _guard = m.lock().unwrap(); <- held to end of scope");
    println!("  `_` is a pattern that binds nothing, so the guard is a temporary");
    println!("  that dies at the end of the statement. That is the one remaining");
    println!("  way to hold a lock for zero instructions and think you held it,");
    println!("  and it is a one-character difference.");
    println!();

    println!("C++ HAS THE SAME ANSWER, WITH A GAP");
    println!("  std::lock_guard is RAII and does the same job. The difference is");
    println!("  that C++ cannot stop you touching the data without it, and");
    println!("  cannot stop a reference to the protected data escaping the");
    println!("  guard's scope. Rust's borrow checker ties the &mut to the");
    println!("  guard's lifetime, so it cannot outlive the lock.");

    assert_eq!(total(&m, false), Ok(10));
    assert!(total(&m, true).is_err());
}

Verified output of forgotten_unlock_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

THE C SHAPE
  pthread_mutex_lock(&m);
  if (bad) return -1;            /* <- lock still held */
  ...
  pthread_mutex_unlock(&m);
  Every path out needs its own unlock, including the paths added
  next year by somebody who did not read the top of the function.
  The failure is a deadlock in an unrelated thread, later.

THE RUST VERSION, BOTH PATHS
  total(&m, false) = Ok(10)
  total(&m, true)  = Err("refused before summing")
  ...and the lock is free afterwards: Ok(10)

  `lock()` returns a GUARD, and the guard's Drop releases. There
  is no unlock call to forget because there is no unlock call --
  the early return drops the guard exactly as the normal return
  does, and so does a panic unwinding through the function.

THE DETAIL THAT MAKES IT MORE THAN A CONVENTION
  The data is inside the Mutex, not beside it. `m.lock()` is the
  only way to a &mut Vec, so 'accessing without the lock' is not a
  mistake you can make -- where C's mutex is a separate object
  and nothing connects it to the data it protects.

AND THE ONE WAY TO STILL GET IT WRONG
  let _ = m.lock().unwrap();      <- releases IMMEDIATELY
  let _guard = m.lock().unwrap(); <- held to end of scope
  `_` is a pattern that binds nothing, so the guard is a temporary
  that dies at the end of the statement. That is the one remaining
  way to hold a lock for zero instructions and think you held it,
  and it is a one-character difference.

C++ HAS THE SAME ANSWER, WITH A GAP
  std::lock_guard is RAII and does the same job. The difference is
  that C++ cannot stop you touching the data without it, and
  cannot stop a reference to the protected data escaping the
  guard's scope. Rust's borrow checker ties the &mut to the
  guard's lifetime, so it cannot outlive the lock.

See also

Po polsku

Każdy pthread_mutex_lock potrzebuje odpowiadającego mu odblokowania na każdej ścieżce wyjścia z funkcji — łącznie z tymi, które ktoś dopisze za pół roku. Taki jest kształt tego błędu: kod bywa poprawny w chwili pisania, a psuje go późniejszy return w środku, dodany przez kogoś, kto nie zauważył, że wyżej stoi blokada.

Rust nie prosi o dyscyplinę, tylko zmienia mechanizm — blokadę zwalnia wypuszczenie wartości (drop), więc nie ma ścieżki, która mogłaby to ominąć: ani wcześniejszy return, ani ?, ani panika. To jest RAII, znane z C++ przez std::lock_guard, z jedną istotną różnicą: w Ruscie nie da się sięgnąć po chronione dane bez wzięcia strażnika, więc nie istnieje wariant „zapomniałem opakować".

Jest jedna rzecz, na którą kompilator faktycznie zwraca uwagę, i wygląda na formalność, a nie jest: let _ = mutex.lock(); nie utrzymuje blokady, bo _ nie jest nazwą, tylko odrzuceniem wartości — strażnik ginie natychmiast i sekcja krytyczna nigdy nie powstaje. Poprawnie jest let _strażnik = …. I to, czego Rust nie obiecuje: zakleszczenie (deadlock) z dwóch blokad branych w odwrotnej kolejności pozostaje w całości twoim problemem.

Szukaj po polsku: muteks i sekcja krytyczna · RAII · zakleszczenie · rust MutexGuard drop · rust let underscore lock