Futures and Mutexes

Hi,
I'm having a bit of trouble understanding how I can make futures work with mutexes.

Let's say I have the following structure:

struct Kitchen { /** */ }
impl Kitchen {
   /* Imagine potentially many functions here for things you could do in a kitchen */
   /// Makes a cheesecake. Can take some time.
   pub async fn make_cheesecake(&mut self) {
    /** */
  }
  /// Will wait until there is a cheesecake to eat
  pub async fn eat_cheesecake(&mut self) {
    /** */
  }
}

And several actors that may access this kitchen, but they all need exclusive access (there's only one sink, one burner, or whatever). Actors all have Arc references to the workplace, and because they need mutability, they get an Arc<Mutex<Workplace>> over it.

Each actor runs as it own task that gets scheduled by the landlord which is a custom Executor of sorts here (keeps track of all tasks, and polls through them in some obscure way).

So let's say I have actor 1 that says:

async fn actor1(kitchen_ref: Arc<Mutex<Kitchen>>) {
  /* */
  kitchen.lock().make_cheesecake().await;
  /* */
}

And actor 2 that does the counterpart:

async fn actor2(kitchen_ref: Arc<Mutex<Kitchen>>) {
  /* */
  kitchen.lock().eat_cheesecake().await;
  /* */
}

Let's now say that actor2 gets executed first. If I understand correctly, it will take the lock for the kitchen, and therefore keep a mutexguard for the kitchen in the fields of the newly created impl Future (return type of eat_cheesecake). Now actor1 gets executed, it tries to acquire the lock, and it fails. Actor2 gets executed again, can't eat any cheesecake, returns. Actor1 again, etc. Seems like a deadlock.

What I would like is that each time the Future is polled, it tries to acquire the lock. If it fails, it returns Pending. Otherwise, it returns the result of the poll. But in any case, it should not acquire the lock for the entire waiting time, only during the call to poll(). This would allow actor1 to run as expected and make that delicious cheesecake.

I've seen that futures_util offers some futures-aware Mutex, but I am under the impression that it would be the same. I would have something like kitchen.lock().await.eat_cheesecake().await; but the actor1 would still wait forever since actor2 still keeps the mutex once it acquired it (or so it seems to me).

Is there something I missed or misunderstood about futures and mutexes? If not, do you have any idea on how I could proceed to make both actors happy?

I hope the culinary MRE was clear

Generally, things with async functions do not go inside an std mutex, because you cannot use await while an std mutex is locked. You could use a tokio::sync::Mutex instead, or perhaps the actor pattern is more appropriate for your kitchen?

3 Likes

Thing is, I'm on an alloc but no_std environment, so even using futures_util::lock::Mutex was somewhat hypothetical (the kitchen Executor here is really a custom one). So I would not be able to use tokio afaik. I'll look into the actor pattern link you provided thanks, can it be alloc-only compatible? (can I use something else than tokio to implement it?)

You could try to copy the Tokio mutex into your codebase. I don't think it uses anything special that wouldn't work in no_std+alloc. (It is implemented in terms of the std mutex, but it sounds like you already have one of those.)

The link you gave is really clear thks
But from what I understand of the actor model, and what is said in the link, it would mean that actor1 needs to know it will send to actor2 (and more than that, have a handle into actor2)? What I currently have (but I can think of how I could change that), is that actor1 makes the cheesecake, leaves the cheesecake on the table, then actor2 enters the kitchen, sees it and eat it. In some sense, they do not necessarily need to know each other to exchange, they just happen to have one place in common where they leave things (and I guess with that formalism, if actor1 wanted to tell who deposited the cheesecake, they would just need to attach a provenance tag on the cheesecake)

Yes indeed I have already the no_std Mutex from spin, I'll look at the tokio codebase then and see how I could use it

There's something I've not understood yet however
Even if I use tokio::sync::Mutex, what difference will it make? actor1 will asynchronously wait for the lock, but actor2 will still keep the lock for themselves during all the time they await for the cheesecake, won't they? So I guess it would mean that actor1 will still be pending forever on the lock(), while actor2 would be pending forever on the eat_cheesecake()

If both need to access the inner value concurrently, then you cannot wrap it in a mutex. How do you want them to notify each other? You could use something like a tokio::sync::Notify to have one caller wake the other one up, by using it while not holding the lock.

My first design was a kitchen with a single waker (hence a single "consumer"), and then extend it to have a slab of wakers, and wake everyone whenever I have a new thing to consume in the kitchen. That's probably where the kitchen analogy begin to be limited, though. I should probably have gone for something like a deposit place where you can deposit things, and whenever you deposit something, everyone gets notified something got deposited and go and get it.

In my opinion, they don't need access to it at the same time, so it shouldn't deadlock. The fact they are deadlocking is only because actor2 takes the lock for longer than it actually needs to

And what about having a special future wrapper that re-implements poll() and re-acquires the lock on each poll? I'm not sure how I could do it, but would it not be possible in theory?

You can't really have it reacquire the lock on each poll transparently because doing that invalidates all references inside the lock. Instead, you have to explicitly lock and unlock it in your code between your awaits.

Not only that, but if this were done, it would not be the actor pattern. An essential part of an actor is that while processing a single message/command from beginning to end, nothing else can possibly be modifying its state.

The usual way to define an actor in Rust is to create a message channel (which ensures that incoming messages are processed fairly) and a single async task that reads it.

@alice Ah yes indeed, I understand better now why it's not possible. I'm nonetheless in the special case where I know I won't use any references into my kitchen between polls of eat_cheesecake, because while you can't eat the cheesecake, you simply wait for it. I'm not even sure to see what I mean, but there's even no way of saying that the poll can be done without keeping other references to the lock between the polls?

@kpreid Although I mentioned actors in my OP, I do not necessarily need the actor pattern though. Just looking for some way to implement the model I showcased. But I'll make a try, although I'm not sure if that model fits my needs

You should unlock the mutex while you're waiting.

In that case, how can actor2 release the lock while waiting? I don't see how they could do otherwise than kitchen.lock().eat_cheesecake().await; or kitchen.lock().await.eat_cheesecake().await

You need to use some other mechanism for waiting, e.g. a tokio::sync::Notify or tokio::sync::Semaphore. If nobody is currently making chessecake, then mutexes do not provide any way to wait for someone to arrive and start making it.

In the short term, what I managed to do finally was to create a custom future EatCheesecakeFuture around a Arc<Mutex<Kitchen>> and turn the asynchronous function eat_cheesecake() into a fn eat_cheesecake(self) -> EatCheesecakeFuture. Then I implement Future for my EatCheesecakeFuture, the polling being done with Pin::new(&mut *self.0.lock()).poll(ctx). I guess this could be generalized to functions that have similar signature to eat_cheesecake(), and this effectively circumvents the deadlock in my particular case.

In the long term, I'll look at the actor model and try to implement it instead, as it's much closer to what I would ideally have in place, and maybe use bits of tokio. Thanks for the hints!

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.