6

I stumbled upon a deadlock condition when using Tokio:

use tokio::time::{delay_for, Duration};
use std::sync::Mutex;

#[tokio::main]
async fn main() {
    let mtx = Mutex::new(0);

    tokio::join!(work(&mtx), work(&mtx));

    println!("{}", *mtx.lock().unwrap());
}

async fn work(mtx: &Mutex<i32>) {
    println!("lock");
    {
        let mut v = mtx.lock().unwrap();
        println!("locked");
        // slow redis network request
        delay_for(Duration::from_millis(100)).await;
        *v += 1;
    }
    println!("unlock")
}

Produces the following output, then hangs forever.

lock
locked
lock

According to the Tokio docs, using std::sync::Mutex is ok:

Contrary to popular belief, it is ok and often preferred to use the ordinary Mutex from the standard library in asynchronous code.

However, replacing the Mutex with a tokio::sync::Mutex will not trigger the deadlock, and everything works "as intended", but only in the example case listed above. In a real world scenario, where the delay is caused by some Redis request, it will still fail.

I think it might be because I am actually not spawning threads at all, and therefore, even though executed "in parallel", I will lock on the same thread as await just yields execution.

What is the Rustacean way to achieve what I want without spawning a separate thread?

Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366
Konstantin W
  • 409
  • 3
  • 11
  • You cannot read just that one sentence. *the feature that the async mutex offers over the blocking mutex is that it is possible to keep the mutex locked across an .await point* — your code definitely calls `.await` while the lock is held. – Shepmaster Sep 02 '20 at 20:12
  • Hm maybe the question was phrased completely wrong. Would the code above be alright if I would use tokio::sync::Mutex instead? – Konstantin W Sep 02 '20 at 20:51

1 Answers1

6

The reason why it is not OK to use a std::sync::Mutex here is that you hold it across the .await point. In this case:

  • task 1 holds the Mutex, but got suspended on delay_for.
  • task 2 gets scheduled and runs, but can not obtain the Mutex since its still owned by task 1. It will block synchronously on obtaining the Mutex.

Since task 2 is blocked, this also means the runtime thread is fully blocked. It can not actually go into its timer handling state (which happens when the runtime is idle and does not handle user tasks), and thereby can not resume task 1.

Therefore you now are observing a deadlock.

==> If you need to hold a Mutex across an .await point you have to use an async Mutex. Synchronous Mutexes are ok to use with async programs as the tokio documentation describes - but they may not be held across .await points.

Matthias247
  • 9,836
  • 1
  • 20
  • 29
  • 1
    So, using tokio::sync::Mutex would be the correct way to go in my case, right? In my real code I am awaiting to get items of a stream, and afterwards I am awaiting on a tokio mutex. For some reason tokio just seems to forget about some of the awaiting mutex futures (as soon as more than one future is awaiting on the mutex???). Therefore this could be a bug and not my fault (I should have asked this instead...) – Konstantin W Sep 07 '20 at 13:26
  • If you need to hold the mutex over the `.await` point, any async mutex implementation (there are ones in tokio, futures-intrusive, async-std, futures-rs, and probably more) should fulfill this. Maybe you can also refactor your program to avoid having to hold the Mutex over the `.await` point. No idea regarding "tokio forgetting about awaiting mutexes". Sounds like either a bug or a usage error. – Matthias247 Sep 08 '20 at 03:23
  • @KonstantinW or use tokio::spawn to run your "work" tasks independent of parent task so they can be scheduled to a different thread. Your current code is single-threaded, everything is running within the same thread, so when `lock()` blocks -> nothing runs anymore. – stepan Feb 25 '22 at 20:35