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 of T can be moved to another thread. Almost everything is Send: i32, String, Vec<T>, Box<T>.
  • Sync — a &T (shared reference) can be shared between threads. Equivalently, T is Sync if and only if &T is Send.
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 !Send and !Sync because its reference count is a plain, non-atomic integer. If two threads cloned/dropped the same Rc at 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 is Send + Sync (and costs slightly more).
  • RefCell<T> is !Sync because its borrow-tracking flag is non-atomic too; two threads calling borrow_mut() simultaneously could both think they got exclusive access. Mutex<T>/RwLock<T> (Interior Mutability) do the synchronization properly, so they're Sync.
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 !Send value 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 be Send, which means every value alive across an await must be Send. Holding a RefCell borrow guard, an Rc, or a MutexGuard from the wrong (sync) mutex across .await makes the future !Send and produces a confusing "future cannot be sent between threads" error pointing at the spawn, not the real culprit. Drop the !Send value (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

TypeSend?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 Tno 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

  • Send means a value can be moved to another thread; Sync means &T can be shared across threads (T: Sync&T: Send). A type can be Send but not Sync (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 from Send/Sync parts, and one !Send field makes the whole type !Send.
  • Rc/RefCell are !Send/!Sync because their counters/flags are non-atomic; Arc and Mutex/RwLock are the thread-safe equivalents that synchronize properly. The fix for a "not Send" error is usually swapping a field's type, not adding an impl.
  • thread::spawn requires Send + 'static: the closure must own its data or share via Arc, and hold no non-'static borrow, because the thread may outlive the spawning frame.
  • In async, the whole future must be Send on a multi-threaded runtime; holding a !Send value (an Rc, a RefCell guard, a sync MutexGuard) across .await makes it !Send. End the borrow before the await or use async-aware types.
  • Manually implementing Send/Sync is unsafe and rare, reserved for raw-pointer/FFI wrappers you've personally verified are thread-safe.