Internals · 07 / 11
Internals

Send + Sync (auto-traits)

"Fearless concurrency" is two empty traits and a derivation rule. Send and Sync carry no methods and generate no code — they are facts the compiler infers about every type and checks at every thread::spawn. This page covers how the inference works, why Rc fails it and Arc passes, the genuinely subtle bounds on Mutex and MutexGuard, and what you sign up for when you write unsafe impl Send.

Long read · the definitions through std's impls and the unsafe escape hatch · references at the end


1 · The two definitions

Both traits live in std::marker and are about which thread may touch a value:

  • T: Send — ownership of a T can move to another thread. The destination thread may use and drop it.
  • T: Sync — a &T can be shared with another thread. Formally: T: Sync if and only if &T: Send. That's the actual definition, not an analogy.
rust core/src/marker.rs · the entire API surface
pub unsafe auto trait Send {}
pub unsafe auto trait Sync {}

// "unsafe": a wrong manual impl causes UB, not a logic bug.
// "auto":   the compiler implements them for you, structurally.
// No methods. Pure compile-time facts.

A data race needs two threads reaching the same memory with at least one write and no synchronisation. Send and Sync make the "two threads reaching the same memory" part impossible to express in safe code unless every type involved has declared it can handle it. The proof lives in the type system; at runtime these traits don't exist.

2 · Auto-trait derivation — structural, recursive, silent

You never derive Send or Sync. For every type definition the compiler applies one rule: a struct or enum is Send if all its fields are Send; Sync if all its fields are Sync. Recursively, down to the leaves. Primitives are both. So almost everything you write is both, automatically — the property is infectious in the good direction.

rust src/main.rs · inference at work
use std::rc::Rc;

struct Job {                    // Send + Sync: all fields are
    id: u64,
    payload: Vec<u8>,
}

struct Cached {                 // NOT Send, NOT Sync:
    job: Job,
    memo: Rc<String>,           // <- one Rc poisons the whole struct
}

fn assert_send<T: Send>() {}

fn main() {
    assert_send::<Job>();       // fine
    assert_send::<Cached>();    // error[E0277]: `Rc<String>` cannot be
                                // sent between threads safely
}

The error message even walks the field chain ("required because it appears within the type Cached"). The reverse direction needs unsafety: leaf types that wrap a raw pointer or do un-synchronised interior mutation opt out, and everything containing them loses the trait. The opt-outs in std are written as negative impls (impl !Send for Rc<T> — the negative_impls feature, unstable for user code); raw pointers *const T / *mut T are neither Send nor Sync by default as a deliberate speed bump for unsafe authors.

3 · Why Rc isn't Send — and Arc is

Rc<T> keeps its strong/weak counts as plain Cell<usize> in the heap block shared by all clones. clone() is a non-atomic read-modify-write. Hand one clone to another thread and two threads now increment the same counter without synchronisation — a textbook data race, lost updates, double-free or leak. So Rc is neither Send (moving a clone moves access to the shared counter) nor Sync (&Rc lets you call clone).

rust src/main.rs · the compiler saying exactly this
use std::rc::Rc;
use std::thread;

fn main() {
    let shared = Rc::new(vec![1, 2, 3]);
    let clone = shared.clone();
    thread::spawn(move || {                 // error[E0277]:
        println!("{:?}", clone);            // `Rc<Vec<i32>>` cannot be sent
    });                                     // between threads safely
}
// note: the trait `Send` is not implemented for `Rc<Vec<i32>>`
// help: use `Arc` instead

Arc<T> makes the counts AtomicUsize and becomes eligible — but conditionally: Arc<T>: Send requires T: Send + Sync. Both bounds are necessary and each guards a different attack. T: Sync because clones on different threads deref to &T simultaneously. T: Send because whichever thread drops the last clone drops the T — through an Arc you can effectively move a value to another thread. Drop T: Send and you could smuggle an Rc across threads inside an Arc.

4 · The std table — and the two clever rows

TypeSendSyncWhy
&Tiff T: Synciff T: Syncsharing a reference is what Sync means
&mut Tiff T: Sendiff T: Syncan exclusive ref is as good as ownership
Rc<T>nononon-atomic refcounts
Arc<T>iff T: Send + Synciff T: Send + Syncshared deref + last-clone drop
Cell / RefCell<T>iff T: Sendnounsynchronised interior mutation
Mutex<T>iff T: Sendiff T: Sendsee below
MutexGuard<'_, T>noiff T: Syncsee below
*const T / *mut Tnonodeliberate speed bump for unsafe code

Row one to internalise: Mutex<T>: Sync requires only T: Send — not T: Sync. Sharing &Mutex<T> across threads never produces two simultaneous &T: the lock guarantees exclusive access, so what's actually happening is serialised hand-offs of the value between threads — and "may be handed between threads" is exactly T: Send. This is why Mutex<Cell<u32>> is perfectly fine to share: the mutex manufactures Sync from Send. (RwLock needs T: Send + Sync for Sync, because multiple readers hold &T concurrently.)

