rustrust-tokio

why does tokio::spawn require a Send bound for local variables


my test code:

use tokio::task::yield_now;
use std::rc::Rc;

#[tokio::main]
async fn main() {
    tokio::spawn(async {
        let rc = Rc::new("hello");
        println!("{}", rc);

        yield_now().await;
    });
}

I don't understand,since a Tokio task runs on only one thread at a time, there won't be any data races involving the local variable rc across multiple threads. So why does tokio::spawn require a Send bound for local variables?

is compile failed:

   Compiling playground v0.0.1 (/playground)
error: future cannot be sent between threads safely
   --> src/main.rs:6:5
    |
6   | /     tokio::spawn(async {
7   | |         let rc = Rc::new("hello");
8   | |         println!("{}", rc);
...   |
11  | |     });
    | |______^ future created by async block is not `Send`
    |
    = help: within `{async block@src/main.rs:6:18: 6:23}`, the trait `Send` is not implemented for `Rc<&str>`, which is required by `{async block@src/main.rs:6:18: 6:23}: Send`
note: future is not `Send` as this value is used across an await
   --> src/main.rs:10:21
    |
7   |         let rc = Rc::new("hello");
    |             -- has type `Rc<&str>` which is not `Send`
...
10  |         yield_now().await;
    |                     ^^^^^ await occurs here, with `rc` maybe used later
note: required by a bound in `tokio::spawn`
   --> /playground/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.39.2/src/task/spawn.rs:167:21
    |
165 |     pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
    |            ----- required by a bound in this function
166 |     where
167 |         F: Future + Send + 'static,
    |                     ^^^^ required by this bound in `spawn`

error: could not compile `playground` (bin "playground") due to 1 previous error

Solution

  • You are correct. This is not necessary for soundness. It is just that the compiler is being overly strict.

    tokio::spawn() requires the future to be Send, and future is not Send if it holds any non-Send value across .await points. But what we really need is another auto trait, not Send, let's call it SendNoEscape, that will be implemented for Rc (because if it does not escape from the future, it is sound to move it between threads), and not implemented for, say, MutexGuard (that really needs to stay on the same thread). Unfortunately, such trait does not exist, and it may never be.

    Emphasis by @Cerberus: values captured by the async code (including async fn parameters) must still implement Send (as there can be copies on other threads); but values created in the async code can be SendNoEscape. The compiler can easily track that.

    Edit: I was correctly pointed out on IRLO that I was missing one important detail - thread locals. Our async block can store the Rc in a thread local, move thread, other code in the original thread will access the thread locals, and dang! we got two Rc pointing at the same object within different threads. A similar exploit can be found with the async block accessing a thread local. Since we probably want thread locals to be sound, that means this API has to be unsound.