A deadlock on an uncontended Tokio RwLock (caused by futures::executor::block_on)
Source: Dev.to
A weird test hang
I recently hit a Rust test that hung forever:
- no panic
- no error
- no timeout
- just… silence
The confusing part: the code path only used tokio::sync::RwLock::read(). No writers, no contention. Two reads succeeded. The third read().await never returned.
That sounds impossible if you’re thinking in “classic lock” terms, but the root cause wasn’t contention—it was executor semantics.
Repro repository
A tiny repo demonstrates the behavior:
# Hangs (expected)
cargo run
# Completes
cargo run -- --fixed
The repro intentionally forces Tokio’s cooperative scheduler to yield, then attempts another uncontended read lock.
Why the lock appears deadlocked
Async vs. synchronous locks
For synchronous locks, an uncontended acquisition cannot block. In async Rust, .await means:
- Poll the future.
- If it can’t make progress right now, return
Poll::Pending.
The executor will poll it again later.
Crucial point: Pending does not always mean “the resource isn’t ready”. Sometimes it means “yield for fairness”.
Tokio uses cooperative scheduling. Each task has a limited budget to prevent a single task from starving others. When that budget is exhausted, Tokio can intentionally return Poll::Pending to force a yield—even if the underlying resource is available. Under a normal Tokio runtime this is fine:
- The task gets re‑polled.
- The budget is reset.
- The operation completes on the next poll.
Mixing executors
In the test I was driving Tokio async code with:
futures::executor::block_on(async { /* ... */ })
futures::executor::block_on is not the Tokio runtime. It:
- Polls the future on the current OS thread.
- If the future returns
Pending, it parks the thread and waits for a wake‑up.
When Tokio returns Pending for cooperative‑scheduling reasons, block_on treats it as “I should sleep until someone wakes me”. But there is no external wake‑up; Tokio expects its own runtime to re‑poll the task. Consequently:
- The OS thread parks.
- The future never gets re‑polled.
- The test hangs forever.
The lock looks “deadlocked” even though it’s uncontended.
The cooperative budget is cumulative. The first two awaits didn’t exhaust the budget; the third hit the yield point, causing the hang. Small code changes can move the hang earlier, later, or make it disappear, which makes this class of bug hard to debug.
Safe way to run async code from sync contexts
If async code uses Tokio primitives, it must be driven by Tokio. One safe pattern is to run the future on a separate OS thread using Tokio’s runtime handle:
fn tokio_block_on<F>(fut: F) -> F::Output
where
F: std::future::Future + Send,
F::Output: Send,
{
let handle = tokio::runtime::Handle::current();
std::thread::scope(|s| {
s.spawn(|| handle.block_on(fut))
.join()
.expect("thread panicked")
})
}
In the repro repo, cargo run -- --fixed uses this approach and completes successfully.
Key takeaways
Poll::Pendingdoesn’t always mean “waiting for a resource”; it can also mean “yield for fairness”.- Tokio relies on its own runtime to re‑poll tasks; mixing executors can break that assumption.
- Avoid
futures::executor::block_onfor Tokio tasks. - When you need to cross sync/async boundaries, drive futures using Tokio itself (or redesign the boundary).
This bug is rare but appears in a very common pattern: synchronous test/mocking glue calling async code. When it happens, it’s deeply confusing—and completely silent.