Send and Sync Deep Dive
Concurrency in Practice mentioned that Rc "isn't thread-safe" and that thread::spawn requires Send, but treated those as rules to follow. This tutorial explains the two traits behind them. Send and Sync are how Rust achieves "fearless concurrency": they encode thread-safety in the type system, so a data race becomes a compile error rather than a runtime heisenbug. Understanding them turns cryptic `Rc<...>` cannot be sent between threads safely errors into something you can actually reason about.
The Two Traits, Precisely
The two traits answer two different questions about a type T:
Send— a value ofTcan be moved to another thread. Almost everything isSend:i32,String,Vec<T>,Box<T>.Sync— a&T(shared reference) can be shared between threads. Equivalently,TisSyncif and only if&TisSend.
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
assert_send::<String>(); // ok — a String can move across threads
assert_sync::<String>(); // ok — &String can be shared across threads
The distinction matters: a type can be Send but not Sync (movable to one thread at a time, but not shareable, like Cell/RefCell), or Sync but rarely the reverse. Most concurrency bugs the compiler catches are really "you tried to share something across threads that isn't Sync," or "you tried to move something that isn't Send."
Auto Traits: You Don't Implement These
Send and Sync are auto traits: the compiler implements them automatically for any type whose fields are all Send/Sync. You never write impl Send for MyStruct, you compose a struct out of Send parts and it becomes Send for free, transitively.
struct Worker {
id: u32, // Send + Sync
name: String, // Send + Sync
queue: Vec<Job>, // Send + Sync if Job is
}
// Worker is automatically Send + Sync — no impl needed, derived from its fields
The consequence: a type is !Send/!Sync precisely when it contains something that is. Add one Rc<T> field to that Worker and the whole struct silently becomes !Send, the property propagates up from the fields with no annotation. This is why the fix for a "not Send" error is usually to change a field's type (swap an Rc for an Arc), not to add a trait impl.
Why Rc and RefCell Are Not Thread-Safe
The two most common !Send/!Sync types are Rc and RefCell, and knowing why makes the errors predictable rather than mysterious:
Rc<T>is!Sendand!Syncbecause its reference count is a plain, non-atomic integer. If two threads cloned/dropped the sameRcat once, they'd race on that counter, corrupting it and causing a double-free or leak.Arc<T>(Smart Pointers Demystified) uses an atomic counter, which is why it isSend + Sync(and costs slightly more).RefCell<T>is!Syncbecause its borrow-tracking flag is non-atomic too; two threads callingborrow_mut()simultaneously could both think they got exclusive access.Mutex<T>/RwLock<T>(Interior Mutability) do the synchronization properly, so they'reSync.
use std::rc::Rc;
use std::thread;
let data = Rc::new(5);
thread::spawn(move || {
println!("{data}"); // ERROR: `Rc<i32>` cannot be sent between threads safely
});
The error isn't arbitrary, it's the compiler refusing to let a non-atomic refcount cross a thread boundary where it could be raced. Swap Rc for Arc and it compiles, because Arc's atomic count is safe to touch from multiple threads.
The Send + 'static Spawn Bound
thread::spawn's signature is the place these bounds bite most often: F: Send + 'static. The Send half means the closure (and everything it captures) must be movable to the new thread; the 'static half (Lifetimes in Practice) means it can't hold any borrowed reference that might dangle once the spawning function returns.
fn spawn_work<F: FnOnce() + Send + 'static>(f: F) {
std::thread::spawn(f);
}
Together they're why a spawned thread must either own its data (move it in) or share it through Arc, you can't lend it a stack reference, because the thread may outlive that stack frame. The 'static bound is not "lives forever"; it's "contains no non-'static borrows," exactly the distinction from the lifetimes tutorial. When you see closure may outlive the current function, the fix is to move owned data in or wrap shared data in Arc.
Gotcha: in async code, the analogous trap is holding a
!Sendvalue across an.await. A multi-threaded runtime (Async/Await with Tokio) may move a task to another thread at each await point, so the entire future must beSend, which means every value alive across an await must beSend. Holding aRefCellborrow guard, anRc, or aMutexGuardfrom the wrong (sync) mutex across.awaitmakes the future!Sendand produces a confusing "future cannot be sent between threads" error pointing at thespawn, not the real culprit. Drop the!Sendvalue (or end the borrow) before the await, or use async-aware equivalents.
The unsafe Escape Hatch
Because they're auto traits, the rare manual implementation is unsafe, you're asserting a thread-safety property the compiler couldn't verify itself (Unsafe Rust Basics). This comes up almost exclusively when wrapping a raw pointer (e.g. an FFI handle) that you know is safe to move or share, but the compiler conservatively marks !Send because raw pointers are !Send by default.
struct FfiHandle(*mut std::ffi::c_void);
// asserting: the underlying C handle is safe to move across threads
unsafe impl Send for FfiHandle {}
This is a promise you are making and the compiler is trusting, get it wrong and you reintroduce exactly the data races Send/Sync exist to prevent. In ordinary safe code you never need this; the auto-derivation does the right thing.
Quick Reference
| Type | Send? | Sync? | Why |
|---|---|---|---|
i32, String, Vec<T> | ✅ | ✅ | plain data, no shared mutability |
Rc<T> | ❌ | ❌ | non-atomic refcount |
Arc<T> | ✅ | ✅ | atomic refcount |
RefCell<T> | ✅ | ❌ | non-atomic borrow flag (movable, not shareable) |
Mutex<T> / RwLock<T> | ✅ | ✅ | synchronized interior mutability |
*mut T / *const T | ❌ | ❌ | no safety guarantees (the unsafe impl case) |
The mental model: Send is about moving ownership across threads; Sync is about sharing a reference across threads. Both are auto-derived from a type's fields, so thread-safety composes automatically, and the single-threaded shortcuts (Rc, RefCell, Cell) are exactly the types that opt out, trading thread-safety for lower overhead.
Key Takeaways
Sendmeans a value can be moved to another thread;Syncmeans&Tcan be shared across threads (T: Sync⟺&T: Send). A type can beSendbut notSync(e.g.RefCell).- Both are auto traits: the compiler derives them for any type whose fields are all
Send/Sync. You don't implement them, you compose fromSend/Syncparts, and one!Sendfield makes the whole type!Send. Rc/RefCellare!Send/!Syncbecause their counters/flags are non-atomic;ArcandMutex/RwLockare the thread-safe equivalents that synchronize properly. The fix for a "notSend" error is usually swapping a field's type, not adding an impl.thread::spawnrequiresSend + 'static: the closure must own its data or share viaArc, and hold no non-'staticborrow, because the thread may outlive the spawning frame.- In async, the whole future must be
Sendon a multi-threaded runtime; holding a!Sendvalue (anRc, aRefCellguard, a syncMutexGuard) across.awaitmakes it!Send. End the borrow before the await or use async-aware types. - Manually implementing
Send/Syncisunsafeand rare, reserved for raw-pointer/FFI wrappers you've personally verified are thread-safe.