Why do async impls suspend threads instead of running next 'coroutine'?

Resource 1: extreme/lib.rs at master · spacejam/extreme · GitHub

Resource 2: @2e71828 's Is this a valid async executor implementation?

As far as I can tell, the main difference between these two impls is that @2e71828 uses park/unpark, while sspacejam/extreme uses (Mutex, CondVar).

As @2e71828 already mentioned in that thread, park/unpark may or may not have a race condition -- we are not going to debate that here.

Here is what I want to know / my confusion. In my mind, one of the main points of async is "when one coroutine would block, the thread executes the next one"

Yet, when we look at both @2e71828 and spacejam/extreme, when one coroutine hits Pending, we either park() the thread or wait on a (mutex,condvar).

What is going on? I expected the code in both case to look like: pop next coroutine from vecdeque of ready coroutines, and start executing. In particular, I expected to see:

match blah.as_mut().poll(&mut cx) {
  Poll::Pending => {
    // this arm confuses me, I see park() and block on (mutex, condvar)
    // what I expected to see is:
    let t = get_first_ready_to_run_async();
    ... run t ... ; // using some trampoline to avoid causing stack overflow
  }
  Poll::Ready(x) => {
    // this arm I understand
    return x;
  }
}

tl;dr: they do, but they can't run the next coroutine if there is no next coroutine to run

Typically a runtime will only suspend the thread if none of its tasks/futures/coroutines are able to perform work right now. In mini-tokio, suspending the thread happens inside the recv call here:

fn run(&self) {
    while let Ok(task) = self.scheduled.recv() {
        task.poll();
    }
}

When no tasks have woken their waker, the channel is empty, and the recv call blocks (I'm not sure whether recv uses park or a condvar to block).

In the case of the two runtimes you have posted, there is only ever one task running, so there's no need to check whether there are other tasks that are able to run when the only task returns Pending.

2 Likes

I don't understand this line:

let xr = async { x = read_from_disk().await(); x + 1 };
let yr = async { y = read_from_other_disk().await(); y + 2 };
let z = join!(x, y);
let a = my_runtime.run(z); // should return (x+1, y+2)

In this situation, how does spacejam/extreme or @2e71828 behave ?

That's still only a single future. It's just that, internally, the future does work on more than one thing when you call its poll function.

Can you show an example of "more than one" future? Intuitively, join2 does 2 future -> 1 future, so by applying it (n-1) times, we can do n future -> 1 future.

What do you mean by "more than one" future then ?

By more than one future, I mean that the runtime itself has a list of futures, as opposed to only accepting a single future from the user. This way, you can do multiple things without using join.

If I am understanding this correctly, in this scenario, how does the async code inform the runtime "hey, add this new future to the list of futures" ?

By calling something like tokio::spawn I suppose.

Yes, exactly.

I think I got a fundamental wrong.

If you tokio::spawn something inside an async block, do you need to await on it for it to start execution ?

No, it already starts on its own :slight_smile: . Doing an .await call on the JoinHandle here is analogous to calling .join() on a handle to a spawned thread.

Interesting. This is a bit greedy -- can one of you async wizards show me how to add ::spawn to either spacejam/extreme or @2e71828 's impl? I think I am confusing a number of things, and seeing this impl would clear alot up.

That would be a rather long change, but the mini-tokio example has a spawn function. You can find the mini-tokio example here.

I see, so the "easier" approach would be:

  1. start with mini-tokio
  2. drop crossbeam::channel
  3. swap out the futures::ArcWake for RawWakerVTable ? (I'm a bit uncomfortable with just how much magic ArcWake is doing)

Also, is the thread-local variable pretty much required, since the api call to spawn is my_tokio::spawn rather than my_tokio_object.spawn, i.e. the async code does not have an ref to the runtime.

You could certainly try to rewrite mini-tokio in that way. An appropriate replacement for the crossbeam channel that is simpler could be an Arc<Mutex<VecDeque<...>>>.

And yes, a thread-local is necessary to implement a standalone spawn function. (Well, unless you want a global runtime, in which case a static can be used instead.)

It is probably worth pointing out that mini-tokio does not really store the list I mentioned previously. Instead, the list it stores is the list of spawned futures that have been notified. Spawned futures that are not notified are simply not stored in the runtime at all, and when you notify them, they will add themselves to the runtime's list.

(mini-tokio stores the list as messages in the crossbeam channel)

From my (admittedly limited) point of view, this is the biggest weakness of Rust's async design. There really should be a better mechanism for the executor to pass some context into the futures it's polling.

At one point, for example, I tried to write a custom executor to manage animations in a game engine. I had to give up the idea because of all the trouble I had giving the futures access to the SDL context, despite all of the futures running on the graphics thread.

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.