Rust Send and Sync: Thread Safety Markers
Rust's approach to concurrency is fundamentally different from most languages. Instead of relying on runtime checks or developer discipline, Rust enforces thread safety at compile time through its...
Key Insights
- Send and Sync are auto-traits that the compiler uses to enforce thread safety at compile time—Send means a type can be transferred between threads, while Sync means a type can be safely referenced from multiple threads
- Most types are Send and Sync by default, but types like
Rc<T>andRefCell<T>deliberately opt out because sharing them across threads would cause data races - Understanding these markers is essential for working with concurrent Rust code, and the compiler’s error messages will guide you toward safe patterns like
Arc<Mutex<T>>for shared mutable state
Understanding Thread Safety in Rust
Rust’s approach to concurrency is fundamentally different from most languages. Instead of relying on runtime checks or developer discipline, Rust enforces thread safety at compile time through its type system. At the heart of this mechanism are two marker traits: Send and Sync.
These traits aren’t something you’ll typically call methods on or interact with directly. Instead, they’re signals to the compiler about what’s safe to do with a type in a multithreaded context. The compiler uses these markers to prevent data races before your code ever runs.
The Send Trait: Transferring Ownership Across Threads
A type is Send if it’s safe to transfer ownership of a value of that type to another thread. This is about moving data, not sharing it. When you spawn a thread and move a value into its closure, the compiler checks that the value is Send.
Most types in Rust are Send automatically. Primitive types, most structs, and standard collections all implement Send without you doing anything:
use std::thread;
struct UserData {
id: u64,
name: String,
scores: Vec<i32>,
}
fn main() {
let user = UserData {
id: 42,
name: "Alice".to_string(),
scores: vec![100, 95, 87],
};
// This works because UserData is Send
let handle = thread::spawn(move || {
println!("Processing user {} with {} scores", user.name, user.scores.len());
});
handle.join().unwrap();
}
The key non-Send type you’ll encounter is Rc<T>, Rust’s reference-counted smart pointer. Rc<T> uses a non-atomic reference count for performance, which means incrementing or decrementing it from multiple threads would cause a data race:
use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(vec![1, 2, 3]);
// This won't compile!
// let handle = thread::spawn(move || {
// println!("Length: {}", data.len());
// });
// Error: `Rc<Vec<i32>>` cannot be sent between threads safely
}
The compiler stops you from making this mistake. If you need shared ownership across threads, you use Arc<T> (atomic reference counted) instead, which is Send because it uses atomic operations.
The Sync Trait: Sharing References Across Threads
A type is Sync if it’s safe to share references to it between threads. More precisely, T is Sync if and only if &T is Send. This might seem abstract, but it’s crucial for understanding what you can safely share.
If a type is Sync, multiple threads can hold immutable references to the same value simultaneously. Most immutable types are Sync:
use std::thread;
use std::sync::Arc;
fn main() {
let data = Arc::new(vec![1, 2, 3, 4, 5]);
let mut handles = vec![];
for i in 0..3 {
let data_clone = Arc::clone(&data);
let handle = thread::spawn(move || {
println!("Thread {}: sum = {}", i, data_clone.iter().sum::<i32>());
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
Here, Arc<Vec<i32>> is Sync, so we can share references to the same data across multiple threads. Each thread gets its own Arc clone (incrementing the reference count), but they all point to the same underlying vector.
Types that provide interior mutability without synchronization are not Sync. Cell<T> and RefCell<T> allow mutation through shared references, but they don’t use any synchronization primitives:
use std::cell::RefCell;
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(RefCell::new(0));
// This won't compile!
// let data_clone = Arc::clone(&data);
// let handle = thread::spawn(move || {
// *data_clone.borrow_mut() += 1;
// });
// Error: `RefCell<i32>` cannot be shared between threads safely
}
Standard Library Patterns
Understanding how standard library types implement Send and Sync helps you choose the right tool:
| Type | Send | Sync | Use Case |
|---|---|---|---|
Rc<T> |
No | No | Single-threaded shared ownership |
Arc<T> |
Yes* | Yes* | Multi-threaded shared ownership |
Cell<T> |
Yes* | No | Single-threaded interior mutability |
RefCell<T> |
Yes* | No | Single-threaded interior mutability with runtime checks |
Mutex<T> |
Yes* | Yes* | Multi-threaded interior mutability |
RwLock<T> |
Yes* | Yes* | Multi-threaded read-heavy scenarios |
*Depends on whether T is Send/Sync
For shared mutable state across threads, the standard pattern is Arc<Mutex<T>>:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter_clone = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter_clone.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
For read-heavy workloads, RwLock allows multiple concurrent readers:
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let data = Arc::new(RwLock::new(vec![1, 2, 3, 4, 5]));
let mut handles = vec![];
// Multiple readers
for i in 0..5 {
let data_clone = Arc::clone(&data);
let handle = thread::spawn(move || {
let vec = data_clone.read().unwrap();
println!("Reader {}: {:?}", i, *vec);
});
handles.push(handle);
}
// One writer
let data_clone = Arc::clone(&data);
let handle = thread::spawn(move || {
let mut vec = data_clone.write().unwrap();
vec.push(6);
});
handles.push(handle);
for handle in handles {
handle.join().unwrap();
}
}
Implementing Send and Sync for Custom Types
The compiler automatically derives Send and Sync for your types based on their fields. If all fields are Send, your type is Send. If all fields are Sync, your type is Sync:
// Automatically Send and Sync
struct Config {
timeout: u64,
max_retries: u32,
endpoint: String,
}
// Not Send or Sync because of Rc
use std::rc::Rc;
struct Cache {
data: Rc<Vec<String>>,
}
For types containing raw pointers or other special cases, you may need to manually implement these traits using unsafe:
use std::marker::PhantomData;
struct MyBox<T> {
ptr: *mut T,
_marker: PhantomData<T>,
}
// Only implement if T is Send
unsafe impl<T: Send> Send for MyBox<T> {}
// Only implement if T is Sync
unsafe impl<T: Sync> Sync for MyBox<T> {}
The unsafe keyword here is your promise to the compiler that you’ve verified the safety requirements. You’re responsible for ensuring that your implementation actually upholds thread safety guarantees.
To explicitly opt out of Send or Sync, use PhantomData with a non-Send/Sync type:
use std::marker::PhantomData;
use std::rc::Rc;
struct NotThreadSafe<T> {
data: T,
_marker: PhantomData<Rc<()>>,
}
Common Pitfalls and Solutions
The most common error you’ll see is attempting to send a non-Send type across threads:
use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(5);
// Compiler error: `Rc<i32>` cannot be sent between threads safely
// let handle = thread::spawn(move || {
// println!("{}", data);
// });
}
The fix is straightforward—use Arc instead:
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(5);
let handle = thread::spawn(move || {
println!("{}", data);
});
handle.join().unwrap();
}
When you need shared mutable state and see errors about Sync, wrap your data in a Mutex or RwLock:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let data = Arc::new(Mutex::new(vec![1, 2, 3]));
let data_clone = Arc::clone(&data);
let handle = thread::spawn(move || {
data_clone.lock().unwrap().push(4);
});
handle.join().unwrap();
println!("{:?}", data.lock().unwrap());
}
Best Practices
Start with immutable data structures and Arc when possible. Shared immutable state is the simplest and most performant pattern. Only introduce Mutex or RwLock when you actually need mutation.
Trust the compiler. If it says a type isn’t Send or Sync, there’s a good reason. Don’t fight the type system—restructure your code to work with it.
Be conservative with unsafe impl Send and unsafe impl Sync. These are promises you’re making to the compiler, and getting them wrong leads to undefined behavior. Only implement these traits manually when you’re wrapping low-level primitives and you fully understand the safety requirements.
Use type aliases to clarify intent in your APIs:
use std::sync::{Arc, Mutex};
type SharedCounter = Arc<Mutex<u64>>;
fn increment_counter(counter: &SharedCounter) {
let mut num = counter.lock().unwrap();
*num += 1;
}
Finally, remember that Send and Sync are about compile-time guarantees, not runtime performance. A type being Send or Sync doesn’t mean it’s efficient to use across threads—just that it’s safe. Design your concurrent systems with both safety and performance in mind.