Arc

#160 May 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.”

#113 May 2026

113. Arc::make_mut — Mutate Inside an Arc Without the Dance

You have an Arc<T>, you want a &mut T. Arc only hands out &T, so the usual workaround is clone-the-inner, mutate, rewrap. Arc::make_mut does that for you — and skips the clone when no one else is watching.

The manual version everyone writes once and then copies forever:

1
2
3
4
5
6
7
8
9
use std::sync::Arc;

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

let mut owned: Vec<i32> = (*shared).clone(); // always clones
owned.push(4);
shared = Arc::new(owned);                    // always reallocates the Arc

assert_eq!(*shared, vec![1, 2, 3, 4]);

It works, but it clones the Vec and reallocates the Arc every single time — even when this Arc is the only one pointing at the data.

Arc::make_mut takes &mut Arc<T> and hands you &mut T:

1
2
3
4
5
6
7
use std::sync::Arc;

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

Arc::make_mut(&mut shared).push(4);

assert_eq!(*shared, vec![1, 2, 3, 4]);

One call, one borrow, and — crucially — no clone when this Arc is unique:

1
2
3
4
5
use std::sync::Arc;

let mut solo = Arc::new(vec![1, 2, 3]);
Arc::make_mut(&mut solo).push(99); // strong_count == 1, mutates in place
assert_eq!(*solo, vec![1, 2, 3, 99]);

When the Arc is shared, make_mut quietly clones the inner value into a fresh allocation and detaches your handle from the rest. The other handles keep seeing the old data — clone-on-write, exactly like you’d want:

1
2
3
4
5
6
7
8
9
use std::sync::Arc;

let mut a = Arc::new(vec![1, 2, 3]);
let b = Arc::clone(&a);            // strong_count == 2

Arc::make_mut(&mut a).push(99);    // clones, then mutates the clone

assert_eq!(*a, vec![1, 2, 3, 99]); // a moved to its own allocation
assert_eq!(*b, vec![1, 2, 3]);     // b still sees the original

The same method exists on Rc for single-threaded code, with identical semantics. Reach for make_mut whenever you find yourself cloning the inside of an Arc just to change one field — you’ll skip the allocation in the common case and get an honest &mut T in return.

83. Arc::unwrap_or_clone — Take Ownership Without the Dance

You need to own a T but all you have is an Arc<T>. The old pattern is a six-line fumble with try_unwrap. Arc::unwrap_or_clone collapses it into one call — and skips the clone entirely when it can.

The old dance

Arc::try_unwrap hands you the inner value — but only if you’re the last reference. Otherwise it gives your Arc back, and you have to clone.

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

let arc = Arc::new(String::from("hello"));
let owned: String = match Arc::try_unwrap(arc) {
    Ok(inner) => inner,
    Err(still_shared) => (*still_shared).clone(),
};
assert_eq!(owned, "hello");

Every place that wanted an owned T from an Arc<T> wrote this same pattern, often subtly wrong.

The fix: unwrap_or_clone

Stabilized in Rust 1.76, Arc::unwrap_or_clone does exactly the right thing: move the inner value out if we’re the last owner, clone it otherwise.

1
2
3
4
5
use std::sync::Arc;

let arc = Arc::new(String::from("hello"));
let owned: String = Arc::unwrap_or_clone(arc);
assert_eq!(owned, "hello");

One call. No match. No deref gymnastics.

It actually skips the clone

The key win isn’t just ergonomics — it’s performance. When the refcount is 1, no clone happens; the T is moved out of the allocation directly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::sync::Arc;

let solo = Arc::new(vec![1, 2, 3, 4, 5]);
let v: Vec<i32> = Arc::unwrap_or_clone(solo); // no allocation, just a move
assert_eq!(v, [1, 2, 3, 4, 5]);

let shared = Arc::new(vec![1, 2, 3]);
let _other = Arc::clone(&shared);
let v2: Vec<i32> = Arc::unwrap_or_clone(shared); // clones, because _other still holds a ref
assert_eq!(v2, [1, 2, 3]);

Also on Rc

The same method exists on Rc for single-threaded code — identical semantics, identical ergonomics:

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

let rc = Rc::new(42);
let n: i32 = Rc::unwrap_or_clone(rc);
assert_eq!(n, 42);

Anywhere you were reaching for try_unwrap().unwrap_or_else(|a| (*a).clone()), reach for unwrap_or_clone instead. Shorter, clearer, and it avoids the clone when it can.