Fearless Concurrency

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.

The big question

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.

Everyday analogy

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.

Where this came from

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 engineer's notebook

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.

In the real world

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

  1. Reading a Send error as a threading mistake. It is usually an ownership decision made earlier — an Rc field that should have been Arc — and the thread boundary is just where the compiler could finally notice.

  2. Thinking you must implement Send or Sync. 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.

  3. Confusing what the two traits say. Send is "may be moved to another thread"; Sync is "a reference may be shared between threads", which is the same as &T being Send.

  4. Using Arc where Mutex is needed, or the reverse. Arc solves ownership and Mutex solves access. Neither does the other's job, which is why the pair exists.

  5. Reaching for Arc when thread::scope would do. Scoped threads borrow locals directly because the scope guarantees they finish first — no Arc, no clone, no 'static.

  6. Assuming RwLock beats Mutex for reads. Its bookkeeping is more expensive, so short critical sections often favour a plain mutex. Measure rather than assume.

  7. Writing .lock().unwrap() reflexively. A poisoned mutex then turns one panicking thread into a cascade across every thread that touches it.

  8. 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.

  9. Leaving threads unnamed. thread::Builder with a name turns a panic report from <unnamed> into something you can act on at three in the morning.

  10. Expecting Send to mean "thread-safe". It means the value may move between threads. Sharing a reference is Sync, and a type can be one without the other.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
What do Send and Sync mean, and who implements them?
Model answer — Send means ownership of a value can be transferred to another thread. Sync means a shared reference to it can be used from several threads at once, which is formally the same as saying a reference to T is Send. Neither has any methods — they are marker traits, and the compiler implements them automatically for your types whenever every field qualifies, so you never write the impl yourself. That means a struct is only as thread-safe as its least thread-safe field. Nearly everything is both: integers, String, Vec, and any struct built out of them. The notable exceptions are Rc, which is neither because its reference count is not atomic, and the Cell types, which are not Sync because their interior mutability is unsynchronised. Those exceptions are where all the interesting compiler errors come from.
Intermediate
Rc and Arc do the same job. Why is one rejected across threads?
Model answer — The only difference is how the reference count is updated: Rc uses an ordinary increment, Arc uses an atomic one. An ordinary increment is read, add, write back, so two threads cloning the same Rc simultaneously can both read one, both compute two, and both write two — two clones exist but the count says one. When both are dropped the count hits zero after the first, the value is freed, and the second clone points into released memory. That is a use-after-free from safe-looking code, so Rc is not Send and the compiler rejects it at the thread boundary. Atomics prevent that by making the read-modify-write indivisible, which costs real time under contention — which is exactly why the standard library ships both rather than making everything atomic. Single-threaded code should not pay for synchronisation it does not need, and the type system is what makes offering the cheap version safe. Worth adding that nothing in the compiler is concurrency-specific here: spawn requires its closure to be Send, closures are Send when their captures are, and ordinary trait resolution does the rest.
Advanced
When would you use scoped threads rather than Arc, and what makes them sound?
Model answer — Whenever the parallel work is bounded by the current function, which covers most data-parallel processing — splitting a buffer, processing chunks of a collection, fanning out and collecting results. thread::spawn requires its closure to be 'static because an unscoped thread might outlive anything that created it, which forces you to move owned data in, and that is where the Arc and the clones come from. thread::scope guarantees by construction that every thread spawned inside it is joined before the scope returns, so the borrow checker can treat the closures as ordinary borrows with a lifetime bounded by the scope. That removes the 'static requirement and with it the need for Arc entirely. The part I find most instructive is that scoped threads compose with split_at_mut: two threads can mutate the same array concurrently because the two halves are provably disjoint, which means no synchronisation is needed at all rather than merely being cheap. That is the foundation of libraries like rayon. The limitation is that it is useless for work that must outlive the function — a background worker, a long-lived task — where you are back to owned data and shared ownership, and that is the legitimate home of Arc.
Company lens — how the same topic gets asked
micro1Practical and verbal: explain what the compiler is objecting to when an Rc cannot be sent to a thread, without using the word "trait" more than once.
GoogleMechanism: Send against Sync precisely, why Mutex of T is Sync when T is only Send, and how auto traits are derived.
MetaDesign: given shared mutable state behind one lock, propose a partitioning, and say when the mutex was actually right.
AmazonOperational: poisoning cascades, named threads in panic reports, and why RwLock is not automatically better for read-heavy work.
Hands-on

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.
  • Send means a value may move to another thread; Sync means &T may be shared — equivalently, T is Sync when &T is Send.
  • 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> is Sync even when T is only Send, because the lock supplies the synchronisation that Sync is a claim about.
  • Rc is 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: spawn requires F: Send, closures are Send when their captures are, and ordinary trait resolution produces the error.
  • Arc solves ownership and Mutex solves access, which is why the pair exists and mirrors Rc<RefCell<T>> from Part II exactly.
  • RwLock is not automatically better for reads — its bookkeeping costs more, so short critical sections often favour a plain mutex.
  • Scoped threads remove the 'static requirement by guaranteeing every spawned thread is joined before the scope returns, which eliminates most Arc use in bounded parallel work.
  • split_at_mut inside 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 like rayon.

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.

Editorial reflection

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.