Skip to content
rusthacks.dev
Concurrency

LocalSet and Non Send Futures in Tokio Explained Made Clear

tokio::spawn demands Send because tasks can move between threads. LocalSet pins tasks to one thread so Rc, RefCell and other !Send types can cross awaits.

7 min read
Summarize in AI
Flat illustration for the post “LocalSet and Non Send Futures in Tokio Explained Made Clear”, composed around ownership.

The first time a program holds an Rc across an .await inside tokio::spawn, the compiler answers with a page of text that ends at a type three layers down. The message is long and the cause is short: tokio::spawn may move a task to another thread, and an Rc must never leave its thread. LocalSet is the tool for the case where that is exactly what you want. Everything here compiles on Rust 1.99 with edition 2024 and tokio 1.54.

The error you will see first

This program uses an Rc<RefCell<Vec<u32>>> as a cheap shared log and holds it across a yield.

src/main.rsrust
// does not compile
use std::{cell::RefCell, rc::Rc};

#[tokio::main]
async fn main() {
    let log = Rc::new(RefCell::new(Vec::new()));
    let handle = tokio::spawn({
        let log = Rc::clone(&log);
        async move {
            log.borrow_mut().push(1);
            tokio::task::yield_now().await;
            log.borrow_mut().push(2);
        }
    });
    handle.await.unwrap();
}
Text
error: future cannot be sent between threads safely
 --> src/main.rs:7:18
  |
  = help: within `{async block@src/main.rs:9:9: 9:19}`, the trait `Send` is not implemented for `Rc<RefCell<Vec<i32>>>`
note: captured value is not `Send`
 --> src/main.rs:10:13
  |
  |             log.borrow_mut().push(1);
  |             ^^^ has type `Rc<RefCell<Vec<i32>>>` which is not `Send`
note: required by a bound in `tokio::spawn`
  |         F: Future + Send + 'static,
  |                     ^^^^ required by this bound in `spawn`

We trimmed the source excerpts, but the three notes are the compiler's own. Read them from the bottom: the bound that rejected the program is F: Future + Send + 'static in tokio::spawn, and the note above it names the culprit, a captured Rc<RefCell<Vec<i32>>>. It is stored in the future's state across the yield_now, so the whole future is not Send. Notice what the message does not say: the Rc is harmless if nothing ever moves it. The compiler cannot know that, which is why the choice of where the task runs has to be explicit.

Run it on a LocalSet

A LocalSet is a scheduler for tasks pinned to the thread that runs it. spawn_local accepts a future with no Send bound, because the set guarantees the task never leaves.

src/main.rsrust
use std::{cell::RefCell, rc::Rc};
use tokio::task::LocalSet;

#[tokio::main]
async fn main() {
    let log = Rc::new(RefCell::new(Vec::new()));
    let local = LocalSet::new();

    let handle = local.spawn_local({
        let log = Rc::clone(&log);
        async move {
            log.borrow_mut().push(1);
            tokio::task::yield_now().await;
            log.borrow_mut().push(2);
        }
    });

    local.run_until(async { handle.await.unwrap() }).await;
    println!("log = {:?}", log.borrow());
}
Text
log = [1, 2]

Two things changed. The task is created with local.spawn_local, and the set is driven by run_until, which polls local tasks and the future you pass until that future completes. A LocalSet does nothing unless something drives it: if you forget run_until (or .await on the set itself), the spawned task never runs and nothing warns you.

The first panic: forgetting the set

spawn_local exists as a free function too, and it only works while a LocalSet is running on the current thread. Calling it from a plain #[tokio::main] task fails at runtime, not at compile time.

Text
thread 'main' panicked at tokio-1.54.1/src/task/local.rs:445:29:
`spawn_local` called from outside of a `task::LocalSet` or `runtime::LocalRuntime`

The fix is always the same: create a LocalSet and either call local.spawn_local(...) on the value, or call tokio::task::spawn_local from a future that is itself running inside local.run_until(...).

Many tasks, one thread, no locks

The payoff is shared mutable state without a mutex. Several local tasks can mutate one Rc<RefCell<T>> because they take turns on the same thread, and a RefCell borrow can never overlap an .await point if you release it before awaiting.

src/main.rsrust
use std::{cell::Cell, rc::Rc, time::Duration};
use tokio::task::LocalSet;

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let counter = Rc::new(Cell::new(0u32));
    let local = LocalSet::new();

    for worker in 0..4 {
        let counter = Rc::clone(&counter);
        local.spawn_local(async move {
            for _ in 0..250 {
                counter.set(counter.get() + 1);
                tokio::time::sleep(Duration::from_micros(10)).await;
            }
            println!("worker {worker} done");
        });
    }

    local.await;
    println!("total = {}", counter.get());
}
Text
worker 0 done
worker 1 done
worker 2 done
worker 3 done
total = 1000

