#160 May 24, 2026

160. Arc<T> — Atomic Reference Counting for Threads, Plus Mutex and RwLock

Rc<T> gave us shared ownership inside one thread. The moment you thread::spawn it, the compiler refuses. Arc<T> is the same shape with an atomic counter: same clone-the-pointer ergonomics, but Send + Sync and safe to share between threads — and the building block under almost every concurrent pattern in Rust.

The pain: Rc stops at the thread boundary

Rc’s counter is a plain usize. Two threads incrementing it at the same time could read, write, and lose updates — and a lost increment would free a still-referenced allocation. So Rc<T> is deliberately !Send, and the compiler stops you before you can try:

1
2
3
4
5
6
use std::rc::Rc;
use std::thread;

let cfg = Rc::new(String::from("app"));
thread::spawn(move || println!("{cfg}"));
// error[E0277]: `Rc<String>` cannot be sent between threads safely

Arc<T>: same API, atomic counter

Arc is Rc’s sibling with an atomic strong-count. Same new, same clone, same Deref<Target = T>, same “read-only through the pointer.” The cost is a fetch_add on every clone and a fetch_sub on every drop — measurable in tight benchmarks, free everywhere else:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::sync::Arc;
use std::thread;

let shared = Arc::new(vec![1, 2, 3, 4, 5]);

let mut handles = vec![];
for i in 0..3 {
    let view = Arc::clone(&shared);
    handles.push(thread::spawn(move || {
        let sum: i32 = view.iter().sum();
        (i, sum)
    }));
}

let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert_eq!(results.len(), 3);
assert!(results.iter().all(|(_, s)| *s == 15));

Every spawned thread gets its own Arc clone, bumps the strong-count on the way in, and decrements on the way out. The Vec is dropped exactly once — when the last thread (or the main one) lets go of the final clone.

The Arc::clone(&x) convention is even more important here than for Rc: at a glance you want to know that the closure captured a counter bump, not a 200MB deep copy of the value inside.

Read-only is still the default — wrap for mutation

Just like Rc, you only get &T through an Arc<T>:

1
2
3
4
5
use std::sync::Arc;
let a = Arc::new(String::from("app"));
let b = Arc::clone(&a);
a.push_str("-prod");
// error: cannot borrow data in an `Arc` as mutable

That’s the right default — two threads with &mut to the same value would be an instant data race. To mutate, you compose Arc with a lock. The wrapper choice mirrors the morning’s tradeoff: RefCell would have given us runtime borrow checking on one thread; across threads we need synchronization the OS understands.

Arc<Mutex<T>>: the workhorse of shared mutable state

Pair an Arc with a Mutex and you get the most common concurrent pattern in Rust: many threads, one allocation, exclusive write access at a time.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0_u64));

let mut handles = vec![];
for _ in 0..8 {
    let c = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        for _ in 0..1_000 {
            *c.lock().unwrap() += 1;
        }
    }));
}
for h in handles { h.join().unwrap(); }

assert_eq!(*counter.lock().unwrap(), 8_000);

The Arc is what each thread owns; the Mutex is what makes the increment safe. Drop either half and the code doesn’t compile: without the Arc, the Mutex can’t be moved into eight closures at once; without the Mutex, you can’t get &mut u64 from &Mutex<u64> at all.

Arc<RwLock<T>>: when reads dominate

When the value is read far more often than it’s written — a config, a cache, a routing table — swap the Mutex for an RwLock. Many readers can hold a shared lock at the same time; writers still take it exclusively.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::sync::{Arc, RwLock};
use std::thread;

let config = Arc::new(RwLock::new(String::from("v1")));

let mut readers = vec![];
for _ in 0..4 {
    let c = Arc::clone(&config);
    readers.push(thread::spawn(move || {
        let g = c.read().unwrap();
        g.len()
    }));
}

{
    let mut w = config.write().unwrap();
    *w = String::from("v2-prod");
}

let lens: Vec<_> = readers.into_iter().map(|h| h.join().unwrap()).collect();
assert!(lens.iter().all(|&n| n == 2 || n == 7));
assert_eq!(*config.read().unwrap(), "v2-prod");

Arc<Mutex<T>> vs Arc<RwLock<T>> is one of those daily judgment calls: if write contention is high, Mutex is often faster because RwLock has more bookkeeping; if reads are ≥10× writes, RwLock lets the readers actually run in parallel.

The catch: Arc<T> doesn’t make T thread-safe

Arc::new(x) only requires T: Sync to be Send. If you wrap a Cell<u32> (which is !Sync) in an Arc, the type system catches you trying to share it across threads — there’s no magic; the wrappers compose because their Send/Sync bounds compose.

1
2
3
4
5
6
7
8
use std::sync::Arc;
use std::cell::Cell;
use std::thread;

let a = Arc::new(Cell::new(0));
let b = Arc::clone(&a);
thread::spawn(move || b.set(1));
// error: `Cell<i32>` cannot be shared between threads safely

That’s why Arc<Mutex<T>> and Arc<RwLock<T>> are the workhorses: the inner type provides the Sync, the Arc provides the Send.

When not to reach for Arc

Arc is the right tool for genuine shared ownership across threads, but it isn’t the only way to move data across one. If a thread just needs to borrow something for a bounded scope, scoped threads let you hand out plain &T without an Arc clone at all. If a value only needs to move from producer to consumer, an mpsc channel transfers ownership directly. Use Arc when more than one thread genuinely owns the same allocation at the same time — not as the default smart pointer for “I have a value and threads.”

← Previous 159. Rc<T> — Single-Threaded Shared Ownership Next → 161. Weak<T> — The Non-Owning Pointer That Breaks Rc Cycles