DEV Community

Cristian Spinetta
Cristian Spinetta

Posted on

A deadlock on an uncontended Tokio RwLock (caused by futures::executor::block_on)

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.

The minimal reproducer

I published a tiny repro repo that demonstrates the behavior:

  • cargo run → hangs (expected)
  • cargo run -- --fixed → completes

tokio-block-on-deadlock

The repro intentionally forces Tokio's cooperative scheduler to yield, then attempts another uncontended read lock.

The mental model mismatch

A common assumption is:

If a lock is uncontended, acquiring it can't block.

That's true for synchronous locks, but async is different. 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

Crucially:

Pending does not always mean "the resource isn't ready".

Sometimes it means "yield for fairness".

Tokio's cooperative scheduling (the key)

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, that's fine:

  • the task gets re-polled
  • the budget is reset
  • the operation completes on the next poll

This is usually invisible.

Where it goes wrong: futures::executor::block_on

In my case, I discovered that part of the test setup was driving Tokio async code using:

futures::executor::block_on(async { ... })
Enter fullscreen mode Exit fullscreen mode

This is a common pattern in tests/mocks when you have a synchronous callback but need to call async code.

The problem is: futures::executor::block_on is not the Tokio runtime.

What it does is roughly:

  • Poll the future on the current OS thread
  • If it returns Pending, park the thread and wait for a wake-up

So when Tokio returns Pending for cooperative scheduling reasons, block_on treats it as "I should sleep until someone wakes me".

But in this scenario, there isn't a normal "external wake-up" to wait for. Tokio expected the runtime to re-poll the task.

Result:

  • the OS thread parks
  • the future never gets re-polled
  • the test hangs forever

And the lock looks "deadlocked" even though it's uncontended.

Why it happened on the third read

Because the cooperative budget is cumulative.

The first awaits didn't exhaust the budget. The third happened to be the first place where Tokio decided "you must yield now", returning Pending.

With small code changes, the hang could move earlier/later or disappear entirely — which is part of what makes this class of bug hard to debug.

A practical fix

The simplest rule:

If async code uses Tokio primitives, it must be driven by Tokio.

If you need to run async code from a synchronous context (tests/mocks/callbacks), one safe pattern is to drive it with Tokio's runtime handle on a separate OS thread:

fn tokio_block_on<F: std::future::Future + Send>(fut: F) -> F::Output
where
    F::Output: Send,
{
    let handle = tokio::runtime::Handle::current();
    std::thread::scope(|s| s.spawn(|| handle.block_on(fut)).join().unwrap())
}
Enter fullscreen mode Exit fullscreen mode

In the repro repo, cargo run -- --fixed uses this approach and completes successfully.

Takeaways

  • Poll::Pending doesn't always mean "waiting for a resource"
  • Tokio relies on its runtime to re-poll tasks; mixing executors can break that assumption
  • Avoid futures::executor::block_on for Tokio tasks
  • If you must cross sync/async boundaries, drive futures using Tokio itself (or redesign the boundary)

This bug is rare, but it sits in a very common pattern: sync test/mocking glue calling async code. When it happens, it's deeply confusing — and completely silent.

Top comments (0)