Rust / Advanced / Lesson 14
Threads, Send, Sync, and shared state
Transfer ownership between threads, protect complete state transitions, and explain exactly what Arc and synchronization traits do and do not guarantee.
On this page
Lesson goals
- Interpret Send and Sync as safety properties of types.
- Use Arc<Mutex<T>> and message passing with explicit shutdown.
- Distinguish race freedom, logical correctness, and deadlock freedom.
Send and Sync describe different transfers
Send means a value can be transferred safely across thread boundaries. Sync means shared references to the type can be sent safely; formally T is Sync when &T is Send. Auto traits are derived from type structure unless special rules apply, and manually implementing them is an unsafe claim requiring proof. Rc uses non-atomic counts and is not the cross-thread shared-owner type. Arc uses atomic reference counting, but Arc<T> is not a blanket permission to access arbitrary T concurrently. The inner type must satisfy the required Send and Sync bounds.
Protect the whole invariant under one lock
Mutex provides exclusive access through a guard whose lifetime controls unlocking. Arc shares ownership of that mutex across worker threads. The first example performs two hundred increments, then joins every worker before reading the final state. Scheduling may differ, but the final count is deterministic because each increment is protected. unwrap here documents expected absence of worker panic and poisoning in this closed demonstration; a real service must choose a poisoning and worker-failure policy. Never hold the lock while calling unknown callbacks or performing long blocking work unless that behavior is explicitly required.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let count = Arc::new(Mutex::new(0u32));
let mut workers = Vec::new();
for _ in 0..2 {
let count = Arc::clone(&count);
workers.push(thread::spawn(move || {
for _ in 0..100 { *count.lock().unwrap() += 1; }
}));
}
for worker in workers { worker.join().unwrap(); }
println!("{}", *count.lock().unwrap());
}
200
Each worker contributes one hundred protected increments. Joining precedes the final guarded read.
Move messages instead of sharing mutable records
Channels can transfer owned messages from producers to a receiver. A move closure owns the sender handle it captures, and sending a String transfers the message value into the channel. The second program drops the original sender so iteration ends when worker senders are gone. Two producers can arrive in either order, so the receiver sorts the owned messages before printing a deterministic report. Closing a channel is part of shutdown design, not an incidental detail. An unnoticed retained sender can keep a receiver waiting even after all intended production is finished.
use std::sync::mpsc;
use std::thread;
fn main() {
let (sender, receiver) = mpsc::channel();
let mut workers = Vec::new();
for name in ["south", "north"] {
let sender = sender.clone();
workers.push(thread::spawn(move || sender.send(String::from(name)).unwrap()));
}
drop(sender);
let mut messages: Vec<String> = receiver.into_iter().collect();
for worker in workers { worker.join().unwrap(); }
messages.sort();
println!("{:?}", messages);
}
["north", "south"]
The channel owns transferred strings. Sorting removes arrival-order dependence from the printed result.
Choose lifetimes and atomic scope precisely
thread::spawn usually requires captured data and the closure to meet static lifetime bounds because the worker may outlive its caller. Scoped threads can borrow non-static data when their scope guarantees completion before those borrows expire. Atomics support specific indivisible operations, but two atomic fields do not automatically preserve a joint invariant. A load followed by store is not equivalent to an atomic fetch_add. Start with a mutex for compound state and introduce weaker atomic memory orderings only with a written publication and lifetime argument. Thread-safe reference counting does not synchronize ordinary inner mutation.
Memory safety is only one concurrency goal
Safe Rust prevents data races when unsafe dependencies uphold their contracts, but deadlocks, starvation, lost business updates, and accidental nondeterminism can still occur. Define lock ordering, bounded queues, cancellation, and joining behavior. Avoid retaining guards across blocking receives or later async suspension without a justified protocol. Test shutdown with no messages and with workers failing, not just the happy path. Structured logs and deterministic aggregation help diagnosis without promising a fixed interleaving. Unsafe synchronization implementations require separate invariant review; the compiler cannot validate a false manual Send or Sync implementation for you.
Common errors and fixes
Arc<RefCell<T>> is expected to enable thread sharing.
- Why it happens
- Atomic ownership was mistaken for synchronized inner access.
- How to fix it
- Use a suitable synchronized inner type or message ownership.
- Prevent it next time
- Check inner Send and Sync requirements.
The receiver waits forever after workers finish.
- Why it happens
- An extra sender remains alive.
- How to fix it
- Drop unused senders and define channel closure ownership.
- Prevent it next time
- Test empty-work shutdown explicitly.
Two individually atomic fields violate a joint rule.
- Why it happens
- The complete transition was not synchronized.
- How to fix it
- Protect related fields under one invariant-preserving operation.
- Prevent it next time
- Review logical transactions separately from data-race freedom.
Check your understanding
1. Remove sorting from the channel example. What output property can still be guaranteed, and which property must no longer be promised?
Show worked solution
The successful run receives one north and one south String, but their order can depend on scheduling. Either ordering is valid, so a test should compare a set or sort before asserting a line. This is ordinary nondeterminism in a synchronized program, not evidence of a data race. Keep the sender-drop and join protocol unchanged.
2. A worker only reads a local vector during a bounded operation. Compare cloning it into thread::spawn with borrowing it through scoped threads.
Show worked solution
Cloning creates independent owned data with copy and allocation cost. Scoped threads can borrow the vector when the enclosing scope guarantees all workers finish before it is dropped. The borrow must remain compatible with any other accesses, and worker failures still need handling. Choose ownership from the real lifetime requirement rather than forcing static by leaking memory.
Key takeaways
- Arc shares ownership; inner types determine safe access.
- Synchronize full invariants and define shutdown.
- Race freedom does not prove liveness or business correctness.