Fearless Concurrency in Rust
"Fearless concurrency" is Rust's own marketing phrase, and unlike most marketing phrases it describes something concrete and checkable. In most languages, writing correct multi-threaded code depends on discipline: reviewers hoping every shared variable is properly locked, and runtime tools like ThreadSanitizer catching races only in the code paths a test happens to exercise. Rust moves a meaningful slice of that correctness burden into the type system itself, so an entire category of data races becomes a compile error instead of a 3 a.m. page.
This page is the conceptual anchor for the rest of the concurrency section. It covers what Send and Sync actually promise, why the compiler can check them without any annotation in most cases, and the fundamental design choice between sharing memory behind a lock and passing ownership through a channel that shapes every concurrent Rust program.
Summary
SendandSyncare marker traits the compiler uses to prove, at compile time, that a given type can cross or be shared across thread boundaries without a data race.- Insight: Data races are undefined behavior, not just bugs, and Rust's ownership model gives the compiler enough information to rule most of them out before the program ever runs.
- Key Concepts: Send, Sync, data race, auto trait, shared-state concurrency, message-passing concurrency.
- When to Use: Any time a program spawns threads, shares an
Arc, uses async tasks across a multi-threaded runtime, or hits a compiler error mentioningSendorSync. - Limitations/Trade-offs:
Send/Synconly rule out data races; they say nothing about deadlocks, logic races, or ordering bugs, all of which remain the programmer's responsibility. - Related Topics: threads and
JoinHandle, atomics and memory ordering, async tasks, the actor pattern.
Foundations
A data race has a precise technical meaning in Rust: two or more threads accessing the same memory location concurrently, at least one access is a write, and there is no synchronization ordering the accesses. This is distinct from a "race condition" in the looser sense (two threads racing to update a database row, for example), which Rust's type system does not and cannot prevent.
Rust's answer to data races is two marker traits with no methods: Send and Sync.
A type is Send if it is safe to move ownership of a value of that type to another thread. A type is Sync if it is safe to share a reference (&T) to a value of that type across threads; formally, T: Sync if and only if &T: Send.
Most types get these traits for free. They are auto traits: the compiler derives Send and Sync automatically based on a type's fields, recursively, with no impl block required. A struct made entirely of Send fields is Send; a struct containing even one !Send field is !Send unless a human explicitly asserts otherwise with unsafe impl.
A small number of types opt out deliberately. Rc<T>, Rust's single-threaded reference-counted pointer, is !Send because its reference count is a plain, non-atomic integer; two threads incrementing it concurrently would corrupt the count, a textbook data race. Arc<T>, its atomic counterpart, uses an atomic integer instead and is Send (when T is Send + Sync), which is precisely why threaded Rust code reaches for Arc over Rc.
The practical effect is that thread-spawning APIs like std::thread::spawn require their closure to be F: Send + 'static. If a captured value is !Send, the code fails to compile with a specific, named error rather than compiling successfully and racing at runtime. This is the mechanism behind "fearless": the failure mode moves from a runtime bug that might never show up in testing to a compiler error that always shows up, on every build.
Mechanics & Interactions
Send and Sync are checked wherever a type crosses a concurrency boundary, and the compiler's error messages name the exact type that fails the bound, which is what makes debugging them tractable in practice.
The two traits are independent, and understanding why clarifies a lot of confusing error messages. A type can be Send but not Sync, meaning it is safe to hand off entirely but not safe to access from two threads at once through a shared reference; Cell<T> and RefCell<T> are exactly this, since their interior mutability has no locking and would race if shared concurrently. A type can also be Copy and !Send at the same time, like Rc<T>, because Copy and thread-safety are orthogonal properties.
Mutex<T> is a useful worked example of how the two traits compose. Mutex<T> is Sync whenever T: Send, because the lock itself provides the synchronization that makes shared access safe; the mutex takes a type that is safe to move but not necessarily safe to share, and produces a type that is safe to share. This is exactly why the canonical Rust pattern for shared mutable state is Arc<Mutex<T>>: Arc provides safe shared ownership across threads, and Mutex provides safe shared mutation once inside.
use std::sync::{Arc, Mutex};
// Arc<Mutex<T>> composes two guarantees:
// Arc<T> is Sync because it clones atomically (shared ownership).
// Mutex<T> is Sync because access is serialized (shared mutation).
let counter: Arc<Mutex<i32>> = Arc::new(Mutex::new(0));
let handle = counter.clone(); // clones the Arc, not the i32The most common real-world friction point is a MutexGuard held across an .await point in async code. MutexGuard is deliberately !Send, because a lock acquired on one OS thread must be released on that same thread in most lock implementations, and an async task can, in principle, resume on a different worker thread after suspending. The compiler rejects this at compile time as a Send bound failure on the surrounding future, which looks cryptic the first time but is the exact mechanism protecting against a genuine correctness hazard.
This is the point where the type-system story runs into its honest limit. Send and Sync prove the absence of data races; they say nothing about deadlocks (two threads waiting on each other's locks forever), livelocks, or plain logic races where the program compiles fine but two threads still interleave in a way that produces the wrong answer. "Fearless" describes one specific, well-defined class of bug, not concurrency correctness in general.
Advanced Considerations & Applications
Every concurrent Rust design eventually chooses between two philosophies, and the choice shapes the rest of the code far more than any individual API detail.
Shared-state concurrency keeps one copy of the data and serializes access to it, typically through Arc<Mutex<T>> or Arc<RwLock<T>>. Every thread that needs the data reaches for the same lock. Message-passing concurrency keeps each piece of data owned by exactly one thread at a time and moves it between threads by sending it through a channel; nothing is ever truly shared, only handed off.
Rust's ownership model makes message passing unusually cheap to reason about compared to other languages, because send on a channel is a genuine ownership transfer enforced by the compiler. The sending thread cannot accidentally keep using the value after sending it; the borrow checker would reject that at compile time.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
Arc<Mutex<T>> | Simple mental model for shared state; no extra scheduling logic | Contention scales badly with many writers; risk of deadlock with multiple locks | Shared caches, counters, small state with infrequent writes |
Arc<RwLock<T>> | Cheap concurrent reads | Writers block everything; some implementations starve writers | Read-heavy shared data, config snapshots |
Channels (mpsc, crossbeam) | No shared mutable state, ownership transfer is compiler-checked | Extra indirection; unbounded channels can leak memory under backpressure | Pipelines, worker pools, producer/consumer designs |
| Atomics | Lock-free, lowest overhead for simple values | Only works for primitive-sized data; easy to misuse memory ordering | Counters, flags, lock-free single values |
| Actor pattern (one owner task per resource) | Combines message passing with encapsulated state; no locks at all | More boilerplate; indirection for simple cases | Complex stateful services, connection managers |
In practice, most production Rust systems use both philosophies at different layers: message passing to move work between stages of a pipeline, and a small number of Arc<Mutex<T>> instances for genuinely shared caches or counters where message passing would just be a lock in disguise. The Tokio ecosystem leans further toward message passing than raw std::thread code does, because holding a std::sync::MutexGuard across an .await is disallowed by the Send bound described above, which nudges async codebases toward channels or tokio::sync::Mutex (an async-aware lock designed to be held across .await).
Atomics sit at the far performance end of this spectrum. AtomicUsize, AtomicBool, and their relatives use CPU-level atomic instructions instead of OS-level locking, which avoids the cost of putting a thread to sleep on contention, but they only work for values that fit in a single machine word and require choosing a memory ordering (Relaxed, Acquire, Release, SeqCst) that is genuinely hard to reason about correctly without a documented mental model.
Common Misconceptions
- "
Arcmakes any type safe to share across threads" -Arc<T>is onlySyncwhenTitself isSend + Sync;Arc<RefCell<T>>is still unsound to share becauseRefCell's interior mutability has no locking. - "
SendandSyncare runtime checks" - they are compile-time trait bounds with zero runtime cost; aSendviolation is a compiler error, never a runtime panic. - "Fearless concurrency means Rust prevents all concurrency bugs" - it prevents data races specifically; deadlocks, livelocks, and plain logic errors across threads compile and run without any warning.
- "You usually need to implement
Send/Syncby hand" - the overwhelming majority of types get both automatically from their fields; manualunsafe implis rare and reserved for raw pointers or FFI handles. - "Channels are always slower than locks" - for high-contention shared counters, a lock or atomic is often faster, but for pipelines with independent work items, channels frequently outperform locks by avoiding contention entirely.
- "Async and threads use the same concurrency model" - async tasks add cooperative scheduling on top of the same
Send/Syncfoundation, which is why non-Sendfutures fail to compile under a multi-threaded runtime even though the underlying rules are identical.
FAQs
What exactly is a data race, in Rust's technical sense?
Two or more threads accessing the same memory concurrently, with at least one access being a write, and no synchronization ordering the accesses. It is undefined behavior in Rust's memory model, not merely a logic bug.
How does the compiler know a type is Send or Sync without an explicit impl?
Both are auto traits: the compiler derives them recursively from a type's fields. A struct is Send if every field is Send, and Sync if every field is Sync, with no manual impl block needed in the common case.
Why is Rc not Send but Arc is?
Rc<T> increments a plain integer reference count with no synchronization, so concurrent clones from different threads would race and corrupt the count. Arc<T> uses an atomic integer for the same operation, which is safe under concurrent access.
Can a type be Send but not Sync?
Yes. Cell<T> and RefCell<T> are Send (when T is Send) because moving one to another thread is fine, but !Sync because sharing &RefCell<T> between threads has no locking and would race.
Why does holding a MutexGuard across .await fail to compile?
MutexGuard is !Send, and an async task's future may resume on a different worker thread after suspending at an .await. Holding a !Send guard across that suspension point would make the surrounding future !Send, which multi-threaded runtimes reject.
When should I choose channels over Arc<Mutex<T>>?
Choose channels when work items are naturally owned by one thread at a time, like pipeline stages or worker pools. Choose Arc<Mutex<T>> when multiple threads genuinely need read/write access to the same long-lived piece of state, like a shared cache.
Do Send and Sync prevent deadlocks?
No. Deadlocks happen when two threads wait on each other's locks, which is a valid, data-race-free program from the type system's point of view. Preventing deadlocks requires discipline like a fixed lock-acquisition order, not a compiler bound.
Can I manually implement Send or Sync for my own type?
Yes, with unsafe impl Send for MyType {}, but only when you can prove thread safety yourself, typically for a wrapper around a raw pointer or FFI handle where the compiler cannot derive the bound automatically.
Are atomics always better than a Mutex?
Only for simple, word-sized values like counters and flags. Atomics avoid lock overhead but require reasoning about memory ordering explicitly, and they cannot protect a multi-field data structure the way a single Mutex can.
How is this different from garbage-collected languages with threads?
Languages like Java or Go let you compile and run code with a data race; the bug appears (or doesn't) at runtime, often intermittently. Rust rejects the same class of bug at compile time via Send/Sync, before the program ever runs.
Does async Rust use Send/Sync differently than threaded Rust?
The traits mean the same thing in both contexts, but async adds a wrinkle: an entire future must be Send for a multi-threaded runtime to move it between worker threads across suspension points, which surfaces Send errors in places threaded code never would.
What is the actor pattern and how does it relate to Send/Sync?
An actor owns its state exclusively and communicates only through messages, so the state itself never needs to be Sync at all, only the messages need to be Send. It is message-passing concurrency taken to its logical conclusion for a single resource.
Related
- Concurrency Basics - first threads,
moveclosures, andjoin - Send & Sync - the marker traits in depth, with a rules-at-a-glance table
- Mutex & RwLock - shared-state locking patterns
- Channels - message-passing with
mpscandcrossbeam - Arc for Shared State - shared ownership across threads
- Shared State in Async - the same trade-off inside Tokio tasks
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.