Four tasks, 1,000 increments, no atomics and no lock. local.await runs the set until every task in it has finished. With a Cell the read-modify-write is safe even without a RefCell borrow, because nothing can interleave between get and set: there is no await between them.

The cheaper fix: do not hold it across the await

Before reaching for a LocalSet, check whether the non-Send value really needs to span the await. A value that is created and dropped between two awaits does not become part of the future's state, and the future stays Send.

src/main.rsrust
use std::rc::Rc;

fn summarize(words: &[&str]) -> usize {
    // Rc is fine inside a synchronous helper: it never reaches the future
    let shared = Rc::new(words.to_vec());
    shared.iter().map(|w| w.len()).sum()
}

#[tokio::main]
async fn main() {
    let total = tokio::spawn(async {
        let n = summarize(&["rust", "async", "send"]);
        tokio::task::yield_now().await;
        n
    })
    .await
    .unwrap();
    println!("total letters: {total}");
}
Text
total letters: 13

This compiles under plain tokio::spawn because the Rc lives and dies inside summarize, between awaits. Compare that with the failing version, where the Rc was created outside the block and captured. Where the value comes from decides whether it is part of the future.

A RefCell borrow across an await still panics

Single-threaded does not mean borrow-safe. If a local task holds a borrow_mut() over an .await and another local task borrows the same cell, the second one panics at runtime.

Text
thread 'main' panicked at src/main.rs:14:21:
already borrowed: BorrowMutError

Keep the guard in a block that ends before the await, or copy what you need out first. Clippy's await_holding_refcell_ref lint catches the common form, and it is worth enabling for any crate that uses LocalSet.

Using a LocalSet inside a multi-thread runtime

You do not need the current-thread flavor to use one. LocalSet::run_until can be awaited from any task, and the local tasks then run on whichever worker is polling that call. This is convenient for one component that holds a non-Send value, such as an embedded interpreter, while the rest of the service stays multi-threaded.

src/main.rsrust
use std::rc::Rc;
use tokio::task::LocalSet;

fn render_page() -> String {
    // an Rc inside, so the whole call is thread-bound
    let title = Rc::new(String::from("local"));
    format!("<h1>{title}</h1>")
}

#[tokio::main]
async fn main() {
    let html = tokio::task::spawn_blocking(|| {
        let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
        let local = LocalSet::new();
        rt.block_on(local.run_until(async { render_page() }))
    })
    .await
    .unwrap();
    println!("{html}");
}
Text
<h1>local</h1>

The pattern here is a dedicated thread (through spawn_blocking) that owns a small current-thread runtime and its LocalSet, with results returned through the join handle or a channel. The non-Send values never leave that thread, and the main runtime only ever sees Send data (choosing a channel).

What Send actually costs you

If the reason for Rc is only speed, measure before you restructure. We cloned and dropped one pointer 100 million times in a release build and timed each type.

src/main.rsrust
use std::hint::black_box;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Instant;

fn main() {
    const N: u64 = 100_000_000;
    let rc = Rc::new(7u64);
    let start = Instant::now();
    let mut sum = 0u64;
    for _ in 0..N {
        let c = black_box(Rc::clone(&rc));
        sum += *c;
    }
    println!("Rc  clone+drop:  {:>7.1?} for {N} (sum {sum})", start.elapsed());

    let arc = Arc::new(7u64);
    let start = Instant::now();
    let mut sum = 0u64;
    for _ in 0..N {
        let c = black_box(Arc::clone(&arc));
        sum += *c;
    }
    println!("Arc clone+drop:  {:>7.1?} for {N} (sum {sum})", start.elapsed());
}
Text
Rc  clone+drop:  425.5ms for 100000000 (sum 700000000)
Arc clone+drop:  972.6ms for 100000000 (sum 700000000)

The atomic version costs about 4.3 ns per clone and drop pair, against 9.7 ns for Arc, so roughly 5 ns more. That is real, and it is also a price you pay a few times per request, not per byte. A service that clones a pointer ten thousand times per request spends 50 microseconds on the difference. For most programs it will not show in a profile, so reach for LocalSet when a type truly cannot be Send, and not to save an atomic. We ran this once, in release mode, with black_box to keep the optimizer from deleting the loop; a different CPU will move both numbers, and the ratio is the part that travels.

When a LocalSet is the wrong answer

  • If the only reason for Rc is habit, switch to Arc and use tokio::spawn. The cost of an atomic counter is small, and you keep multi-core scheduling.
  • If the non-Send type is held briefly, restructure so it is dropped before the .await. The future becomes Send and no special runtime is needed. The error page for that is future cannot be sent between threads safely.
  • If many components need the same non-Send resource, an actor that owns it and receives messages scales better than spreading spawn_local through the code (the actor model in Tokio).
  • CPU-heavy local tasks still block their siblings, as on any single thread (the current-thread runtime, blocking the runtime).
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.