Crust of Rust: Send, Sync, and Thread Safety in Rust
Rust guarantees data race freedom at compile time through its type system. While languages like Java handle concurrent modification at runtime (e.g., throwing a ConcurrentModificationException when a collection is mutated during iteration), Rust uses built-in type properties to verify thread safety before code ever runs.
At the core of Rust's compile-time concurrency model are two fundamental marker traits: Send and Sync. Far from being complex compiler magic, these traits are simple structural bounds that inform the type system whether a value can be transferred between thread boundaries or accessed concurrently across multiple threads.
1. The Core Problem: Compile-Time vs. Runtime Thread Safety
In traditional concurrent programming, ensuring thread safety relies heavily on runtime checks, lock conventions, or documentation guidelines.
By encoding thread capabilities directly into type signatures, Rust moves concurrency safety checks from runtime crashes to compile-time errors.
2. Shared Foundations: Marker Traits and Auto Traits
Send and Sync share two critical architectural characteristics: both are marker traits and auto traits.
Marker Traits
A marker trait contains no methods or associated types in its definition:
pub unsafe trait Send {}
pub unsafe trait Sync {}
Marker traits do not confer new behavior (e.g., implementing Send does not grant a .send() method). Instead, they act as boolean flags for the compiler to enforce trait bounds on generic APIs.
Auto Traits
An auto trait is automatically implemented for a type if all of its constituent fields themselves implement that trait.
- If
TypeAisSendandTypeBisSend, then a tuple(TypeA, TypeB)or a structstruct MyStruct { a: TypeA, b: TypeB }is automaticallySend. - If any internal member of a composite type is
!Send(notSend), the entire outer type automatically becomes!Send.
Key Rule: All auto traits are marker traits, but not all marker traits are auto traits (e.g.,
SizedandCopyare marker traits, but they are not auto traits).
Negative Implementations
To prevent an auto trait from being automatically derived, the standard library uses negative implementations (impl !Trait for Type):
// Unstable stdlib syntax
impl !Send for *const T {}
impl !Sync for *const T {}
Negative trait implementations tell the compiler to halt auto-trait propagation whenever a raw pointer is embedded inside a struct.
3. Deconstructing Send: Transferring Ownership Across Threads
A type is Send if ownership of a value of type can be transferred safely from one thread to another.
When a thread takes ownership of a Send value, it gains exclusive access to read, mutate, or drop that value without coordinating with the originating thread.
What Types Are Send?
The vast majority of Rust types are Send. Primitive types (i32, bool, f64), standard collections (Vec<T>, HashMap<K, V>), and heap allocations (Box<T>) are Send, provided their underlying inner types are also Send.
What Types Are NOT Send? (!Send)
A type cannot implement Send if moving it to another thread violates internal invariants or causes undefined behavior. There are two main categories of !Send types:
-
Types Bound to Thread-Local State: OS-backed
MutexGuardimplementations on certain operating systems require the thread that acquired the lock to be the exact same thread that releases it. If aMutexGuardwere moved to Thread B and dropped there, Thread B would attempt to unlock an OS mutex acquired by Thread A, causing platform-level UB or deadlocks. -
Types Bound to Thread-Local Destructors: If an object relies on thread-local storage (TLS) for its cleanup logic during
Drop, sending it to a different thread causesDropto run against the destination thread's local storage instead of the originating thread's, corrupting memory state. -
Types with Non-Atomic Reference Counting: Types like
Rc<T>rely on non-atomic integer operations to modify reference counts.
4. Deep Dive: Why Rc<T> Cannot Be Send (Code Walkthrough)
To understand why Rc<T> breaks under multi-threaded ownership transfer, consider a simplified implementation of a reference-counted smart pointer:
use std::ops::Deref;
use std::ptr::NonNull;
pub struct Inner<T> {
count: usize,
value: T,
}
pub struct MyRc<T> {
inner: NonNull<Inner<T>>,
}
impl<T> MyRc<T> {
pub fn new(value: T) -> Self {
let boxed = Box::new(Inner { count: 1, value });
// Convert box into a raw non-null pointer
Self {
inner: NonNull::new(Box::into_raw(boxed)).unwrap(),
}
}
}
impl<T> Clone for MyRc<T> {
fn clone(&self) -> Self {
unsafe {
// Direct non-atomic write to the reference counter
let ptr = self.inner.as_ptr();
(*ptr).count += 1;
}
MyRc { inner: self.inner }
}
}
impl<T> Drop for MyRc<T> {
fn drop(&mut self) {
unsafe {
let ptr = self.inner.as_ptr();
(*ptr).count -= 1;
// If reference count hits zero, drop the heap payload
if (*ptr).count == 0 {
let _ = Box::from_raw(self.inner.as_ptr());
}
}
}
}
impl<T> Deref for MyRc<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
unsafe { &self.inner.as_ref().value }
}
}
The Data Race Scenario
If MyRc<T> were permitted to implement Send, two threads could end up sharing a pointer to the exact same inner heap allocation:
When both threads terminate concurrently:
- Thread A and Thread B both invoke
Drop::drop(). - Thread A reads
countas2. Simultaneously, Thread B readscountas2. - Thread A decrements
countto1. Thread B decrementscountto1. - Neither thread observes
count == 0. The memory block is never freed (Memory Leak).
Alternatively, if both threads evaluate (*ptr).count -= 1 simultaneously when the count is 1:
- Both threads see
countdrop to0. - Both threads execute
Box::from_raw(...)on the same address. - A double-free error occurs, triggering instant memory corruption / Undefined Behavior.
Because MyRc uses raw pointers (NonNull<T>), the compiler automatically infers MyRc as !Send and !Sync, blocking this concurrency bug at compile time.
5. Deconstructing Sync: Sharing References Across Threads
The formal definition of Sync is built directly on top of Send:
In plain terms: A type is Sync if and only if a shared reference (&T) can be safely sent to another thread.
Why MutexGuard Is Sync But NOT Send
A MutexGuard<'a, T> presents a unique combination:
- It is
!Send: You cannot send the guard itself to Thread B, because Thread B dropping the guard would violate OS lock-release thread-affinity invariants. - It is
Sync: You can pass a shared reference&MutexGuard<'a, T>to Thread B. Thread B can read the data behind&MutexGuard, but because Thread B only holds a shared reference (&), it cannot move or drop the underlying guard.
6. Types That Are Send But Not Sync: Interior Mutability
A type can be Send (safe to move ownership) while simultaneously being !Sync (unsafe to share references across threads). The primary example of this pattern is single-threaded interior mutability primitives: Cell<T> and RefCell<T>.
The Mechanics of Cell<T>
Cell<T> provides interior mutability by never handing out references to its internal payload. Instead, values are mutated in-place or swapped out by value (get() and set()):
pub struct Cell<T> {
value: UnsafeCell<T>,
}
Why Cell<T> Is Send
If Thread A owns a Cell<i32> and moves it entirely to Thread B, Thread A no longer holds any pointer or reference to that Cell. Thread B now has exclusive ownership and can freely mutate the inner value using single-threaded invariants. Thus, Cell<T>: Send is sound.
Why Cell<T> Is NOT Sync (!Sync)
If Cell<T> were Sync, Thread A and Thread B could simultaneously hold a shared reference &Cell<i32> to the same cell:
Because Cell<T> performs no synchronization (no mutex locks or atomic memory barriers), two threads calling .set() concurrently on &Cell<T> would race on raw memory writes, causing data corruption. Therefore, Cell<T> is explicitly marked !Sync via UnsafeCell<T>.
7. Standard Library Thread Safety Matrix
To build a clear mental model, compare how standard library types configure Send and Sync:
The Unique Bounds of Arc<T>
Notice the condition for Arc<T>:
unsafe impl<T: ?Sized + Sync + Send> Send for Arc<T> {}
unsafe impl<T: ?Sized + Sync + Send> Sync for Arc<T> {}
Arc<T>requiresT: Syncto beSendbecause multiple threads holding clonedArc<T>pointers can access&Tconcurrently.Arc<T>requiresT: Sendto beSendbecause whichever thread drops the finalArchandle will executeDropforT. IfTwas not safe to drop on an arbitrary thread, sending theArcwould violate thread safety.
8. Compiler Trait Propagation & Opting Out with PhantomData
Because Send and Sync are auto-traits, embedding raw pointers (*mut T or *const T) inside a custom type instantly strips the outer struct of its automatic Send and Sync implementations.
Opting Out on Stable Rust without Nightly Features
While the standard library uses the nightly-only impl !Send for Type syntax, you can opt out of Send or Sync on stable Rust using zero-sized marker types like PhantomData:
use std::marker::PhantomData;
use std::rc::Rc;
// A struct that contains primitive Send/Sync types,
// but must NOT
be sent across thread boundaries.
pub struct ThreadBoundWorker {
id: usize,
// PhantomData acts as if an Rc<()> is inside this struct,
// causing auto-traits to infer !Send and !Sync without extra runtime memory cost.
_marker: PhantomData<Rc<()>>,
}
impl ThreadBoundWorker {
pub fn new(id: usize) -> Self {
Self {
id,
_marker: PhantomData,
}
}
}
Unsafely Overriding Compiler Bounds
If a custom data structure contains raw pointers (such as a custom lock-free queue), but you have manually verified that thread-crossing operations are safe, you must explicitly implement Send and Sync using unsafe:
pub struct MyThreadSafeContainer<T> {
raw_ptr: *mut T,
}
// SAFETY: We have manually verified that raw_ptr is never accessed
// concurrently without internal atomic synchronization.
unsafe impl<T: Send> Send for MyThreadSafeContainer<T> {}
unsafe impl<T: Sync> Sync for MyThreadSafeContainer<T> {}
Warning: Manually implementing
SendorSynctells the compiler: "Trust me, I have verified thread safety." If your underlying data structure contains uncoordinated data races, this results in immediate Undefined Behavior.
9. Dynamic Trait Objects (dyn Trait + Send + Sync) & Async Executors
In Rust trait object syntax, dynamic dispatch (dyn Trait) normally restricts type declarations to a single non-auto trait. However, auto traits like Send and Sync are explicit exceptions:
// Valid: Combining a base trait with auto-trait bounds
pub type BoxedThreadSafeIterator = Box<dyn Iterator<Item = String> + Send + Sync>;
Thread Safety in Multi-Threaded Async Executors
This syntax is fundamental to asynchronous frameworks like Tokio or async-std. Multi-threaded async runtimes use work-stealing thread pools:
Because an asynchronous task can pause at an .await point on Worker Thread 1 and resume on Worker Thread 2, the async task state (the generated Future state machine) must implement Send.
// Tokio task spawn signature requires Send
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
// ...
}
If an async function holds a !Send type (such as Rc<T> or a RefCell borrow) across an .await point, the entire compiled state machine becomes !Send, resulting in a compile-time error when passed to tokio::spawn.
10. Complete Thread Safety Evaluation Flow
When the Rust compiler encounters a generic bound requiring T: Send or T: Sync, it evaluates the type structure according to the following resolution pipeline:
Summary and Key Takeaways
- Compile-Time Thread Safety: Rust eliminates data races at compile time by validating
SendandSynctrait bounds during type checking rather than relying on runtime exceptions. - Marker & Auto Traits: Both
SendandSyncare marker traits (no methods) and auto traits (automatically implemented for composite types if all internal fields implement them). SendDefinition:T: Sendmeans ownership of a value can be transferred safely across thread boundaries. Most types areSend.SyncDefinition:T: Syncmeans a shared reference (&T) can be sent across threads ().- Why
Rc<T>is!Sendand!Sync:Rcuses non-atomic reference count increments/decrements. Sharing or sendingRcpointers creates data races on reference counts, leading to double-free bugs or memory leaks. Cell<T>andRefCell<T>(Send + !Sync): Primitives providing single-threaded interior mutability are safe to move across threads (Send), but unsafe to reference concurrently across threads (!Sync).MutexGuard(!Send + Sync): OS-backed locks often require the acquiring thread to release the lock (!Send). However, reading lock payload data through a shared reference across threads is perfectly safe (Sync).- Raw Pointers Disable Auto-Traits: Standard raw pointers (
*const T,*mut T) implement!Sendand!Sync. Any type containing raw pointers automatically opts out of auto-traits. PhantomDataOpt-Outs: Stable code can opt out of auto traits by embedding zero-sized phantom markers such asPhantomData<Rc<()>>.- Async Runtime Necessity: Work-stealing async executors require tasks to implement
Sendbecause futures are periodically migrated across worker thread boundaries during execution.