#202 Jun 14, 2026

202. Arc::clone Is a Refcount Bump, Not a Deep Copy — Share Big Data, Don't Duplicate It

big.clone() on a 50MB lookup table allocates 50MB every time a worker needs a copy. Wrap it in an Arc once and Arc::clone is just an atomic +1 on a counter — every owner reads the same bytes.

This closes out performance week, the afternoon pair to the morning’s entry() bite: that one was about not building a default you’ll throw away, this one is about not copying a payload you only ever read.

The trap: .clone() deep-copies the payload

When several owners each need “their own” handle to a large immutable value, the obvious move is to clone it. But Clone on a Vec/String/HashMap walks the data and allocates a fresh copy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let table: Vec<u64> = (0..1_000_000).collect();

// Four "owners" — four full heap allocations, ~32MB copied.
let a = table.clone();
let b = table.clone();
let c = table.clone();

assert_eq!(a.len(), 1_000_000);
assert_eq!(b[500_000], 500_000);
assert_eq!(c.last(), Some(&999_999));

Nobody mutates the data — they just read it — yet you paid for four independent copies. That’s pure waste.

The fix: one allocation, shared by reference count

Put the value behind an Arc<T> once. Now Arc::clone doesn’t touch the payload at all — it bumps an atomic reference count and hands back another pointer to the same allocation:

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

let table: Arc<Vec<u64>> = Arc::new((0..1_000_000).collect());

let a = Arc::clone(&table);
let b = Arc::clone(&table);
let c = Arc::clone(&table);

// All four point at the SAME bytes — no data was copied.
assert_eq!(Arc::strong_count(&table), 4);
assert_eq!(a[500_000], 500_000);

// Proof it's one allocation, not four:
assert!(Arc::ptr_eq(&a, &b));
assert!(Arc::ptr_eq(&b, &c));

The Arc::clone(&x) spelling (rather than x.clone()) is a convention worth keeping: at the call site it reads as “bump the counter,” so a reviewer knows a cheap pointer copy happened, not a 32MB memcpy.

This is what makes it cheap to send to threads

The same property is why Arc is the building block for sharing immutable data across threads — each thread gets a counted handle, all reading one copy:

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

let config: Arc<Vec<u64>> = Arc::new((0..1_000).collect());

let handles: Vec<_> = (0..4)
    .map(|_| {
        let c = Arc::clone(&config); // refcount bump, then moved into the thread
        thread::spawn(move || c.iter().sum::<u64>())
    })
    .collect();

let total: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();
assert_eq!(total, 499_500 * 4);

Four threads, one underlying Vec. Cloning the payload into each thread would have been four allocations; Arc::clone is four counter bumps.

When not to reach for it

Arc shines for data that’s large, shared, and read-only after construction. It’s not free: every clone and drop is an atomic operation, and the payload lives until the last handle goes away. For small Copy types (bite-200) a plain copy is cheaper than the atomic traffic. And if you need to mutate shared state, you want Arc<Mutex<T>> or Arc<RwLock<T>> (bite-160) — or Arc::make_mut (bite-113) for copy-on-write.

The bottom line

If you’re cloning a big value just to hand read-only access to several owners or threads, you’re copying bytes nobody changes. Wrap it in Arc once: every Arc::clone after that is an atomic increment over a shared allocation, not a deep copy.

← Previous 201. or_insert vs or_insert_with — Don't Build a Default You'll Throw Away Next → 203. Peekable::next_if_map — Consume a Token Only If It Parses, Transform in One Step