all_lessons/Rust/16 · Send and Synclesson 17 / 23

Send and Sync — the law across threads

Lesson 15 moved the law's check to run time: a reference count and a borrow flag, each a plain read-modify-write that assumes one thread. Threads break the assumption: two of them can reach one place at once, and if either writes, that is a data race — Lesson 01's hazard with the ordering removed. This lesson extends the law across threads without a single run-time check. The compiler answers two questions about every type — may a value move to another thread (Send), may a shared reference be used from several at once (Sync) — derives both from the type's parts, and thread::spawn's signature demands them. A data race becomes a type error; the only run-time price is the atomics and locks you ask for by name.

The thesis, here
The compiler knows nothing about threads. It knows two marker traits it derives for any type from its parts, and it checks bounds; thread::spawn is an ordinary library function whose bounds say what may cross. Every guarantee here is those two facts combined — which is why the same error appears in a program with no thread at all.
Linear position
Forced by: Rc and RefCell keep the law but check it while the program runs, within one thread. Threads share memory, and the same shared-mutation hazard between them is a data race. How does the law extend across threads?
New idea: a data race is the law broken by two threads at once, so before a value crosses, the compiler must know whether it may move to another thread (Send) and whether a shared reference to it may be used from several threads at once (Sync, which is exactly &T: Send) — both derived from the type's parts, both demanded by thread::spawn's signature.
Forces next: Send and Sync tell the compiler which values may cross, or be shared between, threads. Given those safe building blocks, how do we structure real concurrent programs — sharing, messaging, borrowing from a parent — and what does the promise still not cover?
The plan
Seven moves. (1) Put Lesson 15's shared counter on two threads and say what would break if it compiled. (2) Ask the two questions a compiler must answer about a type before it crosses. (3) Derive the answers from a type's parts, and find where every "no" starts. (4) Run the solver on the lesson's examples. (5) Read the standard types' answers off the rule, with Arc and Mutex priced. (6) Find the enforcement point: a bound on thread::spawn, checked capture by capture. (7) Say what the two traits do not promise.

1 · A shared counter on two threads

A hit counter that several parts of a program update is the textbook case for Lesson 15's tools: Rc gives it several owners, RefCell lets any of them change it. Now let two threads count. In C++ this compiles, runs, and is a data race — two threads, one location, writes, no synchronization — which the language leaves undefined (C++ · 17, C++ · 19):

#include <thread>
int main() {
    int hits = 0;                                    // one place, two threads
    std::thread a([&] { for (int i = 0; i < 1000; ++i) ++hits; });
    std::thread b([&] { for (int i = 0; i < 1000; ++i) ++hits; });
    a.join(); b.join();
    return hits;                                     // a data race: undefined behavior
}

Lesson 01 wrote this with a plain local and got E0499: two exclusive names for one place. Lesson 15's answer to "several names, one counter" is Rc<RefCell<…>>, which the checker accepts on one thread. On two:

use std::cell::RefCell;
use std::rc::Rc;
use std::thread;

fn main() {
    let hits = Rc::new(RefCell::new(0));     // Lesson 15's shared, mutable counter
    let h2 = Rc::clone(&hits);               // a second owner, for the worker
    let worker = thread::spawn(move || *h2.borrow_mut() += 1);
    *hits.borrow_mut() += 1;                 // the main thread counts too
    worker.join().unwrap();
}
error[E0277]: `Rc<RefCell<i32>>` cannot be sent between threads safely
 --> src/main.rs:8:32
  |
8 |     let worker = thread::spawn(move || *h2.borrow_mut() += 1);
  |                  ------------- -------^^^^^^^^^^^^^^^^^^^^^^
  |                  |             |
  |                  |             `Rc<RefCell<i32>>` cannot be sent between threads safely
  |                  required by a bound introduced by this call
note: required because it's used within this closure
note: required by a bound in `spawn`

What would happen if it compiled? Both of Lesson 15's checks are ordinary integers updated by a read, an add and a write — std documents Rc's counts and RefCell's borrow counter as non-atomic. The worker drops h2 as it finishes, perhaps at the instant main drops hits: both read 2, both write 1, and the counter is never freed. Race a clone against a drop and the count can instead end one too low and reach zero while a handle still exists — a use-after-free. The borrow flag has the same window: both threads read "not borrowed", both get a RefMut, and two exclusive names exist at once — the overlap the flag was built to forbid. On two threads, the checker becomes the race.

