Why must local variables in async functions satisfy `Send`?

Disclaimer: I'm a rust noob so might be missing some obvious things here...

So the rule is that when Futures are ran on a multi-threaded runtime (like Tokio), they need to be Send so that one thread can take a task from another thread for scheduling, and this essentially prevents usage of types that are not Send across await points. Examples include Rc, RefCell etc...

I'm not understanding why must these types be Send. Variables with types like Rc wrapped in a Future (and it's associated state machine) should never be shared between threads because these Futures should not have two copies executing concurrently on different threads. So it seems to me that being able to hold a Rc across await points even tho they can be transferred as part of a Future to other threads is perfectly legitimate. Currently I will have to use Arc instead of Rc, which is fine but feels really weird because I'm really just accessing these Arc variables from a single thread at all times. This feels even more weird if I need a mutable field wrapped in an Arc and have to use Mutex/RwLock (from Tokio) instead of a RefCell.

(Perhaps the runtime is doing some smart optimizations that secretly shares things between threads underneath the hood, but I have not seen discussions of it anywhere...

1 Like

If your Rc state was allowed to be sent to another thread, but another handle to the same Rc allocation existed elsewhere in the original thread, then updates to the Rc counters would not be synchronized between those two threads.

6 Likes

Tokio can move an async task from one thread another while the task is suspended at an .await, so any local variable that lives across an .await must be able to survive being moved to a new thread.

4 Likes

Thanks a lot!

So only the Rc in the local scope but not other Rcs used in the current thread are encapsulated within the context of a Future/task (which are sent across threads)?

I think I somewhat mixed up the idea of OS thread context switching and Rust async context switching then...

Yes. You will notice that Tokio talks about async "tasks" not threads.

There is likely to be many threads spread over the many cores in your machine. Each thread can handle many async tasks. In this way async work can be spread over cores for a performance boost.

This is by contrast to something like Javascript, which operates totally asynchronously but does it all in one thread on one core.

I was pondering this recently as I did not much like the word "actor" to describe tasks in "the actor model". See Alices' recent post on this. But we do need a different word to name those things, distinct from "process" (as used in the OS), "thread" (as used in your program), and "tasks" as defined by Tokio and all.

1 Like

It might be helpful to see Future as closure which can pause itself. Closure captures only the values mentioned in its body, not the full thread scope.

2 Likes

I spent some time thinking about this, and unfortunately I still don't quite understand it

Tokio tasks/async functions are Send because they can be sent between threads for scheduling and execution, but it is not Sync because one task will not be accessed concurrently by two threads.

Rc is not Send because there's a race if thread A sends a Rc to thread B and thread A and B clone the Rc instance at the same time. So to me the real reason why Rc is not Send is actually because it's not Sync. However, if we create a Rc within an async function and and don't send it to other threads other than through Future/Task scheduling. Then the Future/Task is the only unit of execution that can touch the Rc in an async function.

As an example:

async fn loop() {
    let rc = Rc::new(3);
    for _ in 0..10 {
        async {
            rc.clone();
        }
    }
}

Although we are possibly sending rc through many threads, the async function loop wrapped in a future is the only unit of execution that can touch it at any time. So it seems to me that this is fine, unless there are issues about memory coherence when the rc is sent between threads.

It is true that sending a Rc to a new thread is fine as long as you send all clones of it together, ensuring that only one thread has clones of the same Rc. However, the way that Rc is implemented, this kind of move is not allowed. Rust guarantees that anything that compiles is sound - it does not guarantee that anything that is sound will compile.

9 Likes

I see... thanks!

I was reading the docs for tokio recently, and it says that Send types are allowed, as long as they don't exist across a yield (await). You know your code will run sequentially and not be moved in between yield points.

For one case, It's possible to put the Rc into thread local static in safe code.

3 Likes

At the type level, there's nothing that distinguishes a fully contained Rc from one that may have clones elsewhere, even though you as a whole-program analyst might be able to determine whether that is the case.

If you really wanted to assert your point, you could define your own wrapper type around Rc and add an unsafe impl Send. It would therefore become your safety responsibility to make sure that analysis is upheld, now and in all future changes. I would not advise this unless you're sure that safe alternatives are insufficient, like switching to Arc.

3 Likes

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.