Trying to understand how control is yielded and resumed in async code

So I have this code

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    
    let res = async {
        sleeep().await
    };

    res.await;
    dbg!("hello world");
}

async fn sleeep() {
    dbg!("entered sleeping");
    sleep(Duration::from_secs(10)).await;
    dbg!("done sleeping");
}

When I ran it, this happens

[src/main_async.rs:15] "entered sleeping" = "entered sleeping"
--- 10 seconds pause ---
[src/main_async.rs:17] "done sleeping" = "done sleeping"
[src/main_async.rs:11] "hello world" = "hello world"

I was expecting entered sleeping to be called, then the sleep(Duration::from_secs(10)).await call is the blocking call, and since that is the case, I was expecting the execution of the sleep function to be paused with control yielded back, which will then have dbg!("hello world") called...and after 10 seconds, then dbg!("done sleeping"); should be called.

Basically I was expecting

[src/main_async.rs:15] "entered sleeping" = "entered sleeping"
[src/main_async.rs:11] "hello world" = "hello world"
--- 10 seconds pause ---
[src/main_async.rs:17] "done sleeping" = "done sleeping"

Why is it then that the await call of sleep(Duration::from_secs(10)).await does not yield control out of the sleeep function to allow the other code to continue executing? As this is what I thought happens when await lines within an async functions are called.

You're explicitly awaiting the sleeping future, which means "don't continue until this future is completed".

If you want main to continue while the sleep happens, you should use tokio's spawn function to create a new top level task. Though obviously if main returns the program will exit without waiting for the sleep task to finish.

6 Likes

And for that, there's JoinHandle.

3 Likes

You can also run multiple things interleaved without spawning a task, using join! or similar operators:

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    tokio::join!(
        sleeep(),
        async {
            dbg!("hello world");
        }
    );
}

async fn sleeep() {
    dbg!("entered sleeping");
    sleep(Duration::from_secs(2)).await;
    dbg!("done sleeping");
}
[src/main.rs:14] "entered sleeping" = "entered sleeping"
[src/main.rs:8] "hello world" = "hello world"
[src/main.rs:16] "done sleeping" = "done sleeping"

A spawn() will allow execution to proceed using a different thread, independently of the caller, but sometimes that's superfluous or undesired. join! allows the futures it is given to take turns running, so when the sleep occurs, the second future with hello world gets to run.

4 Likes

Is it possible you're confusing the wau Rust's await keyword works with the way await works in another language?

I think I have a fundamental misunderstanding of what the .await syntax does. I thought it means, this call is going to block, so the thread is free to go do something else.

If it means don't continue until this future is completed then how is it different from a normal thread blocking on a call that blocks?

Possibly. And that's where I am here to get my confusion resolved :slight_smile:

await means that the future can yield the thread for it to do other work for other futures. Top level tasks represent other futures that can run during awaits.

If await allowed the current future to continue it would be impossible to return values from futures.

1 Like

I think this is clear and aligns with what I thought .await does - ie this is a blocking operation, so yield the thread to do something else.

I am not sure I fully understand this. What do you mean by top level tasks? How do they relate to threads? I believe it is the threads that blocks so should that not be the fundamental unit in this discussion?

Do you mean "allowed the current thread to continue?"

And how is using spawn() now different from me creating a new thread for the tasks and using JoinHandle to get the final results?

The join!() you mentioned to me sounds more inline with how I thought asynchronous, non-blocking works. Basically allow a thread to do other things on encountering a blocking call, instead of waiting for the blocking operation to conclude.

So not sure how spawn() is async, feels to me like normal traditional multi threading?

tokio::spawn does not necessarily create a new OS thread. The tokio runtime could choose to make a separate thread, but it could also choose to execute all the tasks concurrently on the same thread. Within a thread, it runs one task until it hits an await, then switches to another task until that one awaits, then goes back to check if the first one is ready to continue, and so on.

