How to Ensure Thread Safety in Rust
You're building a leaderboard for a multiplayer game. Two players score simultaneously. Thread A reads the score as 100, adds 5, and writes 105. Thread B reads the score as 100, adds 3, and writes 103. Player A's points vanish. The score should be 108. It's 103. This is a data race. In many languages, this bug hides until production, crashing your server or corrupting data. Rust prevents this before your code runs. The compiler rejects data races at compile time. You cannot accidentally share mutable state across threads without explicit synchronization.
The compiler's thread safety model
Rust enforces thread safety through two marker traits: Send and Sync. These traits don't require you to implement methods. They signal to the compiler whether a type can cross thread boundaries.
Send means ownership of a value can transfer to another thread. If a type is Send, you can move it into a thread::spawn closure. The thread takes full ownership. The original thread can no longer access the value.
Sync means a reference to a value can be shared across threads. If a type is Sync, you can pass &T to multiple threads. They can all read the data simultaneously. Sync does not allow mutation. It only guarantees safe concurrent access.
The relationship between these traits has a surprising implication. If a type T is Sync, then &T is automatically Send. A shared reference can move to another thread because the reference itself is just a pointer and a lifetime. The data it points to is Sync, so the other thread can read it safely. The reverse is not true. A type can be Send without being Sync. Mutex<T> is Send but not Sync unless T is Sync. You can move a mutex to a thread, but you cannot share a reference to the mutex itself without going through the lock.
Most standard types are both Send and Sync. Integers, strings, vectors, and structs containing only Send/Sync fields inherit these traits automatically. The compiler derives them for you. You only run into issues when you use types that explicitly opt out, like Rc or raw pointers.
Trust the traits. If the compiler lets you share a type, it has verified the safety guarantees. If it blocks you, there is a real risk of undefined behavior.
Shared mutable state: Arc and Mutex
When multiple threads need to mutate the same data, you need two tools. You need a way to share ownership across threads, and you need a way to enforce exclusive access during mutation. Rust provides Arc for shared ownership and Mutex for exclusive access.
Arc stands for Atomic Reference Counting. It wraps a value on the heap and maintains a counter of how many Arc instances point to it. The counter uses atomic operations, which are hardware instructions that guarantee updates never collide. When the last Arc is dropped, the value is freed.
Mutex stands for Mutual Exclusion. It wraps a value and allows only one thread to access it at a time. You call lock() to acquire access. The call blocks until the lock is available. lock() returns a guard object that dereferences to &mut T. When the guard drops, the lock releases automatically.
use std::sync::{Arc, Mutex};
use std::thread;
/// Increments a shared counter across multiple threads.
fn main() {
// Wrap the value in Mutex for exclusive access.
// Wrap that in Arc for shared ownership across threads.
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
// Clone the Arc, not the Mutex or the value.
// This increments the atomic reference count.
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
// Lock the mutex to get exclusive access.
// unwrap is safe here because the mutex isn't poisoned in this example.
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
The move keyword in the closure is essential. It transfers ownership of the Arc into the thread. Without move, the closure would try to borrow counter, but the thread might outlive the main function. The compiler rejects this with E0373 (closure may outlive the current function). The move forces the closure to take ownership, ensuring the data stays alive as long as the thread runs.
Convention aside: Always write Arc::clone(&counter) instead of counter.clone(). Both compile and do the same thing. The explicit form signals to readers that you are cloning the reference count, not deep-copying the data. counter.clone() looks like it might duplicate the entire value, which is misleading.
Clone the Arc, never the value. The reference count is the guard.
Shared immutable state
You do not always need a Mutex. If threads only read data, you can share it without locks. Locks add overhead. They force threads to wait even when waiting is unnecessary. If the data is immutable, multiple threads can read it simultaneously without risk.
Use Arc<T> for shared read-only access. Arc provides the shared ownership. The data inside is Sync, so references can be shared. No locking required.
use std::sync::Arc;
use std::thread;
/// Configuration shared across worker threads.
struct Config {
max_retries: u32,
timeout_ms: u64,
}
fn main() {
// Create config and wrap in Arc.
let config = Arc::new(Config {
max_retries: 3,
timeout_ms: 5000,
});
let mut handles = vec![];
for i in 0..5 {
// Clone the Arc for each thread.
let config = Arc::clone(&config);
let handle = thread::spawn(move || {
// Access fields directly. No lock needed.
// The compiler verifies Config is Sync.
println!("Thread {}: retries={}, timeout={}",
i, config.max_retries, config.timeout_ms);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
This pattern is common for configuration, read-only caches, and static data. If you find yourself wrapping everything in a Mutex, check if the data actually changes. Immutable sharing is faster and simpler.
Read-only data needs no locks. Use Arc for shared reads and save the synchronization overhead.
Realistic example: Server state
Real applications often track state that updates frequently. A server might count active connections, track request IDs, or manage a session store. You need to group related fields and update them atomically.
use std::sync::{Arc, Mutex};
use std::thread;
/// Tracks server metrics.
struct ServerState {
active_connections: u32,
total_requests: u64,
errors: u64,
}
fn main() {
let state = Arc::new(Mutex::new(ServerState {
active_connections: 0,
total_requests: 0,
errors: 0,
}));
let mut handles = vec![];
// Simulate 100 client requests.
for i in 0..100 {
let state = Arc::clone(&state);
let handle = thread::spawn(move || {
// Lock the state to update metrics.
let mut s = state.lock().unwrap();
s.active_connections += 1;
s.total_requests += 1;
// Simulate processing time.
thread::sleep(std::time::Duration::from_millis(1));
// Simulate occasional errors.
if i % 10 == 0 {
s.errors += 1;
}
// Decrement connections when done.
s.active_connections -= 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
let final_state = state.lock().unwrap();
println!("Total: {}, Errors: {}, Active: {}",
final_state.total_requests, final_state.errors, final_state.active_connections);
}
The lock guard s holds the lock for the entire duration of the closure. This ensures all updates happen atomically. If you dropped the guard early, another thread could interleave updates and corrupt the state. The borrow checker enforces this. You cannot access s after it drops. You cannot hold the lock across a yield point in async code. The compiler keeps you honest.
Lock, update, unlock. Hold the lock for the minimum time possible to reduce contention.
Pitfalls and compiler errors
Thread safety in Rust is strict. You will hit errors if you try to bypass the rules. The most common error involves using Rc instead of Arc.
Rc is not thread-safe. It uses a non-atomic counter. If you try to share an Rc across threads, the compiler rejects you with E0277 (the trait std::marker::Send is not implemented for std::rc::Rc). The error message tells you exactly what's wrong. Rc cannot be sent to another thread. Replace Rc with Arc to fix this.
Another pitfall is mutex poisoning. If a thread panics while holding a lock, the mutex becomes poisoned. This prevents other threads from accessing potentially corrupted data. Calling lock() on a poisoned mutex returns an error. If you use unwrap(), your program panics again. In production code, use expect() with a message, or handle the error explicitly.
// Handle poison gracefully.
let mut num = counter.lock().expect("Mutex was poisoned by a panic in another thread");
Deadlocks are another risk. If thread A holds lock 1 and waits for lock 2, while thread B holds lock 2 and waits for lock 1, neither can proceed. Rust cannot detect deadlocks at compile time. You must design your locking order carefully. Always acquire locks in a consistent order. Use try_lock() to avoid blocking indefinitely.
Handle the poison. A panicked thread leaves a mark; decide if you can recover or if you must abort.
Choosing the right tool
Thread synchronization has several options. Pick the one that matches your access pattern.
Use Arc<Mutex<T>> when you need multiple threads to share ownership and mutate the data, but only one thread can write at a time. Use Arc<RwLock<T>> when your workload is read-heavy and you want multiple threads to read the data simultaneously without blocking each other. Use channels (std::sync::mpsc) when threads should communicate by sending messages rather than sharing mutable state; this often leads to cleaner code and avoids locks entirely. Use thread-local storage when each thread requires its own isolated instance of data, avoiding synchronization overhead. Reach for Send-only types when you want to move data to a thread but prevent it from being shared back; this restricts the API to exactly what the thread needs.
Match the synchronization primitive to your access pattern. Don't add complexity you don't need.