Why this matters
The previous lessons claimed that Rust prevents data races at compile time. This lesson shows the mechanism: two marker traits, Send and Sync, that describe which types may cross thread boundaries. They explain the errors you get when you try to share an Rc or a RefCell between threads. Then we look at scoped threads, which let threads borrow local data safely, and atomics, the lightweight alternative to a mutex for simple counters and flags.
Send and Sync
- A type is
Sendif a value of it can be moved to another thread safely. - A type is
Syncif it can be shared between threads by reference:TisSyncexactly when&TisSend.
They are auto traits: the compiler implements them automatically for any type whose fields are all Send (or Sync). You almost never implement them yourself. Nearly everything is both: integers, String, Vec<T> (if T is), Box<T>, Arc<T> (if T is Send + Sync), Mutex<T> (if T is Send).
The notable exceptions are types with unsynchronized shared internals:
| type | Send | Sync | why |
|---|---|---|---|
Rc<T> | no | no | its reference count is updated non-atomically |
RefCell<T> | yes (if T is) | no | its borrow counter is not thread-safe |
Cell<T> | yes (if T is) | no | same reason |
| raw pointers | no | no | the compiler cannot know what they point to |
thread::spawn requires its closure (and everything it captures) to be Send + 'static. That single bound is what turns thread-safety mistakes into compile errors:
use std::rc::Rc;
use std::thread;
fn main() {
let shared = Rc::new(5);
let clone = Rc::clone(&shared);
let h = thread::spawn(move || {
println!("{clone}");
});
h.join().unwrap();
}
error[E0277]: `Rc<i32>` cannot be sent between threads safely
--> main.rs:7:27
|
7 | let h = thread::spawn(move || {
| ------------- ^------
| | |
| _____________|_____________within this `{closure@main.rs:7:27: 7:34}`
| | |
| | required by a bound introduced by this call
8 | | println!("{clone}");
9 | | });
| |_____^ `Rc<i32>` cannot be sent between threads safely
|
= help: within `{closure@main.rs:7:27: 7:34}`, the trait `Send` is not implemented for `Rc<i32>`
note: required because it's used within this closure
--> main.rs:7:27
|
7 | let h = thread::spawn(move || {
| ^^^^^^^
note: required by a bound in `spawn`
--> /usr/src/rustc-1.85.1/library/std/src/thread/mod.rs:728:1
The message names the culprit (Rc<i32> is not Send) and where it is captured. Switch to Arc and it compiles. Similarly, Arc<RefCell<T>> fails because RefCell is not Sync; use Arc<Mutex<T>>.
So "fearless concurrency" is not magic: ownership prevents two threads from having &mut to the same data, and Send/Sync prevent sharing types whose internals are not thread-safe. What remains possible are logic errors such as deadlocks and race conditions in order (who finishes first), but not memory corruption from data races.
Scoped threads: borrowing local data
thread::spawn requires 'static because the new thread might outlive the current function. Often you know it will not: you spawn some threads, wait for them, and continue. thread::scope expresses exactly that:
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
let mut results = [0i32; 2];
let (left, right) = data.split_at(data.len() / 2);
let (r0, r1) = results.split_at_mut(1);
thread::scope(|s| {
s.spawn(|| r0[0] = left.iter().sum());
s.spawn(|| r1[0] = right.iter().sum());
});
println!("data still owned by main: {:?}", data);
println!("partial sums: {:?}", results);
}
data still owned by main: [1, 2, 3, 4, 5, 6, 7, 8]
partial sums: [10, 26]
thread::scope(|s| { ... })creates a scope;s.spawn(closure)starts a thread within it.- All threads spawned in the scope are automatically joined before
scopereturns. Because of that guarantee, the closures may borrow local variables (left,right,r0,r1) withoutmove,Arcor cloning. - The borrow rules still apply across threads: two threads can read the same data, and each can get
&mutto a different part (thanks tosplit_at_mut), but two threads cannot both have&mutto the same element.
Scoped threads are the simplest way to parallelize work over borrowed data. (Libraries like rayon build on the same idea with par_iter(); not available in the sandbox.)
Atomics
For a single counter or flag, a Mutex is more machinery than needed. Atomic types (AtomicUsize, AtomicU64, AtomicBool, ...) perform read-modify-write operations as single indivisible CPU instructions:
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
fn main() {
let hits = AtomicUsize::new(0);
thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| {
for _ in 0..10_000 {
hits.fetch_add(1, Ordering::Relaxed);
}
});
}
});
println!("hits: {}", hits.load(Ordering::Relaxed));
}
hits: 40000
fetch_add cannot lose updates, and atomics are Sync, so they can be shared by reference (here from scoped threads; with spawn you would wrap them in an Arc). The Ordering argument controls how the operation is ordered relative to other memory operations. Relaxed is fine for an independent counter; when an atomic is used to publish other data ("the result is ready"), use Release for the write and Acquire for the read, or SeqCst if unsure. Memory ordering is a deep subject; for anything beyond counters and flags, prefer a Mutex or channels.
Choosing a concurrency tool
| need | tool |
|---|---|
| independent work, collect results | thread::spawn + join, or scoped threads |
| work over borrowed local data | thread::scope |
| pass data from producers to a consumer | channels (mpsc) |
| shared mutable state | Arc<Mutex<T>> (or RwLock) |
| a shared counter or flag | atomics |
Common mistakes
RcorRefCellin threads: E0277 "cannot be sent/shared between threads safely". UseArcandMutex.- Borrowing locals in
thread::spawn: E0373. Usemovewith owned data, orthread::scopeto borrow. - Returning values from scoped threads: join the
ScopedJoinHandleinside the scope (s.spawn(...).join()), or write into disjoint&mutslots as above. Relaxedatomics used to signal that other data is ready: may observe the flag before the data. Use Acquire/Release or a Mutex.
Recap
Send: safe to move to another thread.Sync: safe to share by reference. Both are automatic for most types.Rc,RefCellandCellare not thread-safe; the compiler stops them atthread::spawn.thread::spawnneedsSend + 'static;thread::scopejoins its threads automatically, so they may borrow locals.- Atomics (
fetch_add,load,store) handle counters and flags without locks. - Pick the tool by the shape of the problem: join results, channels, mutexes, atomics.
Going further: read the Rust Book chapter 16.4 and the std::sync::atomic module docs. Mara Bos's book "Rust Atomics and Locks" (free online) is the deep dive into everything in this module.