The thread did nothing wrong; it is only a way for a second name to reach a place without being ordered after the first (Lesson 01 §2). The danger is in the data: which places a value lets two threads reach, and whether writes to them are synchronized. So the check belongs on the types of the values that cross — we need a question the compiler can ask of any type.

2 · Two questions about any type

A thread can receive a value in two ways, and each needs its own answer.

Hand it over. The new thread becomes the owner and, by Lesson 04's rule, the old name is dead. That is safe exactly when the move leaves the old thread no way to reach the value's places. It does for i32, String, Vec: each owns its bytes outright. It does not for Rc: the move carries one handle across, and the clones left behind still reach the same count. A type whose values may be moved to another thread is Send.

Share it. Both threads hold a shared reference and use it at once. The law allows any number of readers — provided reading only reads. Behind &i32 it does. Behind &Cell<i32> it does not: a shared reference to a Cell can write (Lesson 15), unsynchronized, so two threads holding one could write together. A type whose shared references may be used from several threads at once is Sync.

The second question is the first in disguise. To share t with another thread you send it a &T, so "may T be shared?" is "may a &T be sent?". Std defines it exactly so — T: Sync if and only if &T: Send — and implements it with one explicit rule for references. One notion, applied to a value and to a reference; §6 shows the compiler printing the definition inside an error.

Road not taken · four other ways to stop a race
Detect it while running, with a race detector like Go's (Go · 11): every access instrumented — Lesson 00's "heavy" ring, over budget — and only the races a run happens to reach are found. Make every count atomic and every cell locked: single-threaded code pays for synchronization it never uses — the Rust Book's reason types are not atomic by default. Never share memory: a copy per message. Document it ("thread-safe"): a promise nobody checks. What is left: two compile-time facts per type, free at run time, with atomics and locks as types you choose and pay for.

So the compiler needs Send and Sync for every type — including the ones you write, which nobody will think to annotate.

3 · Derived from the parts

Moving a struct moves each field; if every field may move to another thread, so may the whole. Sharing a struct shares each field through &, so if every field is Sync, so is the struct. The answer is computable from the parts, and the compiler computes it unasked: Send and Sync are auto traits. The Reference lists five (Send, Sync, Unpin, UnwindSafe, RefUnwindSafe) and the rule: a struct, enum, tuple or array has one when all its parts do, a closure when all its captures do. You write no impl, so there is nothing to forget:

use std::rc::Rc;

fn is_send<T: Send>() {}                          // a probe: compiles only if T: Send

struct Job { name: String, data: Vec<u8> }        // no impl written anywhere
struct Session { id: u64, user: Rc<String> }      // one field opts out

fn main() {
    is_send::<Job>();                             // every field is Send, so Job is
    is_send::<Session>();                         // Rc<String> is not, so Session is not
}
error[E0277]: `Rc<String>` cannot be sent between threads safely
  = help: within `Session`, the trait `Send` is not implemented for `Rc<String>`
note: required because it appears within the type `Session`

Job passed with no ceremony. Session failed, and the compiler named the part that sank it: Rc<String>, "within" Session.

If every answer is assembled from parts, every "no" must start at a part that says no by itself. The Nomicon names three. Raw pointers are neither — it calls this more a lint than a necessity, since nothing can be done through one without unsafe, but it stops a type built on them from becoming Send or Sync by accident. UnsafeCell, the one primitive for mutation through a shared reference, is not Sync, so neither are Cell and RefCell, built on it. Rc carries explicit negative impls, as do a few std types with reasons of their own (§5). Negative impls exist only inside std; user code has no stable way to write one.

The other direction is a promise. Send and Sync are unsafe traits, so a hand-written impl is an unsafe impl: a human asserting what the compiler could not derive. Std's collections hold raw pointers yet are Send when their contents are, so each rests on such a promise; so does Arc, whose conditions say what it guarantees: T: Send + Sync. The compiler believes whoever writes one — even when it is false:

use std::rc::Rc;

fn is_send<T: Send>() {}

