When one thread isn't enough
You are building a web scraper. You have ten worker threads fetching URLs. They all need to update a shared progress counter. You try to pass a Vec or a HashMap to the threads. The compiler screams. You cannot move the data into ten places at once. You need a way to say, "This data lives on the heap, and everyone gets a ticket to read it. When the last ticket is gone, delete the data."
That is what Arc<T> is for. The name stands for Atomic Reference Counted. It is the thread-safe cousin of Rc<T>. It lets multiple threads share ownership of the same heap allocation. The data stays alive as long as at least one thread holds a handle. When the last handle drops, the memory is freed automatically.
The ticket stub analogy
Think of Arc<T> like a concert with ticket stubs. The stage is the data on the heap. Every person holding a ticket stub has permission to look at the stage. When someone leaves, they drop their stub. The venue only tears down the stage when the last stub is collected.
Arc makes sure that counting stubs works correctly even if a thousand people drop theirs at the exact same nanosecond. That is the "Atomic" part. The counter uses hardware instructions that guarantee the count never gets corrupted, even under heavy concurrent pressure. You do not need to write locks to manage the lifetime. The counter handles it.
Minimal example
Here is the basic pattern. You create the Arc, clone the handle for each thread, and move the handle into the thread closure.
use std::sync::Arc;
use std::thread;
/// Demonstrates sharing a vector across threads using Arc.
fn main() {
// Create the Arc on the heap. The reference counter starts at 1.
let data = Arc::new(vec![1, 2, 3]);
// Clone the Arc handle, not the vector. The counter bumps to 2.
// Convention: use Arc::clone(&data) to signal this is a shallow clone.
let handle_for_thread = Arc::clone(&data);
// Spawn a thread that takes ownership of the cloned handle.
let handle = thread::spawn(move || {
// Access the data through the handle.
// The thread owns this handle, so it can read the data safely.
println!("Thread sees: {:?}", handle);
});
// Main thread still has its handle. The counter is still 2.
// Both threads can read the data concurrently.
println!("Main sees: {:?}", data);
// Wait for the thread to finish so the counter can drop.
// If we exit early, the thread might panic or be aborted.
handle.join().unwrap();
}
Convention aside: write Arc::clone(&data) instead of data.clone(). Both compile and both work. The explicit form signals to readers that you are cloning the reference count, not deep-copying the data. It prevents confusion when someone scans the code for expensive operations.
What happens under the hood
When you call Arc::new, Rust allocates memory on the heap for your data and a reference counter. The counter is an atomic integer. Arc::clone reads the current counter, adds one using an atomic instruction, and returns a new pointer to the same memory. No data copying happens. The vector, the string, the struct stays put. Only the pointer and the counter change.
When a thread finishes, its Arc goes out of scope. The destructor runs. It decrements the atomic counter. If the counter hits zero, the memory is freed. If not, the data stays alive for the other threads. The atomic operations ensure that two threads dropping their handles at the same instant do not race and leak memory. The hardware guarantees the decrement is safe.
The counter is the single source of truth. Trust the counter.
Realistic example: Shared configuration
In real code, you often share configuration or read-only resources. Here is a worker pattern where threads share a config struct.
use std::sync::Arc;
use std::thread;
/// Configuration shared across worker threads.
/// Derive Debug for logging. Clone is not needed on the struct itself.
#[derive(Debug)]
struct Config {
max_retries: u32,
timeout_ms: u64,
}
/// Worker function that reads shared config.
/// Takes ownership of the Arc handle to satisfy the thread closure.
fn worker(id: u32, config: Arc<Config>) {
// Read config fields. No locking needed for immutable access.
// Multiple threads can read the same Arc simultaneously.
println!(
"Worker {} starting with retries={}, timeout={}ms",
id, config.max_retries, config.timeout_ms
);
}
fn main() {
// Create config and wrap in Arc.
// The config is immutable, so Arc is sufficient.
let config = Arc::new(Config {
max_retries: 3,
timeout_ms: 500,
});
let mut handles = vec![];
// Spawn multiple workers.
for id in 0..4 {
// Clone the Arc handle for each thread.
// This bumps the counter. The config stays alive for all workers.
let config_clone = Arc::clone(&config);
let handle = thread::spawn(move || {
worker(id, config_clone);
});
handles.push(handle);
}
// Join all threads.
// This ensures all workers finish before main exits.
for handle in handles {
handle.join().unwrap();
}
}
Immutable data is cheap to share. Mutable data requires a lock. Keep data immutable as long as you can.
Thread safety constraints: Send and Sync
Arc enforces thread safety at the type level. You can only wrap types that implement Send and Sync. Send means the value can be transferred to another thread. Sync means the value can be accessed from multiple threads simultaneously via references.
If your struct contains a Cell or an Rc, the compiler rejects the Arc::new call with E0277 (trait bound not satisfied). Those types are not thread-safe. Cell allows interior mutation without synchronization. Rc uses a non-atomic counter. If you try to share them across threads, you risk data races.
Replace Cell with AtomicUsize or Mutex. Replace Rc with Arc. The compiler forces you to pick thread-safe primitives. If your type isn't Send or Sync, Arc won't compile. Fix the type, don't fight the wrapper.
The cost of atomics
Every Arc::clone and drop performs an atomic operation. Atomic operations force the CPU to synchronize cache lines between cores. This is slower than a plain integer increment. On x86, an atomic increment might involve a lock prefix that stalls the pipeline while the cache coherency protocol runs.
If you are cloning an Arc millions of times per second in a single thread, you are paying for thread safety you do not need. The overhead adds up. Use Rc for single-threaded shared ownership. Rc uses a plain integer counter and is significantly faster when threads are not involved. Use Arc only when threads are involved.
Arc is not a free lunch. Pay the atomic cost only when threads cross paths.
Breaking cycles with Weak
Arc does not detect cycles. If two Arcs point to each other, the counter never hits zero. The memory leaks forever. This happens in graphs, trees with parent pointers, or caches that hold references back to their owners.
Arc provides Weak references to break cycles. Weak points to the data but does not keep it alive. It does not increment the strong reference count. Call Arc::downgrade to get a Weak. Call Weak::upgrade to get an Option<Arc<T>>. If the data is gone, upgrade returns None.
Use Weak for parent pointers in trees. Use Weak for caches that should not prevent eviction. The child holds a strong Arc to the data. The parent holds a Weak back to the child. When the child is dropped, the parent's weak reference becomes invalid. The cycle breaks.
Strong references keep data alive. Weak references observe it. Use Weak to prevent leaks in graphs.
Pitfalls and compiler errors
If you pass data directly into thread::spawn without cloning, the compiler rejects you with E0382 (use of moved value). The thread takes ownership of the Arc. Main loses access. Clone the handle first.
Trying to modify data through an Arc fails. Arc guarantees other threads see consistent data. If you allowed mutation, you would have data races. The compiler blocks this with E0596 (cannot borrow as mutable). Wrap the data in a Mutex or RwLock if you need to write. Arc<Mutex<T>> is the standard pattern for shared mutable state.
Holding a lock too long causes deadlocks. Arc prevents data races, not logic bugs. If you hold a lock while waiting for another lock, your program stalls. The compiler will not save you from deadlocks.
Arc gives you shared ownership, not magic. You still need to manage lifetimes and locks.
Decision matrix
Use Arc<T> when you need to share immutable data across multiple threads and the data lives as long as any thread needs it.
Use Arc<Mutex<T>> when you need to share mutable state across threads and only one thread can write at a time.
Use Arc<RwLock<T>> when you have many readers and few writers, and you want readers to proceed concurrently.
Use Rc<T> when you need shared ownership but all access happens on a single thread; Arc has atomic overhead that Rc avoids.
Use plain references (&T) when the data lives longer than the threads or is owned by the main scope and threads only borrow it temporarily.
Match the tool to the access pattern. Over-engineering shared state kills performance.