.await more or less means "I can't make progress until this thing finishes, so please async runtime, go check if any other tasks are waiting to use this thread." [1] If there are no other tasks, the thread will just sit there doing nothing for a little bit.


  1. to be more specific, await polls the underlying Future, and if that underlying Future returns Poll::Pending, the task yields. ↩︎

You may wonder, now: "why async instead of regular threads?" Several reasons:

  1. Async tasks (futures that have been spawned) are cheaper to create and switch between than OS threads, so you can execute lots of them using a small thread pool (or a single thread).
  2. Async tasks allocate exactly as much memory as they need (because the Future they are made from is a struct with a size computed by the compiler); a thread needs to be given a stack of a generous size. So, less memory is used by many tasks on one thread than by each task having its own thread.
  3. You can use select!() (and other combinators) to wait on multiple dissimilar event sources (for example, a socket, a message channel, and a timeout timer); in order to do this naively with threads, you need one thread for each source or at least type of source you block on, and when one wakes up you may be stuck with the other threads still blocking.

All of these things together are why async is considered good for network servers — it provides lots of things that work well for a process which is receiving and reacting to many incoming messages, and needs to be efficient and not readily disrupted by incoming misbehavior.

5 Likes

I think I might be getting it. To further clarify, why then won't this work?

#[tokio::main]
async fn main() {

    let res1 = async {
        sleeep().await
    };

    let res2 = async {
        sleeep().await
    };

    // I can't make progress until this thing finishes, so please async runtime, go check if any other tasks are waiting to use this thread
    res1.await;
// Then async runtime goes to run res2.await
    res2.await;
    dbg!("hello world");
}

async fn sleeep() {
    dbg!("entered sleeping");
    sleep(Duration::from_secs(2)).await;
    dbg!("done sleeping");
}

Why is the first await() not telling the async runtime to go attempt to run the next line, which is res2.await - I have a feeling the answer lies in the concept of tasks which I have not fully understood yet.

In the sentence "I can't make progress", "I" is the async block containing the await. So in this case, by awaiting res1, you are saying that the rest of the main() function is waiting on res1 to finish. If you had spawned another task beforehand (or used a combinator like join!, etc), that other task would start executing. But you didn't spawn anything else, there are no other tasks, so there is nothing else for the tokio runtime to do,

1 Like

The async runtime doesn't know anything about “lines” in your program; it just polls futures. An async block produces a future which specifically does sequential execution. res1.await; means "when I'm polled, I will poll res1, and do nothing else until it completes." All non-sequential execution must be done with something that isn't async {} — whether that's spawn(), join!(), or something else.