struct Session { id: u64, user: Rc<String> }
unsafe impl Send for Session {}       // a promise: the compiler checks nothing behind it

fn main() {
    is_send::<Session>();             // accepted, and false: every clone still shares one count
}

Who audits such promises is Lesson 19's subject. Here it is enough that the answer for any type is a proof over its structure whose leaves are primitives or promises. Building that proof is a trait solver's job — so let us run one.

4 · Run the solver

The widget runs the series' trait solver, extended with §6's closure rule. Each question is a real program, printed at the top of the canvas. The solver builds the proof tree, predicts rustc's error, and computes a repair — one local type change at a time, re-checked until the program is accepted or no type change can help. Against rustc 1.98.1, on every example in every question (60 programs) and 500 random programs with random structs, error codes and culprits agreed every time, and each of the 203 repairs it completed in that run compiled.

Can it cross a thread boundary?
The slider walks through the lesson's examples; the type, the question and the structs are editable. ✓/✗ is the solver's verdict on each goal; the red line under a culprit says why it opts out. "apply the fix" writes the computed repair into the inputs.
verdict
—
T: Send
—
T: Sync
—
repair
—
Show the core JS
// sendsync.js, check(): what a closure handed to a thread must prove, capture by capture
prog.caps.forEach(function (c) {
  var a; try { a = parseType(c.type); } catch (e) { errors.push(c.type + ': ' + e.message); return; }
  var eff = c.mode;
  if (prog.kind === 'spawn' || prog.kind === 'scope') {
    if (prog.move) eff = 'val';                                   // move: every capture by value
    else if (eff === 'val' && TS.solve('Copy', a, env).ok) eff = 'ref';   // a Copy value is only borrowed
  }
  caps.push({ name: c.name, ast: a, eff: eff });
});
// … (a Send or Sync probe solves T itself)
var kids = caps.map(function (c) {
  var goalAst = c.eff === 'val' ? c.ast : { k: 'ref', mut: c.eff === 'mut', to: c.ast };
  return TS.solve('Send', goalAst, env).tree;
});
// … any failing leaf: E0277, and stop. Only when there is none:
if (prog.kind === 'spawn') {                          // type-checking passed; now 'static (borrowck)
  caps.forEach(function (c) {
    if (c.eff !== 'val') res.borrowed.push(c.name);   // E0373: the closure borrows a local
    if (borrows(c.ast)) res.nonStatic.push(c.name);   // E0521: the value itself borrows
  });
  if (res.borrowed.length) res.codes.push('E0373');
  if (res.nonStatic.length) res.codes.push('E0521');
}

What to try. Example 1, Rc<String> moved into thread::spawn, fails at the leaf Rc<String>: Send; one change fixes it, Rc → Arc. Example 3 asks only whether Arc<Cell<i32>> may move, yet the error is `Cell<i32>` cannot be shared — an Arc is Send only when its contents are Send and Sync — and the repair is Cell<i32> → AtomicI32. Example 4, &RefCell<i32>: Send, descends to RefCell<i32>: Sync: the definition of Sync as a proof step. Example 7, a MutexGuard, is Sync but not Send, and no type change helps. Examples 8 and 9 put the culprit inside a struct: the fix edits the field, and with unsafe impl Send the program is accepted "on the author's word". Example 10 lends a Cell<i32> to a scoped thread as &mut and is accepted; ask instead whether it may be shared and it is rejected. Last, ask example 2 the no-move question (thread::spawn(|| … &value …)): every type checks, the verdict is E0373, and the fix is move.

The ✗ leaves keep repeating a few names; read them as a table.

5 · The standard types, read off the rule

Each row follows from §3; the solver computed them, and they agree with a compiler-checked matrix:

TypeSendSyncWhy
i32, String; Vec<T>, Box<T>✓; if T is✓; if T isthey own their bytes; nothing is shared behind them
Rc<T>✗✗every clone updates one non-atomic count
Arc<T>if T: Send + Syncan atomic count, but every clone hands out &T, and the last drops T on its own thread
Cell<T>, RefCell<T>if T is✗mutation through &, unsynchronized
Mutex<T>if T: Sendif T: Sendthe lock admits one thread at a time
RwLock<T>if T: Sendif T: Send + Syncmany readers hold &T at once
MutexGuard<T>✗if T: SyncPOSIX threads require the locking thread to unlock
Sender<T> / Receiver<T>if T: Sendif T: Send / ✗a channel has one consumer
*const T, *mut T✗✗no promise at all
&T / &mut Tif T: Sync / if T: Sendif T: Syncthe definition of Sync; an exclusive reference is the only name (§6)
dyn Traitonly if the type says so: dyn Trait + Send, or Send as a supertraiterasure leaves no parts to derive from