Row two: MutexGuard is not Send, whatever T is. On pthread platforms, unlocking a mutex from a thread that didn't lock it is undefined behaviour, so the guard — whose destructor unlocks — must die on the thread that created it. This is the real reason holding a std::sync::MutexGuard across an .await fails to compile on multi-threaded executors: the suspended future could resume on another worker thread carrying the guard with it.

rust std/src/sync/poison/mutex.rs · the impls, verbatim shape
unsafe impl<T: ?Sized + Send> Send for Mutex<T> {}
unsafe impl<T: ?Sized + Send> Sync for Mutex<T> {}

impl<T: ?Sized> !Send for MutexGuard<'_, T> {}
unsafe impl<T: ?Sized + Sync> Sync for MutexGuard<'_, T> {}

5 · Where the bounds bite: spawn

Auto-traits earn their keep at API boundaries. The two you hit weekly:

rust std + tokio · the signatures that enforce everything
// std::thread
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static;

// tokio (multi-threaded runtime)
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
    F: Future + Send + 'static,
    F::Output: Send + 'static;

A closure or future is a struct of its captures, so the auto-trait rule applies to it like any other type: capture an Rc, the closure isn't Send, the spawn won't compile. For futures it's sharper — the future type contains every local held across an await point, so an Rc created and dropped between awaits is fine, while one held across an await makes the whole future !Send. That is the entire decoding key for "future cannot be sent between threads safely": the compiler names the offending local and the await it lives across. (The ownership-meets-async page works through these errors in detail.)

6 · PhantomData and steering auto-traits without unsafe

PhantomData<T> is a ZST that tells the type system "act as if I contain a T" — for variance, for dropck, and for auto-traits. Since auto-traits are inferred from fields, a phantom field is the stable, safe way to steer them:

rust src/lib.rs · opting out on stable
use std::marker::PhantomData;

/// Handle bound to the thread that created it (e.g. a GUI or OpenGL handle).
/// PhantomData<*const ()> is neither Send nor Sync, so neither is Handle —
/// no unsafe, no nightly negative impls needed.
pub struct Handle {
    id: u32,
    _not_send: PhantomData<*const ()>,
}

/// FFI wrapper that owns a C object behind a raw pointer.
/// The phantom also tells dropck and variance we logically own a T.
pub struct CBox<T> {
    ptr: *mut T,
    _owns: PhantomData<T>,
}

The conventional recipes: PhantomData<*const ()> for !Send + !Sync; PhantomData<Cell<()>> for Send-but-!Sync. (Nothing useful is !Send-but-Sync; MutexGuard is the rare legitimate case and needs the nightly negative impl.) Crates like winit and rusqlite use exactly this to keep OS-thread-affine handles off other threads at compile time.

7 · unsafe impl Send — the contract you're signing

When you wrap a raw pointer (FFI handle, mmap region, lock-free node) your type loses both traits even if it's actually fine. The override is a one-liner with a heavy contract:

rust src/lib.rs · the override, done properly
/// Owned region returned by mmap. We are the unique owner; the kernel
/// doesn't care which thread calls munmap.
pub struct Mapping {
    ptr: *mut u8,
    len: usize,
}

// SAFETY: Mapping uniquely owns the region (no aliases are ever handed
// out except via &self/&mut self with normal borrow rules), and unmapping
// from any thread is allowed. Reads through &self are of immutable bytes.
unsafe impl Send for Mapping {}
unsafe impl Sync for Mapping {}

impl Drop for Mapping {
    fn drop(&mut self) {
        unsafe { libc::munmap(self.ptr.cast(), self.len) };
    }
}

What you are asserting, precisely: for Send, that every operation reachable through owned or &mut access stays sound when the value lives on a different thread than the one that created it (watch for thread-affine C APIs, TLS, OpenGL contexts). For Sync, that every operation reachable through &self is safe to run from many threads at once — which means: no unsynchronised interior mutability behind that shared reference. Getting it wrong is not a bug in the ordinary sense; it deletes the language's core guarantee for every downstream user of your type. The convention: always a // SAFETY: comment, and prefer bounding it (unsafe impl<T: Send> Send for MyBox<T>) so the obligation stays structural.

Review heuristic. An unsafe impl Send/Sync with no SAFETY comment, or one on a type containing Rc, RefCell or a non-thread-safe C handle, is the single highest-yield thing to flag in a Rust code review. It is also the classic fix people apply to make a spawn error "go away" — the compiler error was the correct behaviour.

8 · What Send + Sync don't promise

  • No deadlock freedom. Lock ordering is runtime behaviour. Two Sync mutexes acquired in opposite orders deadlock fine.
  • No race-condition freedom. Atomics are Sync; a check-then-act sequence on an AtomicUsize is still a logical race. Send/Sync rule out data races (unsynchronised conflicting access, which is UB), not concurrency bugs.
  • Nothing inside unsafe. The auto-trait machinery trusts every unsafe impl; one wrong assertion and the proofs above it are vacuous.
  • No runtime cost either way. Neither trait adds code. Arc is slower than Rc because of the atomics it needed to earn the traits, not because of the traits.

References

Found this useful?