Rust's Sneaky Deadlock With `if let` Blocks
brooksblog.bearblog.dev
brooksblog.bearblog.dev
error: calling `Mutex::lock` inside the scope of another `Mutex::lock` causes a deadlock
--> src/main.rs:5:5
|
5 | if let Some(num) = *map.lock().unwrap() {
| ^ --- this Mutex will remain locked for the entire `if let`-block...
| _____|
| |
6 | | eprintln!("There's a number in there: {num}");
7 | | } else {
8 | | let mut lock2 = map.lock().unwrap();
| | --- ... and is tried to lock again here, which will always deadlock.
9 | | *lock2 = Some(5);
10 | | eprintln!("There will now be a number {lock2:?}");
11 | | }
| |_____^
|
= help: move the lock call outside of the `if let ...` expression
= help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#if_let_mutex
= note: `#[deny(clippy::if_let_mutex)]` on by defaultThat lint won't help for those.
match still retains the old behavior.
Compared to that, if let - else has only two branches: one where matching (and possible variable binding) happens, and one where it doesn't. You couldn't have anything borrowed from the matched value in the else branch, so the lifetime extending to encompass the else branch is not strictly necessary.
These all read the same to me from a scoping perspective:
if let Some(x) = expr {
// expr is live here
}
while let Some(x) = expr {
// expr is live here
}
match expr {
// expr is live here
Some(x) => {},
_ => { /* expr is still live, doesn't matter that x is inaccessible */
}
fn foo(x: i32) {
// same idea, the variable x belongs to the scope it's declared alongside
}
For the scope of an if-let expression to extend into the else block afterwards violates the principle of least surprise for me -- and clearly the author of the OP too! if let Some(x) = expr {
// expr is live here
} else {
// ??? why is expr live here? the if is clearly "out of scope!"
}Also, from my experience, acquiring and releasing a mutex multiple times within a single code path feels to me like a smelly, subtly faulty code. Are there legitimate cases when it is inevitable and correct?
A `Mutex`/`RwLock` is doing borrow checking at runtime, effectively replacing the compile time one.
If you'd like to avoid the guardrails, you still need to be disciplined.
It does in a way, as you'd generally do multi-threading using message passing (for which the borrow checker can verify thread-exclusive data access) instead of manual locking.
I can't think of a use case for taking a mutex lock multiple times in a code path, but there is a common cache pattern with RwLock, where you have a "fast path" that only takes a read lock to perform a lookup, and then only take a write lock when inserting a new entry. In this case, you should be using an upgradable read lock, so there is technically a "re-lock" of the same RwLock on upgrade in the same codepath. This model does prevent deadlocks because of move semantics.
The problem is that the syntax of 'if let' makes the programmer assume a particular scoping, but its desugaring implies something else.
But it's not possible to upgrade a read to write lock as racing threads can cause a deadlock, and upgradable RwLock variants don't hand out multiple upgradable read locks.
To maintain concurrent access, the referenced code must recheck the data once a write lock is granted, to gracefully handle TOCTOU.
(I know you were making a general point, I'm just fleshing out this specific example)
There are two errors to avoid here:
- Read lock, read some data, unlock, relock for writing, act on data read during previous locked period. That's a time of check/time of use error.
- Acquire a read lock, then upgrade to a write lock. MutexLock does not let you do that. If you do that from two threads, you can deadlock. You can do that in SQL, but SQL can back out a transaction that deadlocks.
This is not a problem with Rust. Rust prevented the author from shooting themself in the foot.
In a review I'd flag code which depended on this behavior -- it's much clearer instead to simply take the lock outside the if statement.
let lock = RwLock::new(Box::new(111));
let r: &i32 = &**lock.read().unwrap(); // points to 111
*lock.write().unwrap() = Box::new(222); // allocates a new Box and deallocates 111
println!("{}", *r); // use after free let lock = RwLock::new(Box::new(111));
let read = lock.upgradable_read();
let r: &i32 = &**read; // points to 111
*RwLockUpgradableReadGuard::upgrade(read) = Box::new(222); // error[E0505]: cannot move out of `read` because it is borrowed
println!("{}", *r);For each statement, the compiler could keep track of the current "lock priority". If an attempt is made to obtain a new lock with a lower or equal priority, the compiler would reject this. Otherwise, the compiler would use the priority of the new lock as the lock priority for all statements within the scope of the block in which that lock is held.
Aside from modifying the language, the same basic idea could be implemented at the library level by exposing a lock type which still requires a priority to be specified at creation time, but tracks the current lock priority (per thread) at runtime and panics when the constraint is violated. Since the panics would be deterministic, they and the book-keeping required to track the current lock priority could be enabled only in debug mode.
but it's also behavior for rust match-like statements in general so nothing new nor specific to if let
You are correct that there is no concrete spec for the Rust language; the current state of the compiler and stdlib is the "spec". So this is a breaking change to the "spec", and requires a new edition.
In this case this is a Rust bug that will be fixed in the next edition: https://github.com/rust-lang/rust/issues/124085
There's value to being opinionated about API design, but there's also cost. And Rust is supposed to be playing in the same sandbox as the lower level tools.
I don’t think either of those is true. Encoding the ownership in the type system makes things clearer, imposes a compile-time cost but not run-time cost. Also, there isn’t any magic in the stdlib implementation of Mutex and Rwlock other than implementing it natively for every OS. This means that it is possible to implement the pattern for the examples you gave.
Idiomatically, you would then make `Foo::new_unchecked()` unsafe, with the precondition that nobody else is accessing the same external resource, and that `Foo::new_unchecked()` is only ever called once.
So long as MacGuffin doesn't have any data in it, it essentially evaporates at runtime. This is a zero size type (ZST) so we can store that in no bytes of RAM, we can use no registers at all to pass it into a function, and so on. If you're a C programmer this doesn't make sense because you don't have ZSTs, all your types take up at least one byte, but Rust isn't like that.
If locking and unlocking semantics are a good fit for whatever you are doing, then you can just do a lock implementation setting whatever memory mapped registers you need. There is no constraint here whatsoever.
Nobody is advocating for separate atomics in every little object in Rust. That's a Java idea that failed miserably long before Rust was even around.
actually it's a common pattern in complicated multi threaded code or similar to require that
technically yes but practically this is nearly always programmer bug to bypass a lock
If you port C code to rust and now need much more locks in Rust it's nearly always an indicator for either your C code not having been correct (e.g. accessing resources behind a lock without acquiring it) and/or you having badly structured code.
There is basically hardly ever a need to use unsafe for such cases as long as you don't write your own fundamental data structures (e.g. specific kinds of idk. lock free concurrent data structures).
Honestly the main reason I see unnecessary unsafe code usage is people trying to write C or C++ code in rust instead of writing rust in rust.
you can rely on implicit drop to always make sure you never forget to unlock (as long as you don't leak the guard which is hard to do but possible)
but you can also explicitly drop it to have full control over when exactly it will unlock
and in neither situation you have to worry about accidentally bypassing it
and in complicated code relying implicitly on a mutex or similar to still be kept it's a common pattern to do so (i.e. to explicitly drop the mutex guard even if not needed to be extra clear about for how long exactly the lock is held)
Sugary syntax is always an issue when side effects matter.
Having said that, I adore procedural code in general, but it does make ownership a fun mental exercise.
Ultimately this isn't really a problem with how Rust's Mutex/RwLock/etc. works, it's just a poor choice with how the lifetime of the lock guard is figured in the 'if let' case. This poor choice will be fixed in the 2024 Rust edition, and this problem will go away.
The main point being: the implicitness is not necessary for "compiler makes sure you don't forget". So the original comment about how usage of the explicitly named and paired APIs can clarify intent both for the writer and reader can still stand while not implying that forgetting is involved. I see this dichotomy often being drawn and I think it's important to consider the language design space more granularly. I think a reminder is a better fix for forgetting, rather than assuming what you wanted and doing it for you.
(the explicit calls also let you / work better with "go to defintion" on the editor to see their code, learn what to look up in a manual, step in with a debugger, see reasonable function names in stack traces and profiles, pass / require more arguments to the drop call, let the drop call have return / error values you can do something about (consider `fclose`), let the drop call be async, ...)
Plus you're working in a memory unsafe, concurrency unsafe language.
It is a little hilarious to see a rustaceon write:
> and you can sus out if a program is going to cause a deadlock by just making sure you aren't acquiring multiple simultaneous locks.
Ah.. not a problem! You just have to not ever have the problem. Then, of course at the opposite end of the article:
> I wrote this block because this has specifically bitten different Rust crates in the wasmCloud project multiple times
:D
`if let` is more or less syntax sugar for `match`.
And match behaves like that, too and has been in rust since 1.0.
That match did behaves like that is due to some old, you could say legacy, reasons and had been criticized even in the early rust 1.x days.
But changing a behavior which subtle change when locks are released is not something you can easily fix with a rust edition so we are pretty much stuck with it.
Looks like this is getting fixed in Rust 2024 :)
Through making if-let less syntax sugar for changing a implicit behavior people most likely didn't rely on but have problems with seems like a very good idea
My comment about this being hard to change was mainly about match. Not considering the option of making if-let less syntax sugary.
And in difference to if-let, for match people do (or at least did years ago in production code) rely on it.
1) temporaries being added "alongside" the item they appear in. This makes a tone of thing much much simpler, but comes back to bites us here.
2) "alongside" for match statement meaning alongside the whole statement (but for `if <cond> {` it's alongside the condition)
3) things being always dropped at the very end of the scope if not moved out from it earlier, which again makes things easier to understand in most cases.
both had been discussed a bunch around 1.0/early 1.x days and both are things which in most situations make it easier to write rust code (and for beginners potentially much easier)
but both have also drawbacks
like the not-that-common example in this blog
or e.g. in async where rust has to keep any values which impl Drop around across async await calls as it can't know if there is a side effect in Drop
In the past I personally had been a contender of allowing the compiler to drop value anywhere between the last time they have been referenced and the end of the scopes without any rules or stability about where exactly (i.e. if you need a guard to be kept around you need to be explicit about it).
But working more together with people of very varying skill levels in the last 5/6 years made me change my mind and agree that that would have been a terrible idea.
And having rules about guaranteed drops as early as possible seem initially easy but aren't due to things like conditional moves, partial moves etc. I.e. it would still be quite a bit more complicated to teach it.
Furthermore in both alternatives to 3) you likely still wouldn't (guaranteed) drop the guard temporary in the other match branch before you requesting the new guard as guaranteeing compiler behavior like that means having a lot of additional edges in many partial move scenarios and potentially even a bunch of additional branch. In both cases it would likely increase code size and mess with the branch predictor and I-caches and be generally just not good (but it would help with async await boundaries).
Which are new features for systems languages that otherwise rely on RAII for locking. So it's in the class of "original sin."
> so we are pretty much stuck with it.
You could refuse to compile it under some set of flags. Isn't that the basic value premise of the language here?