Understanding Rust async code execution

I'm trying to get an idea of how asynchronous code works in lower levels.

I've been writing synchronous Rust at my day job for the past 6 months. I'm fairly confident in my ability at understanding synchronous Rust code (borrowing etc are fine).

I have written a lot of Kotlin Coroutines code. I understand how code gets rewritten into a state machine, and how state is passed from one suspension point to another. I've read the Async book, and I get why Pin is necessary and how it works.

What I don't understand is what happens during an await. According to the Async book and to the Without Boats conference in 2019, it seems like futures are polling for their result.

The intuition I've built is that the main advantage of asynchronous programming is that it allows to do something else while the thing doing the work (wether it's another thread, an IO system call etc) does its thing.
However, if futures are polling for their result, how can they ever do something else in the meantime?

Either I've missed some important ways the caller is notified when the future is ready, without them having to actively poll, or work only happens during polling, in which case asynchronous in Rust must mean something very different that in does in other languages I've used.

Have you seen this page?

1 Like

it seems like futures are polling for their result.

Let me make this a little more precise: futures have a poll() method. When a future is polled, it will then recursively poll whatever future or futures it contains. That is, the code

async {
    foo.await;
    bar.await;
}

turns into a Future implementation that is roughly like (pseudocode, leaving out details)

impl Future for SomeAsyncBlock {
    fn poll(&mut self, context: &mut Context) {
        match self {
            Self::WaitingForFooState { foo, bar } => {
                match foo.poll(context) {
                    Poll::Ready(_) => *self = Self::WaitingForBarState { bar },
                    Poll::Pending => Poll::Pending,
                }
            }
            Self::WaitingForBarState { bar } => {
                match bar.poll(context) {
                    Poll::Ready(_) => Poll::Ready(()),
                    Poll::Pending => Poll::Pending,
                }
            }
        }
    }
}

So, a future being polled polls its 'children', but not in a loop. That's the key. The loop is only in the executor, so the executor can always do something else between pollings. That's how the executor can (doesn't have to, but can) execute multiple tasks on a single thread β€” it just needs to poll each task's future when woken (e.g. by IO) and no single future hogs the thread.

3 Likes

First, thanks for the link, as always with Rust documentation it is very well written.

I think that answers all my questions, and it seems like the core idea is basically the same as Kotlin coroutines, but I can't stop from thinking that the implementation is overly complicated. Why return the value via the Poll result if the function already must tell its caller that it is ready via Waker, why not just give the result through Waker? The requirement to manage Waker, which doesn't appear in the typesystem, and therefore the compiler cannot know if you did correctly, seems very anti-Rust.

I guess most of this is explained by the async keyword generating all of it for you, which I guess means you can't get it wrong. What happens when you want to call a blocking function from an async one though, do you have to write all of this in that case?

As a side note, I hope Delay being implemented by starting a thread is just for the tutorial and it's not how it's actually implemented.

Why return the value via the Poll result if the function already must tell its caller that it is ready via Waker , why not just give the result through Waker ?

The key here is that the Waker can be passed through multiple levels. In my impl Future for SomeAsyncBlock example above, the code is passing the Context, and therefore the Waker, through to its child future. But the output value is not the output of whatever child finished, it's whatever SomeAsyncBlock wants it to be, and it might not be ready yet (e.g. when we're transitioning from polling foo to polling bar). waker.wake() doesn't mean "I have a return value for you", it means "please poll me so that I can make progress on the computation".

Wakers are almost always invoked by β€œleaf” futures that perform IO, timers, or receiving events from channels or callbacks. (And they can be called from any thread, not necessarily the thread the executor is polling the futures on.) Non-leaf futures β€” async blocks and other futures that compose futures β€” then do the actual work to piece together the results of those leaf futures into the actual result the application wants.

As a side note, I hope Delay being implemented by starting a thread is just for the tutorial and it's not how it's actually implemented.

Yes, certainly. tokio and any other full-featured async executor will have a timer queue used to efficiently implement delays.

The reason it's presented as an example is because delays aren't part of what's built in to Rust β€” they're the simplest example of a β€œleaf” future that must be implemented using custom code and not simply an async block.

5 Likes

Thanks for the answers, it makes a lot more sense now.