Why does holding a non-Send type across an await point result in a non-Send Future?
Asked Answered
G

1

18

In the documentation for the Send trait, there is a nice example of how something like Rc is not Send, since cloning/dropping in two different threads can cause the reference count to get out of sync.

What is less clear, however, is why holding a binding to a non-Send type across an await point in an async fn causes the generated future to also be non-Send. I was able to find a work around for when the compiler has been too conservative in the work-arounds chapter of the async handbook, but it does not go as far as answering the question that I am asking here.

Perhaps someone could shed some light on this with an example of why having a non-Send type in a Future is ok, but holding it across an await is not?

Geology answered 5/2, 2021 at 10:37 Comment(6)
Everything you hold on to across an await needs to be stored in the future, since the function returns at this point, storing its state. And if you store something that's not Send in a future, the future itself can't be Send.Indoiranian
That does seem pretty intuitive now that you have said it like that. So what is the situation where I pass something that is not Send as an argument to an async fn, which has being called to generate the Future but has not yet been polled. I guess under the hood these arguments are also stored inside the generated Future?Geology
That depends on how the async fn uses it. If the async fn consumes it before await-ing then the future would not need to store it, and should thus be Send.Rawhide
@Rawhide Even if the async fn consumes the argument(s) before first await, I think the future will still need to store them. When the async fn is called, it returns the future immediately, and AFAIK the body of the function doesn't start executing until poll() is invoked on the future.Manna
Yes, this is one of the differences between async fn foo() and fn bar() -> impl Future: bar can do work immediately upon being called, whereas foo has to store all its arguments and wait to be polled.Kauffmann
@Manna ah true, that's also a difference between async functions and functions with an async block, probably.Rawhide
A
22

When you use .await in an async function, the compiler builds a state machine behind the scenes. Each .await introduces a new state (while it waits for something) and the code in between are state transitions (aka tasks), which will be triggered based on some external event (e.g. from IO or a timer etc).

Each task gets scheduled to be executed by the async runtime, which could choose to use a different thread from the previous task. If the state transition is not safe to be sent between threads then the resulting Future is also not Send so that you get a compilation error if you try to execute it in a multi-threaded runtime.

It is completely OK for a Future not to be Send, it just means you can only execute it in a single-threaded runtime.


Perhaps someone could shed some light on this with an example of why having a non-Send type in a Future is ok, but holding it across an await is not?

Consider the following simple example:

async fn add_votes(current: Rc<Cell<i32>>, post: Url) {
    let new_votes = get_votes(&post).await;
    *current += new_votes;
}

The compiler will construct a state machine like this (simplified):

enum AddVotes {
    Initial {
        current: Rc<Cell<i32>>,
        post: Url,
    },
    WaitingForGetVotes { 
        current: Rc<Cell<i32>>,
        fut: GetVotesFut,
    },
}
impl AddVotes {
    fn new(current: Rc<Cell<i32>>, post: Url) {
        AddVotes::Initial { current, post }
    }

    fn poll(&mut self) -> Poll {
        match self {
            AddVotes::Initial(state) => {
                let fut = get_votes(&state.post);
                *self = AddVotes::WaitingForGetVotes {
                     current: state.current,
                     fut
                }
                Poll::Pending
            }
            AddVotes::WaitingForGetVotes(state) => {
                if let Poll::Ready(votes) = state.fut.poll() {
                    *state.current += votes;
                    Poll::Ready(())
                } else {
                    Poll::Pending
                }
            }
        }
    }
}

In a multithreaded runtime, each call to poll could be from a different thread, in which case the runtime would move the AddVotes to the other thread before calling poll on it. This won't work because Rc cannot be sent between threads.

However, if the future just used an Rc within the same state transition, it would be fine, e.g. if votes was just an i32:

async fn add_votes(current: i32, post: Url) -> i32 {
    let new_votes = get_votes(&post).await;

    // use an Rc for some reason:
    let rc = Rc::new(1);
    println!("rc value: {:?}", rc);

    current + new_votes
}

In which case, the state machine would look like this:

enum AddVotes {
    Initial {
        current: i32,
        post: Url,
    },
    WaitingForGetVotes { 
        current: i32,
        fut: GetVotesFut,
    },
}

The Rc isn't captured in the state machine because it is created and dropped within the state transition (task), so the whole state machine (aka Future) is still Send.

Ankeny answered 5/2, 2021 at 13:3 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.