Understanding Send and Sync in Rust Async Handlers
Daniel Hayes
Full-Stack Engineer · Leapcell

Introduction
You've probably been there: writing an async handler in Rust, feeling productive, only to hit a compiler error screaming about Send or Sync. It's a common stumbling block for developers new to Rust's concurrency model, especially when venturing into the world of async/await. This isn't just a cryptic error message; it's Rust's rigorous type system ensuring the safety and correctness of your concurrent code. Ignoring or misunderstanding these traits can lead to subtle, hard-to-debug data races or deadlocks. In this article, we'll peel back the layers of Send and Sync, explain why they are crucial for async handlers, and provide practical solutions to write robust, thread-safe asynchronous Rust code.
The Foundation of Rust Concurrency
Before we dive into the specifics of async handlers, let's establish a clear understanding of the core concepts that underpin thread safety in Rust: Send and Sync.
What are Send and Sync?
In Rust, Send and Sync are marker traits. They don't have any methods; their purpose is purely semantic, informing the compiler about the thread-safety properties of a type.
-
Send: A typeTisSendif it is safe to transfer ownership of a value of typeTfrom one thread to another. Most primitive types (likei32,bool), smart pointers likeBox<T>(ifTisSend), and many collection types (Vec<T>,HashMap<K, V>if their contents areSend) areSend. Conversely, raw pointers (*const T,*mut T) are notSendbecause transferring them without proper synchronization can lead to data races when dereferenced on different threads. Channels and mutexes are typicallySendbecause they manage internal state to allow safe cross-thread communication. -
Sync: A typeTisSyncif it is safe for multiple threads to have shared references (&T) to a value of typeT. This means thatTcan be safely accessed concurrently. ForTto beSync,&Tmust beSend, implying that a shared reference toTcan be sent to another thread and accessed there. Immutable data, likei32or&str, isSync. Types that provide internal mutability through mechanisms likeMutex<T>orRwLock<T>(ifTisSend) are alsoSyncbecause their internal mechanisms ensure safe concurrent access.RefCell<T>and raw pointers (*const T,*mut T) are notSyncbecause they don't provide thread-safe internal mutability.
Most types in Rust are Send and Sync by default (impl Trait for T {}). Types only become non-Send or non-Sync if they contain fields that are non-Send or non-Sync, or if they explicitly opt out using ![derive(Send)] or ![derive(Sync)] (though this is rare).
Why are Send and Sync important for Async?
Asynchronous Rust, powered by futures and executors, relies heavily on these traits even though it might seem like single-threaded execution at first glance. An async fn or async {} block desugars into a state machine that implements the Future trait. This Future needs to be safely polled by an executor.
Consider an executor (like Tokio or async-std) that manages a pool of threads. When you spawn a Future, await on another future, or move a closure into an async block, the executor might move the Future's state between threads to balance work, or poll it from a different thread on subsequent .await calls.
Futuremust beSend: If aFuturecontains state that is notSend, the executor cannot safely move theFuture's state between threads. This is crucial for multi-threaded executors that re-schedule tasks. If theFutureitself represents a task that isSend, then its internal state can be moved between threads at.awaitpoints.- Capturing environment variables: When an
asyncblock captures variables from its surrounding environment, these variables become part of theFuture's state. If these captured variables are notSend, then theFutureitself cannot beSend.
This is where the compiler errors often arise. You're creating an async block, and it implicitly captures something that isn't Send (or sometimes Sync), preventing the compiler from guaranteeing thread safety.
Common Scenarios Leading to "Not Send" Errors
Let’s look at some common pitfalls and their solutions.
Scenario 1: Capturing Rc<T> or RefCell<T>
Rc<T> (Reference Counted) and RefCell<T> (Reference Cell for interior mutability) are designed for single-threaded scenarios. They do not provide thread-safe access control.
Problematic Code:
use std::rc::Rc; use std::cell::RefCell; use tokio::task; #[tokio::main] async fn main() { let counter = Rc::new(RefCell::new(0)); // Error: `Rc<RefCell<i32>>` cannot be sent between threads safely // `RefCell<i32>` cannot be sent between threads safely // `Rc<i32>` cannot be sent between threads safely let handle = task::spawn(async move { // This closure captures `counter` by moving ownership, // and because it's an async block, the generated Future // needs to be Send. for _ in 0..100 { *counter.borrow_mut() += 1; } println!("Counter in task: {}", *counter.borrow()); }); handle.await.unwrap(); println!("Final counter: {}", *counter.borrow()); }
Why it fails: Rc allows multiple owners within a single thread. RefCell allows mutable borrows within a single thread without requiring mut on the owner. Neither provides synchronization mechanisms for multi-threaded access, hence they are not Send or Sync. The task::spawn function, designed for multi-threaded executors, requires its future argument to be Send.
Solution: Use Arc<T> and Mutex<T> / RwLock<T>
For multi-threaded shared ownership and interior mutability, use their thread-safe counterparts: Arc<T> (Atomic Reference Counted) and Mutex<T> (Mutual Exclusion Lock) or RwLock<T> (Read-Write Lock).
use std::sync::{Arc, Mutex}; use tokio::task; #[tokio::main] async fn main() { let counter = Arc::new(Mutex::new(0)); // Arc for shared ownership, Mutex for interior mutability let counter_clone = Arc::clone(&counter); // Clone the Arc for the task let handle = task::spawn(async move { for _ in 0..100 { // Lock the mutex to get exclusive access to the inner data let mut num = counter_clone.lock().unwrap(); *num += 1; } println!("Counter in task: {}", *counter_clone.lock().unwrap()); }); handle.await.unwrap(); println!("Final counter: {}", *counter.lock().unwrap()); }
Here, Arc<Mutex<i32>> is Send because Arc safely handles reference counting across threads, and Mutex ensures exclusive access to the i32 data even when multiple threads try to modify it.
Scenario 2: Holding on to Non-Send Types Across .await Points
Sometimes, the culprit isn't a type like Rc, but rather a temporary non-Send value that is implicitly captured by the async block's state machine across an .await point.
Problematic Code (Conceptual):
Imagine a scenario where an async block temporarily creates a non-Send resource, then awaits something, and then uses that non-Send resource again. Although typical OS-level non-Send handle types are rare in idiomatic Rust, this concept applies.
// This example is conceptual as std::process::Child stdin/stdout handles are indeed Sync // but illustrates the idea if a resource *were* non-Send #[tokio::main] async fn main() { let config = String::from("some_config_data"); // This block might capture `config` if `my_non_send_struct` borrowed it, for example. let _handle = tokio::spawn(async move { // Assume `MyNonSendType` is a type that is NOT Send. // If an instance of `MyNonSendType` were created here // or borrowed something from the environment that was not Send, // and then we hit an await point... // let some_data = MyNonSendType::new(); // Hypothetical non-Send type println!("Before await"); tokio::time::sleep(std::time::Duration::from_millis(10)).await; println!("After await. The `Future` might have jumped threads."); // If 'some_data' was captured across the await, and it's not Send, ERROR! // some_data.do_something(); }); }
Why it fails (conceptually): If an async block (which desugars into a Future) captures a non-Send variable and holds onto it across an .await point, the compiler will complain. This is because the executor might suspend the Future on one thread, and then resume it on a different thread after the await completes. If the non-Send variable was part of the Future's state, it would be moved across threads unsafely.
Solution: Move non-Send variables to local scopes or ensure they are properly synchronized.
- Move to local scope: If the non-
Sendvariable is only needed before or after an.awaitpoint, ensure its lifetime doesn't span across the.await. - Synchronize: If the non-
Sendvariable must persist and be accessed across.awaitpoints and potentially different threads, it needs to be wrapped in thread-safe primitives likeArc<Mutex<T>>orArc<RwLock<T>>.
Scenario 3: Closures and Fn vs FnOnce vs FnMut
When spawning async tasks, it's common to pass closures. The move keyword on a closure is crucial.
#[tokio::main] async fn main() { let mut my_data = 10; // Error: `my_data` is captured by reference implicitly, // and `my_data` is not Sync (it's mutable). // The spawned future would need `&mut i32` to be Send+Sync which it isn't. // let handle = tokio::spawn(async { // println!("Data from inner task: {}", my_data); // my_data += 1; // This would cause 'my_data' to be captured as `&mut` // }); // Correct: use `move` to transfer ownership. let handle = tokio::spawn(async move { println!("Data from inner task: {}", my_data); my_data += 1; // Now `my_data` is owned by the closure }); handle.await.unwrap(); // Cannot access my_data here, as ownership moved to the spawned task. // println!("Data in main: {}", my_data); }
Why it matters:
Without move, my_data would be captured by reference (&mut my_data). A mutable reference &mut T is valid only on the thread it was created on, making it non-Send across threads. When you spawn an async block, the outer task and the spawned task might operate on different threads.
Solution: Use move keyword
By using move in async move { ... }, ownership of my_data is transferred into the Future. Since i32 is Send, the Future containing my_data is also Send. If you need to share data while retaining access in the original scope, refer back to Arc<Mutex<T>>.
The for<'a> Future<&'a Context<'a>> Problem
This is a more advanced case often seen in generic async code or implementing traits that involve lifetimes. If your async handler needs to borrow data with a specific lifetime, say 'a, and that data is not Sync, then the Future cannot be Send. This happens if the executor needs to move the Future and its borrowed data across threads, but the borrowed data cannot be safely shared implicitly.
Conclusion
The Send and Sync traits are fundamental pillars of Rust's thread safety guarantees, extending their reach deeply into asynchronous programming. When your async handler throws an error about Send or Sync, it's not a hindrance but a helpful alert from the compiler, preventing potential data races and undefined behavior. By understanding that async blocks desugar into Futures whose state might be moved between threads by an executor, you can correctly identify when to use thread-safe primitives like Arc and Mutex instead of their single-threaded counterparts like Rc and RefCell, and leverage the move keyword effectively. Embracing these core concepts is key to writing robust, high-performance, and truly thread-safe asynchronous applications in Rust. Your compiler is your friend, guiding you towards safer concurrency.

