Rust Local Tasks
A local task is a task that stays on one thread for its whole life. Local tasks may hold values that are not Send, like an Rc across an .await.
A task that stays on its thread
spawn_local starts a task inside the set โ it will never be moved to another thread:
task::spawn_local(async {
println!("this task never leaves its thread");
});spawn refuses this future
An Rc held across an .await makes the future !Send, and spawn will not take it. The compiler error says so plainly: "future cannot be sent between threads safely".
let data = Rc::new(String::from("not send"));
tokio::spawn(async move {
println!("{data}");
sleep(Duration::from_millis(1)).await; // Rc is still alive here
println!("{data}");
}); // ERROR: the future is not SendThe same future, welcomed locally
Inside a LocalSet, spawn_local takes the future that spawn refused โ the Rc never has to leave the thread:
task::spawn_local(async move {
println!("{data}");
sleep(Duration::from_millis(1)).await;
println!("{data}");
});Rc and RefCell just work
Inside the set, the single-threaded pair needs no lock and no Send proof:
let counter = Rc::new(RefCell::new(0));
let inside = Rc::clone(&counter);
task::spawn_local(async move { *inside.borrow_mut() += 1; })
.await
.unwrap();
println!("counter = {}", counter.borrow());Await the set to finish everything
A LocalSet can also take tasks directly. Awaiting the set itself runs every one of them to its end:
let local = LocalSet::new();
local.spawn_local(async { println!("local task ran"); });
local.await;
println!("all local tasks finished");Example
// Cargo.toml: tokio = { version = "1", features = ["full"] }
use std::cell::RefCell;
use std::rc::Rc;
use tokio::task::LocalSet;
#[tokio::main(flavor = "current_thread")]
async fn main() {
// Rc is not Send, so tokio::spawn would refuse this data.
let log = Rc::new(RefCell::new(Vec::new()));
let local = LocalSet::new();
local
.run_until(async {
let log2 = Rc::clone(&log);
tokio::task::spawn_local(async move {
log2.borrow_mut().push("first task ran");
})
.await
.unwrap();
let log3 = Rc::clone(&log);
tokio::task::spawn_local(async move {
log3.borrow_mut().push("second task ran");
})
.await
.unwrap();
})
.await;
println!("log = {:?}", log.borrow());
}Key points
- A LocalSet is a group of tasks that all live on one thread, so !Send values are welcome inside it
- block_in_place is a hint for worker threads.
- It says "this thread is about to block".
- It is banned inside local tasks.
- It is also banned on current-thread runtimes.
- There it panics.
- Use spawn_blocking instead.
- Local tasks suit thread-bound work: a GUI thread, a library that is not Send, or squeezing one busy core.
In this example, one log โ an Rc<RefCell<Vec>> โ is shared by two local tasks. Rc cannot cross threads, so tokio::spawn would refuse any future holding it. Instead, a LocalSet runs on this one thread. run_until drives the set. Inside it, the main future spawn_locals two tasks. Each task clones the Rc. It pushes its line into the same Vec. There is no Arc and no Mutex. Nothing ever leaves the thread. When both handles are awaited, the log holds both lines, in the order the tasks ran.
Note
Ordinary tasks should stay on spawn, so the runtime can balance them across threads.
Try it Yourself
Edit the code, then press Run.
Running is simulated ยท Rust Playground
Exercise
Why does tokio::spawn refuse a future that holds an Rc across an .await?