Arc adds one thing: an atomic count. In machine code (rustc 1.98.1 -C opt-level=3, one AArch64 machine, Apple silicon, 2026-09):

use std::rc::Rc;
use std::sync::Arc;

#[inline(never)]                    // keep each as its own function, so we can read it
pub fn clone_rc(r: &Rc<u64>) -> Rc<u64> { Rc::clone(r) }

#[inline(never)]
pub fn clone_arc(a: &Arc<u64>) -> Arc<u64> { Arc::clone(a) }
clone_rc:                       clone_arc:
  ldr   x0, [x0]                  ldr   x0, [x0]
  ldr   x8, [x0]     ; load       mov   w8, #1
  adds  x8, x8, #1   ; add        ldadd x8, x8, [x0]   ; one indivisible add
  str   x8, [x0]     ; store      tbnz  x8, #63, trap  ; overflow guard
  b.hs  trap         ; overflow   ret
  ret

Another thread can run between clone_rc's load and its store; both then store the same count and an increment vanishes — §1's lost update in three instructions. ldadd reads, adds and writes as one indivisible step (C++ · 18). An indivisible update on every clone and drop is the price of making shared ownership Send, which is why std keeps both types and advises Rc when nothing crosses a thread. What Arc does not add is safety for the data: every clone gives its thread a &T, so T must be Sync, and the last clone drops T on its own thread, so T must be Send:

use std::cell::Cell;
use std::sync::Arc;

fn is_send<T: Send>() {}

fn main() {
    is_send::<Arc<Cell<i32>>>();   // every clone of the Arc reaches the same Cell
}
error[E0277]: `Cell<i32>` cannot be shared between threads safely
  = help: the trait `Sync` is not implemented for `Cell<i32>`
  = note: required for `Arc<Cell<i32>>` to implement `Send`

Mutex adds the other thing: exclusion. Mutex<T> is Sync when T is merely Send: the lock hands the inside to one thread at a time, so access to T moves between threads and is never shared — exactly Send's question. That condition cannot be dropped: if Mutex<Rc<i32>> were Sync, two threads could each lock it in turn, clone the Rc, and keep the clones after unlocking — one non-atomic count, two threads, no lock. So Arc<Mutex<T>> is two layers with two jobs: Arc supplies owners on several threads and asks for Send + Sync contents; Mutex turns a Send value into a Sync one. §1's counter, repaired:

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let hits = Arc::new(Mutex::new(0));      // an atomic count, and a lock
    let h2 = Arc::clone(&hits);
    let worker = thread::spawn(move || *h2.lock().unwrap() += 1);
    *hits.lock().unwrap() += 1;
    worker.join().unwrap();
    println!("hits = {}", *hits.lock().unwrap());
}
hits = 2

The table's odd pair follows the same way. A Receiver may move to one thread but not be shared: a channel promises one consumer. A MutexGuard may be shared — only &guard crosses, and dropping a reference does nothing — but not sent: its drop unlocks, and POSIX threads require the locking thread to unlock. And dyn Trait, as Lesson 13 promised: erasure forgot the concrete type, so no parts are left to derive from, and the compiler can only believe the object type — Box<dyn Fn()> is Send only when written Box<dyn Fn() + Send>.

The types carry the answers. What remains is where the compiler asks — and no function in the language is special there. It is a bound.

6 · The enforcement point: a bound on spawn

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T,
    F: Send + 'static,
    T: Send + 'static,

Read it as a contract. F: Send: the closure moves to the new thread. T: Send: its result moves back to whoever calls join. 'static: the thread may outlive the caller, so the closure may hold no borrow of the caller's frame — E0373, and Lesson 14's move. A closure is a struct of captures, so it is Send when its captures are, each asked by how it is held: by value, T: Send; by shared reference, &T: Send, that is T: Sync; by exclusive reference, &mut T: Send, that is T: Send — the widget's closure node.

