Fearless Concurrency
"Fearless concurrency" is Rust's promise: you can write multithreaded code confidently because the type system catches entire classes of concurrency bugs at compile time, before your program ever runs.
In most languages, concurrency is a source of anxiety — data races, use-after-free in threads, forgotten synchronisation. In Rust, the compiler acts as a second pair of eyes on every concurrent access to shared data, and it refuses to compile code that would cause a data race.
What Is a Data Race?
A data race occurs when all three of the following are true simultaneously:
- Two or more threads access the same memory location.
- At least one of those accesses is a write.
- The accesses are not synchronised (no mutex, no atomic, no happens-before relationship).
Data races are undefined behaviour in C and C++. The program might produce wrong output, crash, or appear to work correctly while silently corrupting memory. They are notoriously hard to reproduce because they depend on exact thread scheduling, which varies between runs and machines.
How Rust Prevents Data Races
Rust's ownership and borrowing rules — which you already use to prevent dangling references in single-threaded code — extend naturally to threads:
- You can have many immutable (
&T) references OR one mutable (&mut T) reference — never both at the same time. - The borrow checker enforces this across thread boundaries, not just within a function.
A data race requires at least one writer and at least one concurrent reader or
writer. Rust's exclusive mutable reference rule makes that impossible in safe code:
if one thread has &mut T, no other reference to T can exist anywhere.
// The borrow checker prevents simultaneous mutation in a single thread…
fn single_thread() {
let mut data = vec![1, 2, 3];
let r = &data; // immutable borrow
println!("{:?}", r);
// data.push(4); // compile error — cannot mutate while borrowed
drop(r);
data.push(4); // fine once the borrow ends
}
// …and the same rules extend across thread boundaries.
// Sending &mut data to two threads simultaneously is impossible in safe Rust.The Send and Sync Marker Traits
Rust encodes thread safety in the type system through two marker traits — traits with no methods that exist solely to carry a compile-time guarantee.
Trait | Meaning | Automatically derived? |
|---|---|---|
Send | It is safe to transfer ownership of T to another thread | Yes, for almost all types |
Sync | It is safe to share &T across threads (T: Sync implies &T: Send) | Yes, for almost all types |
Most types are automatically Send + Sync because their data is either owned
(one owner at a time) or immutably shared. The exceptions are types that use
non-atomic internal mutation.
Type | Send | Sync | Reason |
|---|---|---|---|
i32, f64, bool, … | Yes | Yes | Plain data — safe everywhere |
String, Vec<T> | Yes | Yes | Owned, no shared interior mutation |
Arc<T> (T: Send+Sync) | Yes | Yes | Atomic reference counting |
Mutex<T> (T: Send) | Yes | Yes | Guards all mutable access |
Rc<T> | No | No | Non-atomic reference count — use Arc instead |
RefCell<T> | Yes | No | Runtime borrow checking is not thread-safe — use Mutex |
Raw pointer *const T | No | No | Opt out of all guarantees; unsafe impl required |
MutexGuard<T> | No | Yes | Must be unlocked on the same thread it was locked |
What the Compiler Catches
The most common mistake — sending an Rc<T> to another thread — is a
compile-time error. Rc uses non-atomic reference counting; if two threads
incremented the count simultaneously, the count could be corrupted.
use std::rc::Rc;
use std::thread;
fn main() {
let value = Rc::new(42);
let value_clone = Rc::clone(&value);
// ERROR: Rc<i32> cannot be sent between threads safely
let handle = thread::spawn(move || {
println!("{}", value_clone);
});
handle.join().unwrap();
}// Compiler output:
// error[E0277]: Rc<i32> cannot be sent between threads safely
// --> src/main.rs:8:18
// |
// | let handle = thread::spawn(move || {
// | ^^^^^^^^^^^^^
// | Rc<i32> cannot be sent between threads safely
// |
// = help: the trait Send is not implemented for Rc<i32>
// = note: required because it appears within the type closureThe fix is to replace Rc with Arc, which uses atomic operations for its
reference count and is Send + Sync:
use std::sync::Arc;
use std::thread;
fn main() {
let value = Arc::new(42); // Arc is Send + Sync
let value_clone = Arc::clone(&value);
let handle = thread::spawn(move || {
println!("{}", value_clone); // compiles and works
});
handle.join().unwrap();
}42
RefCell vs Mutex
RefCell<T> provides interior mutability with runtime borrow checking. It is
useful in single-threaded code but is not Sync, so it cannot be shared across
threads. The thread-safe equivalent is Mutex<T>, which provides the same
interior mutability guarantee but enforces it with a lock that works across threads.
RefCell<T> | Mutex<T> | |
|---|---|---|
Thread-safe | No | Yes |
Borrow checking | Runtime panic on violation | Blocking lock — no violation possible |
Overhead | Two small counters | OS lock (heavier) |
Use in | Single-threaded interior mutation | Shared mutable state across threads |
Implementing Send and Sync Manually
If you write a type that wraps a raw pointer or other non-Send/Sync primitive,
the compiler will not automatically derive Send or Sync for it. You can
opt in manually with unsafe impl, but you are then responsible for proving
the safety guarantee.
// A wrapper around a raw pointer — not Send by default
struct MyBuffer(*mut u8);
// We know our buffer is not aliased across threads,
// so we opt in manually. This is unsafe — we must be right.
unsafe impl Send for MyBuffer {}
unsafe impl Sync for MyBuffer {}unsafe impl Send or unsafe impl Syncwhen you have thoroughly verified that no data races are possible. A wrong implementation can produce all the same bugs as C++ — use-after-free, torn reads, memory corruption.Comparing to Other Languages
Fearless concurrency is one of Rust's defining differentiators. Here is how the same data race scenario plays out in different languages.
Language | Data race detection | When caught |
|---|---|---|
C / C++ | None by default — undefined behaviour | Maybe at runtime with tsan, maybe never |
Go | Race detector (-race flag) | Runtime — only if the racing path is exercised |
Java | Programmer must use volatile/synchronized correctly | Never caught automatically — logic error |
Python (CPython) | GIL prevents true parallel threads | Hidden by the GIL — not a general solution |
Rust | Type system — Send, Sync, borrow checker | Compile time — before the program runs |
The key insight is that Rust's guarantees are unconditional: they apply to every build, every run, every platform. Go's race detector only fires if the racing code path is actually executed during a test with the flag enabled. C's sanitizers are similar — they only catch races that are triggered. Rust catches them all, always.
The Three Concurrency Models in Rust
Rust does not mandate one concurrency style. It supports three composable models, and real programs often use all three together.
Threads + mutexes — OS threads (
std::thread::spawn) withArc<Mutex<T>>orArc<RwLock<T>>for shared data. Best for CPU-bound parallelism.Channels —
std::sync::mpscfor message passing between threads. Data moves rather than being shared. Best for producer/consumer pipelines.Async tasks — lightweight futures driven by a runtime such as Tokio. Many tasks multiplexed on a small thread pool. Best for I/O-bound concurrency (web servers, HTTP clients, database queries).
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for i in 0..5 {
tx.send(i * i).unwrap();
}
});
for received in rx {
println!("got: {}", received);
}
}got: 0 got: 1 got: 4 got: 9 got: 16
A Data Race That Rust Prevents: Illustrated
Here is the classic data race — two threads incrementing a shared counter without
synchronisation — and how Rust refuses to compile it, then the correct version using
Arc<Mutex<T>>.
// This does NOT compile — Rust prevents the data race
use std::thread;
fn main() {
let mut counter = 0u32;
// ERROR: cannot borrow 'counter' as mutable,
// and cannot close over &mut across thread boundaries
let t1 = thread::spawn(|| { counter += 1; });
let t2 = thread::spawn(|| { counter += 1; });
t1.join().unwrap();
t2.join().unwrap();
}// Correct version — the type system guides you to Arc<Mutex<T>>
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0u32));
let c1 = Arc::clone(&counter);
let c2 = Arc::clone(&counter);
let t1 = thread::spawn(move || { *c1.lock().unwrap() += 1; });
let t2 = thread::spawn(move || { *c2.lock().unwrap() += 1; });
t1.join().unwrap();
t2.join().unwrap();
println!("counter = {}", counter.lock().unwrap()); // 2
}counter = 2
Summary
A data race requires concurrent unsynchronised access with at least one write.
Rust's ownership rules — one writer OR many readers — make data races impossible in safe code.
The Send marker trait ensures a type can be moved to another thread.
The Sync marker trait ensures a shared reference &T can be used from multiple threads.
Rc<T> and RefCell<T> are not thread-safe; their thread-safe equivalents are Arc<T> and Mutex<T>.
Rust catches these errors at compile time — no runtime race detector needed.
The three concurrency models (threads + mutexes, channels, async) are all safe and composable.
Send and Sync are just the borrow checker extended to cross-thread boundaries, concurrent Rust feels as natural and safe as single-threaded Rust.