Shared state without the race
You're building a multiplayer game server. Ten threads are processing player actions. Every time a player scores, the global score needs to update. In C++, you reach for a mutex, forget to lock it in one path, and the score oscillates between negative and infinity. In Java, you wrap everything in synchronized, and the server crawls to a halt under load. You want the speed of C++ with the safety of Java, without the runtime overhead or the boilerplate. Rust gives you that, but it forces you to think about data flow before you write a single lock.
Send and Sync: the thread safety contract
Rust models thread safety with two marker traits: Send and Sync. They sound abstract, but they map to physical actions. Send asks: "Can I hand this value to another thread?" If yes, it's Send. Sync asks: "Can I let multiple threads look at this value at the same time?" If yes, it's Sync.
Think of Send like a sealed envelope. You can drop it in a mailbox and send it to a different office. Once it's mailed, you don't have it anymore. The other office has the only copy. That's moving ownership. Think of Sync like a read-only whiteboard in a hallway. Ten people can walk by and read it simultaneously. No one can erase it while someone else is reading. That's shared immutable access. If you want to write on the whiteboard, you need a key to ensure only one person writes at a time.
Rust enforces this distinction at compile time. You can't accidentally write to the whiteboard without the key.
The auto trait magic
You rarely write impl Send or impl Sync by hand. Rust derives these traits automatically. These are auto traits. If every field in a struct is Send, the struct is Send. If every field is Sync, the struct is Sync. This means you don't annotate types. The compiler infers it.
If you add a Rc to a struct, the struct instantly becomes !Send. Rc uses non-atomic reference counting. Two threads incrementing the count simultaneously would corrupt memory. The compiler rejects the code immediately. You don't hunt for race conditions at runtime. You fix the type composition. The compiler tells you exactly which field broke the trait.
Minimal example: Arc and Mutex
When you need shared mutable state, you combine Arc and Mutex. Arc provides the Sync guarantee by using atomic reference counting. Mutex provides interior mutability, allowing you to mutate data behind a shared reference.
use std::sync::{Arc, Mutex};
use std::thread;
/// Shared counter wrapped for thread safety.
fn main() {
// Arc allows multiple owners across threads.
// Mutex ensures only one thread mutates at a time.
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
// Clone the Arc, not the data. Bumps reference count.
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
// Lock the mutex before accessing the inner value.
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
You'll see Arc::clone(&counter) in idiomatic Rust code. You could write counter.clone(), and it compiles. The community prefers the explicit form because clone() on an Arc looks like it might deep-copy the data, but it only copies the pointer. The explicit call signals intent: I'm sharing ownership, not duplicating the payload.
What happens under the hood
When you call Arc::new, the value lands on the heap. The Arc holds a pointer and a reference count. Arc::clone increments that count atomically. thread::spawn takes ownership of the closure. The move keyword forces the closure to capture counter by value. Since counter is an Arc, the closure gets its own handle to the shared data.
Inside the thread, lock() acquires the mutex. If another thread holds the lock, this thread blocks. Once the lock is acquired, you get a guard that dereferences to the inner value. When the guard drops, the mutex unlocks. When all threads finish and all Arc handles drop, the count hits zero, and the memory frees. No leaks. No races.
Realistic example: shared game state
Real code involves structs with multiple fields. The compiler checks the whole struct for Send and Sync.
use std::sync::{Arc, Mutex};
use std::thread;
/// Game state that tracks score and active players.
struct GameState {
score: u64,
active_players: Vec<String>,
}
impl GameState {
fn new() -> Self {
GameState {
score: 0,
active_players: vec![],
}
}
fn add_score(&mut self, amount: u64) {
self.score += amount;
}
fn join_game(&mut self, name: String) {
self.active_players.push(name);
}
}
fn main() {
let state = Arc::new(Mutex::new(GameState::new()));
let mut handles = vec![];
// Simulate player joining
let state_clone = Arc::clone(&state);
let handle = thread::spawn(move || {
let mut s = state_clone.lock().unwrap();
s.join_game("Alice".to_string());
});
handles.push(handle);
// Simulate scoring
let state_clone = Arc::clone(&state);
let handle = thread::spawn(move || {
let mut s = state_clone.lock().unwrap();
s.add_score(100);
});
handles.push(handle);
for h in handles {
h.join().unwrap();
}
let final_state = state.lock().unwrap();
println!("Score: {}, Players: {:?}", final_state.score, final_state.active_players);
}
GameState contains u64 and Vec<String>. Both are Send and Sync. The compiler derives Send and Sync for GameState automatically. If you added a Rc field, the compiler would reject the code with E0277 (trait bound not satisfied). The error message points to the Rc field and explains that Rc is not Send. You swap Rc for Arc, and the code compiles.
Pitfalls and compiler errors
Rust prevents data races, but it doesn't prevent deadlocks. If thread A holds lock X and waits for lock Y, while thread B holds lock Y and waits for lock X, the program hangs. The compiler can't catch this. You have to reason about lock ordering. Keep lock scopes small. Avoid holding multiple locks simultaneously.
The lock() method returns a Result. The example code calls unwrap(), which panics if the mutex is poisoned. A poisoned mutex means a previous thread panicked while holding the lock. In production code, handle the PoisonError instead of unwrapping. You can recover the guard using into_inner(), or you can accept that the state is corrupted and the process should restart.
Under the hood, interior mutability types like Mutex and Cell wrap UnsafeCell. UnsafeCell is the only way to get mutable access through a shared reference. It is !Sync by default. Mutex wraps UnsafeCell and implements Sync by ensuring atomic operations on the lock state. Cell wraps UnsafeCell but doesn't add synchronization, so it remains !Sync. This design keeps the safety guarantees localized. You can build your own thread-safe types by wrapping UnsafeCell and proving safety in unsafe blocks, but you should rarely need to.
How this differs from C++ and Java
C++ leaves thread safety to you. You can pass a raw pointer to a thread, and the compiler won't stop you. If you forget a lock, you get a data race, which is undefined behavior. The program might crash, or it might silently corrupt memory. Java uses a garbage collector and synchronized blocks. The GC helps manage memory, but synchronized is a runtime check. You can still get deadlocks or race conditions if you mix synchronized with non-thread-safe collections.
Rust moves the checks to compile time. If you can't prove the data is safe, the code doesn't compile. You pay the cost upfront, not at runtime. There's no garbage collector pausing your threads. There's no hidden overhead for safety checks. The safety is baked into the type system.
When to use what
Use Arc<Mutex<T>> when multiple threads need to read and write shared state, and you can tolerate the overhead of locking. Use Arc<RwLock<T>> when reads vastly outnumber writes, allowing concurrent readers without blocking each other. Use channels when threads communicate by sending messages rather than sharing mutable state; this often simplifies reasoning about data flow. The standard library provides std::sync::mpsc, but the community often prefers crossbeam-channel for better performance and multi-producer support. Use thread-local storage when each thread needs its own independent copy of data, avoiding synchronization entirely. Use Atomic types for simple counters or flags where you need lock-free performance and the operation is a single read-modify-write step.
Reach for channels when you can; shared state is the harder path.