Nothing in the compiler knows what a thread is. Give any function the same bounds and it gets the same protection, and the same message:

use std::rc::Rc;

fn run_later<F: FnOnce() + Send + 'static>(job: F) { job() }   // no thread anywhere

fn main() {
    let user = Rc::new(String::from("ada"));
    run_later(move || println!("{user}"));
}
error[E0277]: `Rc<String>` cannot be sent between threads safely
note: required because it's used within this closure
note: required by a bound in `run_later`

The words "between threads" belong to the trait Send, not to spawn: they appear where no thread exists. Here the language meets the library: the compiler derives Send and Sync and checks 'static; std decides where to demand them.

The &mut rule looks too generous: why may an exclusive reference cross when T is only Send? Because while it lives it is the only name for T (Lesson 05). Handing it over moves the whole right to touch T for the loan's duration; nothing is shared, so Sync is not the question. A Cell can therefore be lent to a scoped thread — thread::scope, stable since Rust 1.63, joins its threads before returning, so they may borrow the caller's locals (Lesson 17 builds on it):

use std::cell::Cell;
use std::thread;

fn main() {
    let mut hits = Cell::new(0);
    thread::scope(|s| {
        let only = &mut hits;                    // the one name for hits while it lives
        s.spawn(move || only.set(only.get() + 1));
    });
    println!("{}", hits.get());
}
1

Shared, the same Cell is refused, and the last note is §2's definition in the compiler's words:

use std::cell::Cell;
use std::thread;