Also note that the async runtime has no idea that res2 (or even res1 exists); it just sees your main future and polls it. async {} does not tell the runtime anything, and await only controls how this future makes progress when polled. The only way tasks (top-level futures) get registered with the runtime is when you explicitly do so with spawn(), block_on(), etc. (The #[tokio::main] attribute macro generates a block_on() that wraps the main you write.)

3 Likes

I would suggest going through the async book, which explains all of this in more detail.

1 Like

await is a yield point, which is terminology from cooperative multitasking. You're probably familiar with preemptive multitasking like OS threads which yield whenever the OS scheduler decides. Instead, cooperative tasks like async fn yield explicitly only where the programmer puts an await keyword.

With this in mind, the async fn main() task yields to the tokio scheduler first at res1.await. Since there are no other tasks spawned, the executor effectively has no other work to do until the sleep finishes. Once that event occurs, the executor wakes up and resumes the async fn main task from the last yield point. Which happens to be the next line: res2.await. Another yield point! This process continues until the task ends.

1 Like

After reading through the responses, I can say for certain I was confused by my understanding of how await works in JavaScript...where the runtime is built into the language, where there is one thread, and where an await in a function means the function yields control, and what comes after it can be executed. In such a model no need to explicitly have tasks registered with the runtime, as it is the case in async Rust

1 Like

Let's take a step back.

Your code has the print statements in the order "entered sleeping", "done sleeping", and "hello world". Why would you expect any other order?

Async is just like normal sequential code, except that control flow can be suspended at await points. But it doesn't jump around randomly. The point of async is that you can write your concurrent code as if it were sequential.

This is not true in JavaScript either. Inside an async function, the code after an await won't run until the awaited promise settles. Think about this: if that were not the case, how could you possibly get a return value out of await? Neither of these languages is willing to execute code just because it happens to not have a dependency on data not yet available.

In fact, in both languages, the point of async/await syntax is to mimic sequential execution in a concurrent environment. When you want concurrency, you must use something that isn't purely awaiting other async functions.)

However, it might be useful to note a way in which JS and Rust are very different:

no need to explicitly have tasks registered with the runtime

This is true, sort of. In Rust, a Future is an arbitrary computation that eventually completes when it is polled. In JavaScript, a Promise contains no computation whatsoever; rather, some other code is running by its own means (which can be an independent task or it can be just a callback somewhere) and eventually commands the promise to become settled (to have a value or error). When that happens, all callbacks registered on the promise fire — these can be registered by .then() or by await. In the await case, it's just like the remainder of the function was wrapped in a closure and passed to .then(). The "registration of tasks" happens when those callbacks fire, because the promise always runs them as microtasks rather than synchronously.

There are a lot of implementation differences, but they don't change how this async/await code works. Both languages agree on what it means, more or less. Here's a JavaScript analogue of your code:

async function main() {

    let res1 = async () => {
        await sleeep();
    };

    let res2 = async () => {
        await sleeep();
    };

    await res1();
    await res2();

    console.log("hello world");
}

async function sleeep() {
    console.log("entered sleeping");
    await sleep(2000);
    console.log("done sleeping");
}

function sleep(durationMs) {
    return new Promise((resolve) => setTimeout(resolve, durationMs));
}

main();

(Notice that the async blocks have become async functions. This is because JavaScript has no equivalent to async blocks, and async functions with no arguments are the closest analogue, except that they can be started (called) more than once.)

If you run this, you'll see it will be sequential; it will print

entered sleeping
done sleeping
entered sleeping
done sleeping
hello world

However, we can change it to concurrent by not delaying the sleep calls:

async function main() {

    let res1 = sleeep();
    let res2 = sleeep();

    await res1;
    await res2;

    console.log("hello world");
}

// rest of code same as previous

What is happening in this revision is that each sleep call gets invoked without awaiting them, so they both have the same start time for the timers, and they sleep for overlapping periods.

Now, how about Rust? Yes, Rust's paradigm is different in important ways, but you can actually get exactly the same results without any extra operations:

use std::future::Future;
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {

    let res1 = sleeep();
    let res2 = sleeep();

    res1.await;
    res2.await;
    dbg!("hello world");
}

fn sleeep() -> impl Future<Output = ()> {
    dbg!("entered sleeping");
    let sleep_future = sleep(Duration::from_secs(2));
    async {
        sleep_future.await;
        dbg!("done sleeping");
    }
}

The key thing here is that the time at which the sleep future wakes is set by when you call the tokio::time::sleep() function (just as in JavaScript it is set by when you call setTimeout()). In order to cause it to be called promptly, I made two changes:

  • I got rid of the superfluous async blocks for res1 and res2. (They're both still futures, just different ones.)
  • I changed sleeep() itself into a non-async function. It still returns a future, but it has an opportunity to do something before that future is polled.

Now, one can argue that this is not really demonstrating any concurrency — it's just playing games with timer start points. That's true. But we can also consider starting a timer to be a trivial case of “starting an independent async task that is registered with the runtime” — look at sleep() as if it were spawn(). In that view, the thing that we are doing here is exactly the same thing as you have to do to have independent tasks running concurrently: you have to start them both before you wait for either of them to finish.

2 Likes