Rust's Sneaky Deadlock With `if let` Blocks
Rust is my favorite programming language. I've been writing Rust for about 6 years, from college to professionally, and I'm vocal about how helpful the compiler is in catching particularly nasty multithreaded issues and memory bugs. What the compiler doesn't always catch though is deadlocks, which can occur using several well known types like Mutex
and RwLock
.
Generally, this is fine, 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. This post is going to focus specifically on the former situation, which is acquiring multiple simultaneous competing read/write locks on the same resource. Take this snippet for example:
use std::sync::RwLock;
fn main() {
let optional = RwLock::new(Some(123));
let lock = optional.read().unwrap();
let lock2 = optional.write().unwrap();
eprintln!("Finished!");
}
This program will deadlock during the statement that attempts to acquire a write lock on the map. Preventing this can take many forms, the simplest being to drop
the read lock before acquiring the write lock:
use std::sync::RwLock;
fn main() {
let optional = RwLock::new(Some(123));
let lock = optional.read().unwrap();
drop(lock);
let lock2 = optional.write().unwrap();
eprintln!("Finished!");
}
You can also force dropping the lock by using an explicit block statement:
use std::sync::RwLock;
fn main() {
let optional = RwLock::new(Some(123));
{
let lock = optional.read().unwrap();
}
let lock2 = optional.write().unwrap();
eprintln!("Finished!");
}
So in a single-threaded case with competing locks, there are fairly simple ways to prevent this from happening. Now for the trouble.
Sneaky Deadlock
Knowing what you know now, let's take a look at another snippet of code:
use std::sync::RwLock;
fn main() {
let map: RwLock<Option<u32>> = RwLock::new(Some(2));
if map.read().unwrap().is_some() {
eprintln!("There's a number in there");
} else {
let mut lock2 = map.write().unwrap();
*lock2 = Some(5);
eprintln!("There will now be a number {lock2:?}");
}
eprintln!("Finished!");
}
Here, we're locking an optional number in a RwLock
In the case where that variable is Some
, we know that it's been initialized and don't need to take any action. Otherwise, we acquire a write lock and then can write a number to the lock. This runs just fine.
Let's use the if let
statement to clean up our code a little bit and print out the number that's contained in the RwLock
:
use std::sync::RwLock;
use std::collections::HashMap;
fn main() {
let map: RwLock<Option<u32>> = RwLock::new(Some(2));
if let Some(num) = *map.read().unwrap() {
eprintln!("There's a number in there: {num}");
} else {
let mut lock2 = map.write().unwrap();
*lock2 = Some(5);
eprintln!("There will now be a number {lock2:?}");
}
eprintln!("Finished!");
}
This is the same logic, other than using the value obtained from the read lock changing the contents of the print statement. If we run this program, we get the output:
There's a number in there: 2
Finished!
Now, what happens if we change the map to RwLock::new(None)
, and try to initialize it?
It deadlocks.
But why?
In short, when declaring a variable as a part of an if let
statement that variable is held for the entire statement, including the else
block. You can think of the program we wrote as syntactic sugar for:
use std::sync::RwLock;
use std::collections::HashMap;
fn main() {
let map: RwLock<Option<u32>> = RwLock::new(Some(2));
{
let num = map.read().unwrap();
if num.is_some() {
eprintln!("There's a number in there: {}", num.unwrap());
} else {
let mut lock2 = map.write().unwrap();
*lock2 = Some(5);
eprintln!("There will now be a number {lock2:?}");
}
}
eprintln!("Finished!");
}
From this perspective, it's obvious that the read lock will be held for the entire block, and we could employ one of our previous fixes (adding explicit drop
calls, or multiple blocks) to fix the issue. In the above cherry-picked example, this would work fine, albeit making the code a bit more complicated.
I wrote this block because this has specifically bitten different Rust crates in the wasmCloud project multiple times (most recently here and I hoped to prevent the same mistake for others. If you're dealing with locks in Rust it can be a good idea to write out some unit tests for the different control paths you could take to make sure they all complete, and if in doubt, wrap your locks in explicit blocks to make sure they're dropped when you're finished with them.