fn main() {
    let hits = Cell::new(0);
    thread::scope(|s| {
        s.spawn(|| hits.set(hits.get() + 1));    // captures &hits
        s.spawn(|| hits.set(hits.get() + 1));    // ...and so does this one
    });
}
error[E0277]: `Cell<i32>` cannot be shared between threads safely
  = help: the trait `Sync` is not implemented for `Cell<i32>`
  = note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` or `std::sync::atomic::AtomicI32` instead
  = note: required for `&Cell<i32>` to implement `Send`
Trap · a closure captures places, not variables
Since edition 2021 a closure captures the places its body uses (Lesson 14), so a promise made for a whole struct does not cover a field captured alone:
use std::thread;

struct Buffer(*mut u8);
unsafe impl Send for Buffer {}            // the author vouches for the whole struct

fn main() {
    let b = Buffer(std::ptr::null_mut());
    thread::spawn(move || { let _p = b.0; }).join().unwrap();   // captures only b.0
}
The error names *mut u8: the closure holds the raw pointer, not a Buffer. Call a method on b instead and the closure captures b, promise included.
Reading the error
Moving a whole Session into a thread (the method call captures s, not a field) produces the complete chain:
use std::rc::Rc;
use std::thread;

struct Session { id: u64, user: Rc<String> }

impl Session {
    fn describe(&self) -> String { format!("#{} {}", self.id, self.user) }
}

fn main() {
    let s = Session { id: 7, user: Rc::new(String::from("ada")) };
    let worker = thread::spawn(move || println!("{}", s.describe()));
    worker.join().unwrap();
}
error[E0277]: `Rc<String>` cannot be sent between threads safely
note: required because it appears within the type `Session`
note: required because it's used within this closure
note: required by a bound in `spawn`
Read bottom-up, it is the solver's failing path top-down: the bound that demanded Send, the closure that captured the value, the struct holding the field, the leaf that says no. It tells you where the proof broke, not what you meant: Arc if the threads should share the name, Arc<Mutex<…>> if they must also change it, a plain owned field if only the worker needs it. rustc's note in the Cell case — use RwLock or an atomic — is a local edit, not a design.

So, given honest Send and Sync answers, no two threads reach a place while one writes unsynchronized. Both "honest" and "data race" are narrower than they sound.

7 · What the two traits do not promise

The Nomicon defines a data race precisely: two or more threads access one memory location concurrently, at least one writes, at least one access is unsynchronized. It is undefined behavior, and safe Rust excludes it — mostly through ownership (an exclusive reference cannot alias), and through Send and Sync for whatever mutates through a shared handle. A race condition is different: an answer that depends on scheduling — check a balance, release the lock, withdraw. Every access is synchronized and the answer is still wrong. The Nomicon calls preventing those impossible for a language that does not control the scheduler, and the Reference lists deadlocks and leaks as not unsafe. Lesson 17 gives the honest list.

What this does not cover
No freedom from deadlock, race conditions or leaks. And every guarantee is conditional on the promises underneath: std's unsafe impls for Arc, Mutex and channels, and any unsafe impl Send you write. §3's Session compiled with a false one; two threads sharing its Rc would race on the count exactly as in §1.

Common mistakes / failure modes

"Send and Sync both mean thread-safe"
They answer different questions: may the value move (Send), may &T be shared (Sync). Cell<i32> is Send but not Sync; MutexGuard the reverse (§5). In a 2024 study of the Rust Book's quizzes, 25% of readers answered a question on this distinction correctly, 49% after the text was revised.
"Arc makes anything shareable"
Arc makes ownership thread-safe, not the data: Arc<T> is Send only when T: Send + Sync, so Arc<Cell<i32>> is neither (§5).
"Sync means it has a lock"
Sync means &T may be sent. i32 and String are Sync with no lock, because a shared reference to them only reads. A lock is one way to make a type Sync; an atomic is another.
"Mutex<T> needs T: Sync"
Only T: Send: the lock gives one thread at a time access to T, so access moves and is never shared. Arc<Mutex<Cell<i32>>> is Send and Sync (§5).
"Fearless concurrency means no deadlocks"
It means no data races. Deadlocks, race conditions and leaks all compile (§7; Lesson 17).
"If it compiles, the threads are correct"
It compiles when every access is synchronized or unshared. Whether the logic survives every interleaving — check-then-act, lock order, a message never sent — is still yours to get right.

Checkpoint exercise

Try it
In the widget, type Rc<RefCell<Vec<String>>> and choose thread::spawn(move || …). Before looking, write down the error, its culprit and the fix. Then type RefCell<Vec<String>> and predict three verdicts: spawn with move, a scoped thread that shares &value, and a scoped thread that gets &mut value. (Answer: E0277, `Rc<RefCell<Vec<String>>>` cannot be sent; two changes — Rc → Arc, then RefCell → Mutex — give Arc<Mutex<Vec<String>>>. The bare RefCell is accepted when moved, rejected with E0277 `RefCell<Vec<String>>` cannot be shared when shared, and accepted when lent as &mut: Send, not Sync.) Then write the moved version as a scratch program — build the value in main, move it into thread::spawn, push a string in the thread — and check that rustc agrees.

Where this points next

The law now reaches across threads at no run-time cost beyond what we ask for by name: every type carries two derived answers, and thread::spawn is an ordinary function whose bounds demand them. But we have only watched single values cross — an Arc<Mutex<T>> here, a Cell lent to a scoped thread there. A real concurrent program must decide, for every piece of data, whether threads share it under a lock, hand it over as a message, or borrow it from a parent that waits for them — and must still face what the traits never promised: deadlock, race conditions, leaks. With these building blocks, how do we structure a real concurrent program, and what does the promise still leave to us?

Takeaway
A data race is the law — shared XOR exclusive — broken by two threads at once, so the check belongs on the values that cross. Every type carries two answers: Send, may a value move to another thread, and Sync, may &T be used from several at once — defined as &T: Send. Both are auto traits derived from a type's parts; every "no" starts at a part that refuses by itself (raw pointers, UnsafeCell, Rc, a few std types), every hand-written "yes" is an unsafe impl. Arc adds an atomic count and needs T: Send + Sync; Mutex adds exclusion and needs only T: Send; &mut T crosses when T: Send, being the only name. thread::spawn's F: Send + 'static is where the compiler asks, capture by capture. It proves the absence of data races — not of race conditions, deadlocks or leaks.

Interview prompts

Companion reads: C++ · 17 Threads and races (the data race defined; a mutex that protects nothing by type), C++ · 18 Atomics and the memory model (what Arc's count relies on), C++ · 19 Undefined behavior (why a race is UB), Go · 08 Goroutines and Go · 11 When to share memory (the same hazard, caught by a race detector while running).