When data crosses the thread boundary
You are building an async web server. You have a shared database pool. You spawn a task to handle an incoming request and pass the pool to it. The compiler rejects the code with a trait bound error. You fix it, deploy, and suddenly the server segfaults under load. Two tasks are mutating the same connection at the same time. The crash isn't a bug in your logic. It is a violation of thread safety guarantees that the compiler was trying to enforce.
Send and Sync are the mechanisms Rust uses to prevent this. They are marker traits that control how data moves and is shared across threads. They exist solely to tell the compiler whether a type is safe to use in a multi-threaded context. Most types implement them automatically. When they don't, the compiler stops you before you can shoot yourself in the foot.
Send moves ownership. Sync shares references.
Send and Sync have no methods. They carry no behavior. They are purely informational. The compiler uses them to enforce rules at compile time.
Send means a type can transfer ownership to another thread. If a type is Send, you can move a value of that type from the main thread into a spawned thread. The new thread becomes the sole owner. The old thread loses access.
Sync means a type can be shared between threads via references. If a type is Sync, you can hand a reference (&T) to multiple threads simultaneously. All threads can read the data at the same time. If the type allows mutation, Sync guarantees that the mutation is safe, usually through internal synchronization like a mutex.
Think of Send like handing a physical package to a courier. The courier takes the package and drives it to another city. Once the courier has it, you don't have it anymore. The package moved. The courier is responsible for it now.
Think of Sync like pointing at a whiteboard in a shared office. Multiple people can look at the whiteboard at the same time. The whiteboard doesn't move. Everyone just reads what is written there. If someone tries to erase the whiteboard while others are reading, chaos ensues. Sync guarantees that reading is safe. If writing is allowed, the whiteboard has a lock that only one person can hold at a time.
Minimal example
Here is a struct that implements Send and Sync by default.
use std::thread;
/// A simple struct containing an integer.
/// i32 is Send and Sync, so Counter inherits both traits.
struct Counter {
count: i32,
}
fn main() {
// Create a counter on the main thread.
let counter = Counter { count: 42 };
// Move ownership of counter into a new thread.
// This requires Counter to be Send.
let handle = thread::spawn(move || {
// Access the value inside the new thread.
println!("Count: {}", counter.count);
});
// Wait for the thread to finish.
handle.join().unwrap();
}
The move keyword forces the closure to take ownership of counter. The compiler checks if Counter implements Send. Since i32 is Send, and Counter contains only i32, the compiler derives Send for Counter. The code compiles. The data moves safely.
The compiler checks at compile time
When you call thread::spawn, you are creating a new OS thread. The closure runs on that thread. The move keyword captures variables by value. For this to work, the captured variables must be safe to transfer. The compiler verifies the Send bound.
If you try to move a type that is not Send, the compiler rejects the code. You get error E0277 (trait bound not satisfied). The error message tells you exactly which field is causing the problem.
use std::rc::Rc;
use std::thread;
struct NonSendData {
// Rc is not Send. It uses reference counting without atomic operations.
// Moving Rc across threads causes data races on the counter.
data: Rc<String>,
}
fn main() {
let data = NonSendData {
data: Rc::new("hello".to_string()),
};
// This fails to compile.
// NonSendData contains Rc, which is not Send.
// The compiler prevents moving data into the closure.
thread::spawn(move || {
println!("{}", data.data);
});
}
The compiler stops you. Rc uses a non-atomic reference count. If two threads increment the count at the same time, the count can become corrupted. Memory leaks or double-frees follow. Rc is fast for single-threaded code. It is unsafe for multi-threaded code. The compiler enforces this distinction.
Sync is just Send for references
There is a deep relationship between Send and Sync. A type T is Sync if and only if &T is Send.
This definition simplifies the compiler's job. Sharing a reference across threads is exactly the same as moving that reference to another thread. If you can hand a reference to another thread safely, the type is Sync.
This means Sync is not a separate concept. It is a derived property. If &T can be sent, T is sync. If &T cannot be sent, T is not sync.
This relationship explains why Mutex<T> is Sync. A Mutex protects its inner data. You can hand a reference to a Mutex to another thread. The other thread can lock the mutex and access the data safely. The reference is Send. Therefore, Mutex is Sync.
It also explains why RefCell<T> is not Sync. A RefCell uses runtime borrowing checks. Those checks are not thread-safe. You cannot hand a reference to a RefCell to another thread. The reference is not Send. Therefore, RefCell is not Sync.
Async runtimes enforce Send
In async Rust, Send matters even more. Async runtimes like Tokio use a thread pool. They spawn tasks and move those tasks between threads to keep all cores busy.
When you call tokio::spawn, you hand a future to the runtime. The runtime might run that future on any thread in the pool. If the future captures data, that data must be Send. The runtime needs to move the future between threads. If the future is not Send, the runtime cannot guarantee safety.
use std::rc::Rc;
#[tokio::main]
async fn main() {
let data = Rc::new(42);
// This fails to compile.
// Rc is not Send.
// tokio::spawn requires the future to be Send.
// The runtime may move this task to a different thread.
tokio::spawn(async move {
println!("{}", *data);
});
}
The error is E0277. The future captures data. data is Rc. Rc is not Send. The future is not Send. tokio::spawn rejects it.
Some runtimes support !Send tasks. These tasks are pinned to a single thread. They never move. This is useful for types that hold thread-local state. Tokio offers a current_thread runtime for this purpose. The current_thread runtime does not require Send futures. It runs everything on the calling thread.
Convention aside: Use #[tokio::main(flavor = "current_thread")] only when you have a specific reason to avoid Send. The multi-threaded runtime is the default for a reason. It utilizes all your cores. Restricting yourself to one thread throws away performance unless you are dealing with !Send types that cannot be wrapped.
Fixing non-Send types
When you hit a Send error in async code, you usually need to replace Rc with Arc and RefCell with Mutex.
Arc stands for Atomic Reference Counting. It uses atomic operations to update the reference count. Atomic operations are safe across threads. Arc is Send and Sync.
Mutex stands for Mutual Exclusion. It ensures only one thread accesses the inner data at a time. Mutex is Sync. The inner data can be Send or !Send, but the mutex protects it.
use std::sync::{Arc, Mutex};
struct ThreadSafeState {
// Arc is Send and Sync. It uses atomic reference counting.
// Mutex ensures only one thread accesses the inner data at a time.
data: Arc<Mutex<String>>,
}
fn safe_spawn() {
let state = ThreadSafeState {
data: Arc::new(Mutex::new("hello".to_string())),
};
// Clone the Arc to share ownership.
// Arc::clone increments the atomic counter.
let state_clone = Arc::clone(&state.data);
std::thread::spawn(move || {
// Lock the mutex before accessing the data.
// This blocks other threads until the lock is released.
let guard = state_clone.lock().unwrap();
println!("{}", *guard);
});
}
Convention aside: Write Arc::clone(&data) instead of data.clone(). Both compile and do the same thing. The explicit form signals to readers that you are cloning the reference count, not the inner data. data.clone() looks like a deep clone. Arc::clone(&data) makes the shallow clone obvious. This small detail reduces cognitive load for anyone reviewing your code.
When you must go unsafe
Sometimes you have a type that the compiler cannot analyze. You might be wrapping a C library that uses thread-local storage. Or you might be building a custom allocator. The compiler sees raw pointers and assumes the worst. It marks the type as !Send and !Sync.
You can override this with unsafe impl. This tells the compiler, "I know what I am doing. Trust me."
struct UnsafeWrapper {
ptr: *mut u8,
}
// SAFETY:
// 1. The pointer is only accessed through a Mutex in the surrounding code.
// 2. The memory is allocated and freed exclusively on the thread that holds the lock.
// 3. No aliasing occurs; only one reference exists at a time.
unsafe impl Send for UnsafeWrapper {}
unsafe impl Sync for UnsafeWrapper {}
The unsafe keyword shifts the burden of proof to you. The compiler will no longer check thread safety for this type. If your invariants are wrong, you get data races. Data races are undefined behavior. Your program can crash, corrupt memory, or leak secrets.
Treat the SAFETY comment as a proof. If you can't write it, you don't have one.
Decision matrix
Use Send when you need to move ownership of a value to another thread. Use Sync when you need to share a reference to a value across threads. Reach for Arc<T> when you need shared ownership of data across threads. Reach for Mutex<T> or RwLock<T> when you need to mutate shared data safely. Reach for Rc<T> when you are in a single-threaded context and want to avoid the overhead of atomic operations. Reach for unsafe impl Send only when you are wrapping a type that the compiler cannot analyze, and you can prove thread safety through invariants. Reach for current_thread runtime when your tasks capture !Send types and cannot be wrapped in safe abstractions.