name: rust-concurrency
description: Detect and fix concurrency bugs in Rust: data races prevented by Send/Sync, deadlocks from inconsistent lock ordering, poisoned Mutex after panic, MutexGuard held across .await (deadlock + !Send future), Rc/RefCell in spawned or async contexts, blocking calls inside async without spawn_blocking, Arc where the value is never mutated, Cell/RefCell shared across threads. Covers std threads, JoinHandle, move closures, mpsc channels, Arc<Mutex>, Arc<RwLock>, Send/Sync marker traits, lock poisoning recovery, and tokio-specific async caveats. Auto-triggers on: Arc<Mutex, lock().unwrap(), std::sync in async fn, Rc across spawn, MutexGuard held across .await, thread::sleep inside async, spawn_blocking missing. Use when this capability is needed.
metadata:
author: adelabdelgawad
Fearless Concurrency (+ Async Caveats)
Rust's ownership and type system makes many concurrency bugs compile-time errors rather than runtime surprises. The Book states it directly: "By leveraging ownership and type checking, many concurrency errors are compile-time errors in Rust rather than runtime errors." This skill covers the patterns the compiler enforces, the patterns it cannot enforce (deadlocks, poisoning, blocking-in-async), and the async-runtime caveats that appear on top.
When to Use
Invoke this skill when reviewing or writing code that:
- Introduces
Arc<Mutex<T>> or Arc<RwLock<T>> shared state
- Holds a
std::sync::MutexGuard and calls .await in the same block
- Uses
Rc or RefCell in code passed to thread::spawn or tokio::spawn
- Calls
thread::sleep, blocking file I/O, or CPU-intensive work inside an async fn
- Acquires two or more distinct locks in the same function (deadlock risk)
- Uses
.lock().unwrap() in long-lived, multi-threaded code paths
- Uses
Cell or RefCell wrapped in Arc
- Wraps an immutable value in
Arc<Mutex<T>> with no write path
Core Idioms
let counter = Arc::new(Mutex::new(0u64));
let c = Arc::clone(&counter);
thread::spawn(move || {
*c.lock().unwrap_or_else(|p| p.into_inner()) += 1;
});
let (tx, rx) = mpsc::channel::<String>();
thread::spawn(move || {
let msg = String::from("hello");
let _ = tx.send(msg);
});
async fn update(state: Arc<Mutex<HashMap<u32, String>>>, k: u32) {
{
let mut map = state.lock().unwrap_or_else(|p| p.into_inner());
map.insert(k, "pending".into());
}
do_async_work().await;
}
use tokio::sync::Mutex as AsyncMutex;
async fn fetch_and_store(state: Arc<AsyncMutex<Vec<u8>>>) {
let mut v = state.lock().await;
let bytes = fetch_bytes().await;
v.extend(bytes);
}
let config = Arc::new(RwLock::new(Config::default()));
let reader = Arc::clone(&config);
thread::spawn(move || {
let cfg = reader.read().unwrap_or_else(|p| p.into_inner());
println!("{}", cfg.timeout);
});
Forbidden Patterns
Forbidden 1 — std::sync::MutexGuard Held Across .await
async fn bad(state: Arc<Mutex<Vec<u8>>>) {
let mut v = state.lock().unwrap();
some_async_call().await;
v.push(1);
}
async fn good_a(state: Arc<Mutex<Vec<u8>>>) {
{
let mut v = state.lock().unwrap_or_else(|p| p.into_inner());
v.push(1);
}
some_async_call().await;
}
use tokio::sync::Mutex as TokioMutex;
async fn good_b(state: Arc<TokioMutex<Vec<u8>>>) {
let mut v = state.lock().await;
().;
v.();
}
Why: std::sync::MutexGuard<T> is !Send. Tokio's multi-threaded runtime requires futures passed to spawn to be Send. Holding a std guard across .await makes the enclosing future !Send, causing a compile error on tokio::spawn. Even if the runtime is single-threaded today, the pattern is fragile and prohibits future migration.
grep -rn '\.lock()' src/ | grep -vE 'tokio::sync|async_mutex'
Forbidden 2 — Rc or RefCell in Code That Must Be Send
let data = Rc::new(vec![1, 2, 3]);
thread::spawn(move || println!("{data:?}"));
async fn bad(data: Rc<Config>) {
expensive_call().await;
}
let data = Arc::new(vec![1, 2, 3]);
thread::spawn(move || println!("{data:?}"));
Why: The Book explains: "This cannot implement Send because if you cloned an Rc<T> value and tried to transfer ownership of the clone to another thread, both threads might update the reference count at the same time." Arc<T> uses atomic operations and is Send + Sync. (Book ch16-04)
grep -rnE 'Rc::new|Rc<' src/ | grep -vE '#\[cfg\(test|// @single-thread'
grep -rnE 'RefCell::new|RefCell<' src/ | grep -vE '#\[cfg\(test|// @single-thread'
Forbidden 3 — Inconsistent Lock Ordering (Deadlock)
async fn transfer(accounts: Arc<Mutex<Accounts>>, limits: Arc<Mutex<Limits>>) {
let _a = accounts.lock().unwrap();
let _l = limits.lock().unwrap();
}
fn acquire_both<'a>(
first: &'a Mutex<Resource>,
second: &'a Mutex<Resource>,
) -> (MutexGuard<'a, Resource>, MutexGuard<'a, Resource>) {
let g1 = first.lock().unwrap_or_else(|p| p.into_inner());
let g2 = second.lock().unwrap_or_else(|p| p.into_inner());
(g1, g2)
}
Why: The Book warns: "Rust can't protect you from all kinds of logic errors when you use Mutex<T>… Mutex<T> comes with the risk of creating deadlocks. These occur when an operation needs to lock two resources and two threads have each acquired one of the locks, causing them to wait for each other forever." (Book ch16-03) This is a logic-level invariant; the compiler has no visibility into runtime acquisition order.
grep -rn '\.lock()' src/ | awk -F: '{print $1}' | sort | uniq -d
Forbidden 4 — .lock().unwrap() Without Considering Poisoning
let val = shared.lock().unwrap();
let val = shared.lock().unwrap_or_else(|poisoned| {
tracing::warn!("mutex was poisoned; recovering inner value");
poisoned.into_inner()
});
let val = shared.lock().map_err(|_| AppError::Internal)?;
Why: The std library docs note that a mutex becomes poisoned if the thread holding it panics. Once poisoned, all other threads are unable to access the data by default, and subsequent lock() calls return Err(PoisonError<...>) (std::sync::Mutex §Poisoning). The Book only says "The call to lock would fail if another thread holding the lock panicked" — the PoisonError API details are documented in std, not the Book chapter. Blindly calling .unwrap() turns an upstream panic into a cascade of panics across every thread that touches the mutex. .unwrap_or_else(|p| p.into_inner()) recovers the data; the poison flag signals that the invariant may be broken, so log and audit before trusting the recovered value.
grep -rn '\.lock()\.unwrap()' src/ | grep -vE '#\[cfg\(test'
Forbidden 5 — Blocking Call Inside Async Without spawn_blocking
async fn hash_password(password: String) -> String {
std::thread::sleep(Duration::from_secs(1));
bcrypt::hash(&password, 12).unwrap()
}
async fn hash_password(password: String) -> Result<String, AppError> {
tokio::task::spawn_blocking(move || {
bcrypt::hash(&password, 12)
})
.await
.map_err(|_| AppError::Internal)?
.map_err(|_| AppError::Internal)
}
Why: Async runtimes like Tokio multiplex many tasks onto a small pool of OS threads. A blocking call (std::thread::sleep, blocking file I/O, heavy CPU computation) inside an async fn starves every other task scheduled on that thread. spawn_blocking moves the work to a dedicated blocking thread pool sized for blocking workloads.
grep -rnE 'thread::sleep|std::fs::|bcrypt::|argon2::' src/ \
| grep -vE 'spawn_blocking|#\[cfg\(test'
Forbidden 6 — Cell or RefCell Shared Across Threads
let cell = Arc::new(Cell::new(0u32));
thread::spawn(move || cell.set(1));
let shared = Arc::new(RefCell::new(vec![]));
thread::spawn(move || shared.borrow_mut().push(1));
Why: Cell<T> and RefCell<T> provide interior mutability without synchronization. They are !Sync, so Arc<Cell<T>> does not implement Send. The compiler rejects the code, but the pattern surfaces in refactors where someone wraps a single-threaded type in Arc without changing the interior mutability strategy. Correct replacement: Arc<Mutex<T>> or Arc<RwLock<T>>.
grep -rnE 'Arc<.*(Ref)?Cell<|Arc::new\((Cell|RefCell)::new' src/
Forbidden 7 — Arc<Mutex<T>> When the Value Is Never Mutated
let config = Arc::new(Mutex::new(AppConfig::load()));
let cfg = config.lock().unwrap();
let config = Arc::new(AppConfig::load());
let config = Arc::new(RwLock::new(AppConfig::load()));
let cfg = config.read().unwrap_or_else(|p| p.into_inner());
Why: Mutex<T> serializes all access — readers block each other. When data is constructed once and then only read, Arc<T> (zero lock overhead) is correct. When reads dominate but occasional writes happen, Arc<RwLock<T>> lets N readers proceed concurrently while a writer gets exclusive access.
grep -rnE 'Arc<Mutex<|Arc::new\(Mutex::new' src/
grep -rnE '\.lock\(\).*=|\.lock\(\).*push|\.lock\(\).*insert|\.lock\(\).*remove' src/
Async/Tokio Caveats Section
The patterns above apply to std threads. Async runtimes add a second layer of rules.
| Scenario | std threads | tokio::spawn |
|---|
Rc<T> captured | compile error (not Send) | compile error (future not Send) |
std::sync::MutexGuard across await | n/a | compile error (not Send) |
| blocking call in task | starves thread pool | starves async executor |
tokio::sync::Mutex guard across await | n/a | OK — designed for this |
Rule of thumb: If a future is passed to tokio::spawn, every value it holds across an .await must be Send. The compiler enforces this. The human-review gap is which .await a value crosses — check each lock scope manually.
Cross-reference: leptos-hydration-discipline Forbidden 9 covers the identical !Send error for #[server] functions specifically (Rc, RefCell, raw PgConnection across await). That skill is the project-specific application of this general rule.
Book References
All std-thread material is grounded in The Rust Programming Language (official edition, doc.rust-lang.org/book):
The async/tokio caveats (guard across .await, spawn_blocking) are runtime-level rules; the Book's Send/Sync chapter is the foundation; Tokio's documentation and the async-book extend it to the executor model.
Verification Hooks
Run all detectors in one sweep. Every command is heuristic — false positives are expected; rustc/clippy is the real gate.
echo "=== F1: MutexGuard across .await ==="
grep -rn '\.lock()' src/ | grep -vE 'tokio::sync|async_mutex'
echo "=== F2: Rc/RefCell in spawned contexts ==="
grep -rnE 'Rc::new|Rc<' src/ | grep -vE '#\[cfg\(test|// @single-thread'
grep -rnE 'RefCell::new|RefCell<' src/ | grep -vE '#\[cfg\(test|// @single-thread'
echo "=== F3: Multiple lock() calls in same file (deadlock risk) ==="
grep -rn '\.lock()' src/ | awk -F: '{print $1}' | sort | uniq -d
echo "=== F4: .lock().unwrap() without poison handling ==="
grep -rn '\.lock()\.unwrap()' src/ | grep -vE '#\[cfg\(test'
echo "=== F5: Blocking calls inside async without spawn_blocking ==="
grep -rnE 'thread::sleep|std::fs::|bcrypt::|argon2::' src/ \
| grep -vE 'spawn_blocking|#\[cfg\(test'
echo "=== F6: Cell/RefCell wrapped in Arc ==="
grep -rnE 'Arc<.*(Ref)?Cell<|Arc::new\((Cell|RefCell)::new' src/
echo
grep -rnE src/
grep -rnE src/
Related Skills
- rust-smart-pointers —
Rc vs Arc ownership semantics; when to prefer Arc even in single-threaded code for future-proofing.
- rust-ownership-borrowing — lifetime rules that govern what
move closures can capture; why borrows cannot outlive the spawning scope.
- leptos-hydration-discipline — Forbidden 9 is the project-specific instance of Forbidden 1 and 2 here:
Rc, RefCell, raw PgConnection across .await inside #[server] functions breaks the Tokio Send requirement and the SSR/WASM compilation boundary simultaneously.
Source: adelabdelgawad/rust-fullstack-agents — distributed by TomeVault.