Lesson 1 of 5
Threads, Send and Sync
"Haste in every business brings failures." — Herodotus, The Histories (Book VII)
"Fearless concurrency" is the phrase Rust is sold with, and like "zero-cost abstraction" it is a precise claim wearing a slogan's clothes. Part I took the second apart with measurements. This Part does the same to the first, and it starts with the two traits that do all the work.
The claim is not that concurrent Rust is easy, or that your design will be right, or that you cannot deadlock. It is narrower and much stronger than that: a data race — two threads accessing the same memory, at least one writing, with no synchronisation — is a compile error. Not a lint, not a runtime check, not a tool you remember to run. The program does not exist.
That guarantee rests on two marker traits introduced in Part III: Send and Sync. They have no methods and no runtime representation. Send means a value may be moved to another thread. Sync means a reference to it may be shared between threads — equivalently, T is Sync when &T is Send. Almost every type gets them automatically, a handful deliberately do not, and that handful is where all the interesting errors come from.
What makes this unusual is where the knowledge lives. In C++ or Java, "this class is not thread-safe" is a sentence in the documentation, and the compiler has no opinion about whether you obeyed it. In Rust it is a property of the type that the compiler checks at every thread boundary, which means the mistake is caught in the place where it was made rather than in production under load at a scale you cannot reproduce.
This chapter covers threads and what join returns, the exact meaning of the two traits, why Rc is rejected and what would go wrong if it were not, the Arc/Mutex pair that replaces Rc/RefCell, and scoped threads — which let you borrow local data across threads and are the first thing most people reach for once they know about them.
Rc and Arc do the same job and differ only in how they update a number. Why is one a compile error across threads and the other not?
Threads, and what join gives back
Start with the mechanics, which are smaller than you might expect.
use std::thread;
fn main() {
// spawn takes a closure and returns a handle. `move` is almost always
// needed: the thread may outlive the scope that created it.
let data = vec![3, 1, 4, 1, 5, 9, 2, 6];
let handles: Vec<_> = (0..4).map(|chunk| {
let slice: Vec<i32> = data[chunk * 2..chunk * 2 + 2].to_vec();
thread::spawn(move || slice.iter().sum::<i32>())
}).collect();
// join blocks until the thread finishes and returns what it produced.
let partials: Vec<i32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
println!("partial sums {partials:?}");
println!("total {}", partials.iter().sum::<i32>());
println!("direct {}", data.iter().sum::<i32>());
// A thread can be named, which is what appears in a panic report.
let named = thread::Builder::new()
.name("worker-1".into())
.spawn(|| thread::current().name().unwrap_or("?").to_string())
.unwrap();
println!("thread name {}", named.join().unwrap());
}partial sums [4, 5, 14, 8]
total 31
direct 31
thread name worker-1
thread::spawn takes a closure and returns a JoinHandle<T>, where T is whatever the closure returns. join() blocks until that thread finishes and gives you a Result<T> — Ok with the value, or Err with the panic payload, as Part IV established.
The move is doing real work and is not decoration. A spawned thread may outlive the function that created it, so its closure cannot hold references to that function's locals — the compiler enforces this by requiring the closure to be 'static. move transfers ownership of the captures into the closure, which satisfies that. The next section's scoped threads are the exception, and they are the exception precisely because the scope proves the threads finish first.
Note that the results came back in order even though the threads ran in whatever order the operating system chose. That is because order came from the handles vector, not from the threads. Collecting results in a deterministic order while the work happens in a nondeterministic one is the normal shape of parallel code, and it is why this block's output is reproducible.
thread::Builder is worth knowing for one reason: a named thread appears in panic reports and in debuggers. In a server with twenty worker threads, a panic attributed to worker-1 rather than to <unnamed> is the difference between a useful log line and a mystery.
Think about handing out sections of a document to several proofreaders. Send is the question of whether a thing can be given to somebody else at all — a printed page can, a half-eaten sandwich on your desk perhaps should not. Sync is whether several people can look at the same copy simultaneously: a page pinned to a noticeboard, yes; a page somebody is actively rewriting, no. Most office objects are fine on both counts, which is why the rules only become visible when you meet the exceptions — and the exceptions are exactly the things with a hidden counter on them that two people could update at once.
Send and Sync, stated precisely
The two traits have no methods, so the only way to see them is to ask the compiler whether a bound holds.
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
// These compile only if the bound holds, so they are a compile-time probe.
fn assert_send<T: Send>() -> &'static str { "Send" }
fn assert_sync<T: Sync>() -> &'static str { "Sync" }
struct Plain { n: u32 } // auto Send + Sync
struct HasRc { inner: Rc<u32> } // neither, because Rc is neither
fn main() {
// Auto traits: a struct is Send if all its fields are, and likewise Sync.
println!("u32 {} {}", assert_send::<u32>(), assert_sync::<u32>());
println!("String {} {}", assert_send::<String>(), assert_sync::<String>());
println!("Vec<u8> {} {}", assert_send::<Vec<u8>>(), assert_sync::<Vec<u8>>());
println!("Plain {} {}", assert_send::<Plain>(), assert_sync::<Plain>());
println!("Arc<Mutex<u32>> {} {}", assert_send::<Arc<Mutex<u32>>>(), assert_sync::<Arc<Mutex<u32>>>());
// Mutex<T> is Sync even though T may not be: the lock provides the
// synchronisation, which is exactly what Sync is a claim about.
println!("Mutex<u32> {} {}", assert_send::<Mutex<u32>>(), assert_sync::<Mutex<u32>>());
// These lines do NOT compile, and the comments say which bound fails:
// assert_send::<Rc<u32>>(); // Rc is not Send: non-atomic refcount
// assert_sync::<RefCell<u32>>(); // RefCell is not Sync: non-atomic flag
// assert_send::<HasRc>(); // a struct is only Send if its fields are
let _ = (Plain { n: 1 }.n, HasRc { inner: Rc::new(2) }.inner, RefCell::new(0u32));
println!("the three commented lines above are compile errors by design");
}u32 Send Sync
String Send Sync
Vec<u8> Send Sync
Plain Send Sync
Arc<Mutex<u32>> Send Sync
Mutex<u32> Send Sync
the three commented lines above are compile errors by design
The definitions, exactly:
Send means ownership of a value may be transferred to another thread. Almost everything is. The exceptions are types with non-atomic shared state — Rc, and raw pointers.
Sync means &T may be shared between threads, which is the same as saying &T is Send. A type is Sync when concurrent reads through references are safe, so anything with interior mutability that is not synchronised — Cell, RefCell — is not.
Both are auto traits: the compiler implements them for your types automatically whenever every field qualifies. Plain is Send and Sync because u32 is; HasRc is neither because Rc is neither. You never write impl Send for MyType, and you can only opt out, which is almost never what you want.
The most instructive line is Mutex<u32>, which is Sync even though it contains data being mutated. That is the whole point of a mutex: it provides the synchronisation that makes shared access safe, so wrapping a value in one lifts it into Sync even when the inner type would not be. Mutex<T> is Sync whenever T is Send — the lock handles the sharing, and all it needs from T is that the value can move between threads at all.
The commented lines are the three failures worth recognising on sight. A struct is only as thread-safe as its least thread-safe field, which is why a single Rc buried three levels down can make an otherwise ordinary type unusable across threads — and why the error, when it comes, points at the outer type and makes you go looking.
Thread safety as a documented property rather than a checked one has a long and costly history. Java shipped Vector and Hashtable as synchronised, then ArrayList and HashMap as unsynchronised with a warning in the Javadoc, and the resulting misuse was common enough that java.util.concurrent was built partly to give people obviously-correct options. C++ documents which standard-library operations are safe to call concurrently and leaves enforcement entirely to the programmer. Rust's contribution was not inventing the distinction — it was noticing that "safe to send" and "safe to share" are properties the type system can derive structurally and check at every boundary, so the documentation becomes a bound and the warning becomes an error.
Why Rc cannot cross
Here is the answer to the chapter's big question, and the error that enforces it.
use std::rc::Rc;
use std::thread;
fn main() {
let shared = Rc::new(vec![1, 2, 3]);
let copy = Rc::clone(&shared);
// Rc's refcount is a plain integer incremented with ordinary
// instructions. Two threads cloning at once could lose an increment,
// the count would reach zero early, and the value would be freed
// while still in use. So Rc is not Send, and this cannot compile.
let h = thread::spawn(move || copy.len());
println!("{}", h.join().unwrap());
println!("{}", shared.len());
}error[E0277]: `Rc<Vec<i32>>` cannot be sent between threads safely
--> src/main.rs:13:27
|
13 | let h = thread::spawn(move || copy.len());
| ------------- -------^^^^^^^^^^^
| | |
| | `Rc<Vec<i32>>` cannot be sent between threads safely
| | within this `{closure@...}`
| | required by a bound introduced by this call
|
= help: within `{closure@...}`, the trait `Send` is not implemented for `Rc<Vec<i32>>`
note: required because it's used within this closure
Rc and Arc do the same job. The only difference is that Rc increments its counter with an ordinary instruction and Arc uses an atomic one. That difference is the whole of why one is a compile error and the other is not.
Trace the failure concretely. An increment is read, add one, write back. Two threads cloning the same Rc simultaneously can both read 1, both compute 2, and both write 2 — so two clones exist but the count says one. When both are dropped, the count reaches zero after the first drop, the value is freed, and the second clone is a pointer into released memory. That is a use-after-free produced by a program containing no unsafe and no obvious mistake.
Atomics make the read-modify-write indivisible, which costs real time under contention — enough that the standard library ships both types rather than making everything atomic. Rc exists because single-threaded code should not pay for synchronisation it does not need, and the type system is what makes shipping the cheap version safe.
The error message is worth reading for its shape. It does not say "do not do that"; it says the trait Send is not implemented for Rc<Vec<i32>>, and then explains that the requirement came from the closure being passed to spawn. The mechanism is entirely ordinary trait resolution — spawn requires F: Send, closures are Send when their captures are, Rc is not, so the bound fails. There is no concurrency-specific machinery in the compiler at all.
The thing I got wrong early was assuming these errors meant I had made a threading mistake. Usually I had made an ownership mistake several steps earlier, and the thread boundary was simply the first place the compiler could notice. A struct would acquire an Rc field for a perfectly good single-threaded reason, and six months later somebody would try to hand that struct to a worker and get a wall of text about a trait nobody in the team had thought about. The fix is almost never at the spawn; it is deciding whether that field should have been an Arc all along, which is a design question the error has usefully forced into the open.
Arc and Mutex
The thread-safe pair replaces Rc and RefCell, for the same reasons and at a higher price.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Arc gives shared OWNERSHIP across threads; Mutex gives exclusive
// ACCESS at any instant. You need both, and for the same reasons
// Rc and RefCell were needed together in a single thread.
let counter = Arc::new(Mutex::new(0u32));
let handles: Vec<_> = (0..8).map(|_| {
let c = Arc::clone(&counter);
thread::spawn(move || {
for _ in 0..1000 {
*c.lock().unwrap() += 1;
}
})
}).collect();
for h in handles { h.join().unwrap(); }
// Deterministic despite the interleaving: every increment is exclusive.
println!("final count {}", *counter.lock().unwrap());
println!("owners left {}", Arc::strong_count(&counter));
// RwLock when reads dominate: many readers or one writer.
use std::sync::RwLock;
let table = Arc::new(RwLock::new(vec![1, 2, 3]));
let readers: Vec<_> = (0..4).map(|_| {
let t = Arc::clone(&table);
thread::spawn(move || t.read().unwrap().iter().sum::<i32>())
}).collect();
let sums: Vec<i32> = readers.into_iter().map(|h| h.join().unwrap()).collect();
println!("reader sums {sums:?}");
}final count 8000
owners left 1
reader sums [6, 6, 6, 6]
Eight threads, a thousand increments each, and the answer is exactly eight thousand every time. In C or Java with an unsynchronised counter this is the textbook data race and the result is reliably less than eight thousand — increments vanish exactly as described in the previous section. Here the mutex makes each increment exclusive, and the count is not merely usually right but necessarily right.
Arc::strong_count reading 1 at the end confirms the other half: every spawned clone was dropped when its thread finished, leaving only the original. The allocation is freed when that goes out of scope, with no collector involved.
The division of labour is worth stating explicitly because people conflate the two types. Arc solves ownership — several threads need to keep the value alive and none of them knows which will finish last. Mutex solves access — only one may touch it at a time. Neither does the other's job, which is why Arc<Mutex<T>> is the pair, exactly as Rc<RefCell<T>> was in Part II.
RwLock is the variant for read-heavy data: many readers concurrently, or one writer exclusively. It is not free — the bookkeeping is more expensive than a mutex's, so for short critical sections a Mutex often wins outright — and it is worth measuring rather than assuming. The rule of thumb is that RwLock pays off when reads vastly outnumber writes and the critical sections are long enough for the parallelism to matter.
One caution carried over from Part IV: lock() returns a Result because the mutex may be poisoned by a panicking thread. .lock().unwrap() is the common shorthand and it turns one panic into a cascade. Worth a deliberate decision rather than a reflex.
What a thread costs
Before reaching for more threads, it is worth knowing what one is. A thread is an operating-system object, not a language-level abstraction, and it has a price that catches people who have come from languages with lightweight tasks.
Each thread gets its own stack, reserved at creation. Rust's default for a spawned thread is two megabytes — the main thread's is set by the OS and is often larger. That is address space rather than committed memory on most platforms, so a thousand idle threads do not consume two gigabytes of RAM, but it is a real limit: thread-per-connection designs run out of address space and scheduler patience long before they run out of CPU. thread::Builder::new().stack_size(n) adjusts it when you know your workload, which matters most for deeply recursive work, since Part I's stack-overflow abort is per thread.
Creating one is not free either. A spawn is a system call, and the cost is measured in tens of microseconds rather than nanoseconds. That is irrelevant if you spawn four threads to process a large batch, and ruinous if you spawn one per item in a loop over ten thousand items — the coordination will cost more than the work.
Those two facts together give the rule that governs almost all real use: spawn threads proportional to your cores, not to your work. std::thread::available_parallelism() reports how many the machine can genuinely run at once, and the standard shape is to divide the work into roughly that many chunks. The example earlier used four threads for eight numbers, which is the wrong ratio for a real workload and the right one for a block that has to fit on a page.
This is also where the ecosystem takes over from the standard library. A thread pool amortises the spawn cost by keeping workers alive and feeding them tasks — rayon does this transparently behind parallel iterators, so par_iter() replaces iter() and the chunking, spawning and joining are handled. And for work that is waiting rather than computing — thousands of connections mostly idle — threads are the wrong tool entirely, which is what async exists for and what the fourth chapter of this Part is about.
The distinction worth carrying forward is between work that is CPU-bound, where threads matching your core count is exactly right, and work that is IO-bound, where you want many more concurrent operations than you have cores and a thread each is unaffordable.
Borrowing across threads
Everything above required owned data, because a spawned thread must be 'static. Scoped threads remove that requirement, and they are underused.
use std::thread;
fn main() {
let mut readings = vec![10, 20, 30, 40, 50, 60];
let label = String::from("batch-7");
// Scoped threads may BORROW locals, because the scope guarantees they
// finish before it returns. No Arc, no clone, no 'static requirement.
let (first, second) = thread::scope(|s| {
let a = s.spawn(|| readings[..3].iter().sum::<i32>());
let b = s.spawn(|| readings[3..].iter().sum::<i32>());
println!("inside scope, label is still ours: {label}");
(a.join().unwrap(), b.join().unwrap())
});
println!("halves {first} + {second} = {}", first + second);
// The borrow ended with the scope, so the data is mutable again.
readings.push(70);
println!("after scope {} values, label {label}", readings.len());
// A scope can also mutate disjoint parts, as Part II's split_at_mut did.
let mut buf = [0u32; 6];
thread::scope(|s| {
let (left, right) = buf.split_at_mut(3);
s.spawn(|| { for (i, v) in left.iter_mut().enumerate() { *v = i as u32; } });
s.spawn(|| { for (i, v) in right.iter_mut().enumerate() { *v = 100 + i as u32; } });
});
println!("filled {buf:?}");
}inside scope, label is still ours: batch-7
halves 60 + 150 = 210
after scope 7 values, label batch-7
filled [0, 1, 2, 100, 101, 102]
No Arc, no clone, no move. The threads borrowed readings directly, and label stayed usable inside the scope and afterwards.
The reason this is sound is a guarantee thread::scope makes: every thread spawned inside it is joined before the scope returns. That is enforced by the API rather than by convention, so the borrow checker can treat the spawned closures as ordinary borrows with a lifetime bounded by the scope. The 'static requirement on thread::spawn exists only because an unscoped thread might outlive anything; remove that possibility and the requirement goes with it.
The second half is the payoff that surprises people. Two threads mutating the same array at once, accepted by the compiler, because split_at_mut produced two provably disjoint mutable slices — exactly the Part II mechanism, now doing work across threads. This is data parallelism expressed in safe Rust with no synchronisation at all, because there is nothing to synchronise: the halves never touch.
That pattern is the foundation of libraries like rayon, whose parallel iterators are this idea generalised. When somebody says Rust makes parallelism approachable, this is usually what they mean — not that threads are easy, but that "these pieces are disjoint" is a statement a compiler will check instead of a comment you hope is true.
The practical consequence is a different default. In most languages, sharing mutable state between threads is the normal approach and splitting the data is an optimisation somebody argues for. In Rust the costs are visible in the types — Arc<Mutex<T>> is longer to write and obviously doing bookkeeping — so teams drift toward partitioning the work instead, which is both faster and simpler to reason about. The honest caveat is that this is a nudge rather than a law: plenty of real problems genuinely need shared mutable state, and reaching for a mutex when the data actually is shared is correct rather than a failure of imagination. The failure mode to watch for is the opposite one, where somebody wraps an entire application state in one mutex and then discovers every thread is waiting on it.
Common Mistakes
Reading a
Senderror as a threading mistake. It is usually an ownership decision made earlier — anRcfield that should have beenArc— and the thread boundary is just where the compiler could finally notice.Thinking you must implement
SendorSync. They are auto traits derived structurally from your fields. You never write the impl; a type is only as thread-safe as its least thread-safe field.Confusing what the two traits say.
Sendis "may be moved to another thread";Syncis "a reference may be shared between threads", which is the same as&TbeingSend.Using
ArcwhereMutexis needed, or the reverse.Arcsolves ownership andMutexsolves access. Neither does the other's job, which is why the pair exists.Reaching for
Arcwhenthread::scopewould do. Scoped threads borrow locals directly because the scope guarantees they finish first — noArc, no clone, no'static.Assuming
RwLockbeatsMutexfor reads. Its bookkeeping is more expensive, so short critical sections often favour a plain mutex. Measure rather than assume.Writing
.lock().unwrap()reflexively. A poisoned mutex then turns one panicking thread into a cascade across every thread that touches it.Putting the whole application state behind one mutex. It compiles, it is correct, and every thread ends up waiting on the same lock. Partition the data instead where you can.
Leaving threads unnamed.
thread::Builderwith a name turns a panic report from<unnamed>into something you can act on at three in the morning.Expecting
Sendto mean "thread-safe". It means the value may move between threads. Sharing a reference isSync, and a type can be one without the other.
Interview Corner
TODO 1 is the lock-granularity question in miniature, and the second half is the real one — taking the lock once per chunk changes the contention enormously and must not change the answer, so be able to say why it cannot. TODO 2 removes an allocation per chunk and an Arc clone per thread; list everything you deleted, because the list is longer than it first appears. TODO 3 asks you to predict rather than assume, and the honest answer here is probably that RwLock does not help, so work out what would have to be true for it to. TODO 4 is the chapter's central distinction: removing the Arc fails, and naming which of Send or Sync is being violated — and why it is that one rather than the other — is the test of whether the two definitions have landed.
use std::sync::{Arc, Mutex};
use std::thread;
#[derive(Debug)]
struct Stats { seen: u32, total: u64 }
fn main() {
let readings: Vec<u64> = (1..=100).collect();
let stats = Arc::new(Mutex::new(Stats { seen: 0, total: 0 }));
let handles: Vec<_> = readings.chunks(25).map(|chunk| {
let owned: Vec<u64> = chunk.to_vec();
let s = Arc::clone(&stats);
thread::spawn(move || {
for r in owned {
let mut g = s.lock().unwrap();
g.seen += 1;
g.total += r;
}
})
}).collect();
for h in handles { h.join().unwrap(); }
println!("A {:?}", *stats.lock().unwrap());
// TODO 1: this takes the lock once PER READING. Rewrite it to take the
// lock once per chunk. Does the final answer change? Should it?
// TODO 2: `chunk.to_vec()` copies each chunk so the thread can own it.
// Use thread::scope to borrow instead. What else can you delete?
// TODO 3: swap Mutex for RwLock and add four threads that only READ
// the stats. Predict whether that helps here, and say why.
// TODO 4: remove the Arc and try to share `&stats` directly with
// thread::spawn. Predict the error code before compiling, then
// explain which of Send or Sync is the one being violated.
println!("B expected total {}", (1..=100u64).sum::<u64>());
}A Stats { seen: 100, total: 5050 }
B expected total 5050
Summary
- The fearless-concurrency claim is narrow and strong: a data race is a compile error, not a lint or a runtime check, so the program simply does not exist.
Sendmeans a value may move to another thread;Syncmeans&Tmay be shared — equivalently,TisSyncwhen&TisSend.- Both are auto traits derived structurally, so you never implement them and a type is only as thread-safe as its least thread-safe field.
Mutex<T>isSynceven whenTis onlySend, because the lock supplies the synchronisation thatSyncis a claim about.Rcis rejected across threads because its counter is non-atomic — two simultaneous clones can lose an increment, freeing the value while a clone still points at it.- Nothing in the compiler is concurrency-specific here:
spawnrequiresF: Send, closures areSendwhen their captures are, and ordinary trait resolution produces the error. Arcsolves ownership andMutexsolves access, which is why the pair exists and mirrorsRc<RefCell<T>>from Part II exactly.RwLockis not automatically better for reads — its bookkeeping costs more, so short critical sections often favour a plain mutex.- Scoped threads remove the
'staticrequirement by guaranteeing every spawned thread is joined before the scope returns, which eliminates mostArcuse in bounded parallel work. split_at_mutinside a scope lets two threads mutate one array with no synchronisation at all, because the halves are provably disjoint — the foundation of data-parallel libraries likerayon.
In the next chapter, The Data Race You Cannot Write, we take the guarantee apart: what a data race actually is at the hardware level, why the compiler's refusal is a proof rather than a heuristic, what the memory model has to say about it, and exactly which concurrency bugs this does and does not prevent.
Herodotus was writing about an invasion rather than a thread pool, and the observation survives the change of subject: a race is a situation where two things proceed without regard for each other and the outcome depends on which arrives first. What is unusual about Rust's treatment is not that it noticed the danger — every concurrent language has — but where it put the knowledge. "Not thread-safe" moved out of the documentation, where it could only be obeyed by people who read it, and into the type, where it is checked by a machine that reads everything. That relocation is the actual innovation, and it is worth separating from the slogan, because the slogan promises fearlessness and what you get is an argument with a compiler at the exact moment you were about to make a mistake you would not have noticed for six months.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.