Learn Rust Series (#34) - Arc: Thread-Safe Reference Counting & Its Cost
Learn Rust Series (#34) - Arc: Thread-Safe Reference Counting & Its Cost
What will I learn
- You will learn how
Arc<T>differs fromRc<T>: identical shape and methods, but the reference counts are updated with atomic operations; - what those atomic operations actually cost, and why you should not reach for
Arcby default; - how
Arc<Mutex<T>>andArc<RwLock<T>>give you shared, mutable state across threads; - when
Arcalone is enough, for shared read-only data, and howArc<AtomicUsize>handles a shared counter without a lock; - a clear, mechanical rule for choosing between
RcandArc.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Rust toolchain (via rustup, from rustup.rs);
- The previous thirty-three episodes, especially the smart pointers primer (episode 12), the concurrency primer (episode 13), the marker traits
Send/Sync(episode 25), andRcinternals (episode 33); - The ambition to learn systems programming from the ground up.
Difficulty
- Beginner
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums
- Learn Rust Series (#6) - Error Handling
- Learn Rust Series (#7) - Collections
- Learn Rust Series (#8) - Traits & Generics
- Learn Rust Series (#9) - Modules & Crates
- Learn Rust Series (#10) - Lifetimes
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
- Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App
- Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch
- Learn Rust Series (#16) - Static vs Dynamic Dispatch
- Learn Rust Series (#17) - Associated Types vs Generic Parameters
- Learn Rust Series (#18) - Operator Overloading with std::ops
- Learn Rust Series (#19) - Deref, DerefMut & Deref Coercion
- Learn Rust Series (#20) - Drop & Deterministic Destruction (RAII)
- Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions
- Learn Rust Series (#22) - Deriving Common Traits
- Learn Rust Series (#23) - The Orphan Rule & Trait Coherence
- Learn Rust Series (#24) - Blanket Implementations & the Newtype Pattern
- Learn Rust Series (#25) - Marker Traits: Sized, Send, Sync & Copy
- Learn Rust Series (#26) - Const Generics: Types That Depend on Values
- Learn Rust Series (#27) - Generic Associated Types & Lending Iterators
- Learn Rust Series (#28) - Sealed Traits & Designing Stable APIs
- Learn Rust Series (#29) - Typestate Programming: State Machines in the Type System
- Learn Rust Series (#30) - Mini Project: A Generic Units-of-Measure Library
- Learn Rust Series (#31) - Move Semantics Deep Dive
- Learn Rust Series (#32) - Interior Mutability: Cell & RefCell
- Learn Rust Series (#33) - Rc Internals: Reference Counting & Shared Ownership
- Learn Rust Series (#34) - Arc: Thread-Safe Reference Counting & Its Cost (this post)
Learn Rust Series (#34) - Arc: Thread-Safe Reference Counting & Its Cost
Last episode we pried Rc open and looked at the machinery inside: one heap allocation holding a strong count, a weak count, and your value, with the counts moving up on clone and down on drop. We ended on a cliffhanger -- Rc is strictly single-threaded, because its counts are plain, non-atomic integers, and the compiler flatly refuses to let one cross a thread boundary. Today we meet the type that lifts exactly that restriction: Arc<T>, "atomically reference counted", Rc's thread-safe twin.
Here is the good news up front. Structurally, Arc is the same as Rc: one allocation, the same strong/weak counts, the same methods (clone, strong_count, ptr_eq, get_mut, try_unwrap). The only difference is that the counts are updated with atomic instructions in stead of ordinary ones, and that single change is what makes Arc safe to share across threads -- and slightly more expensive to clone. That trade, when it is worth paying and when it is not, is the whole story of this episode. But first, as always, let me clear last episode's homework ;-)
Solutions to Episode 33 Exercises
Episode 33 was Rc internals. There were three exercises, and here is full runnable code for each.
Exercise 1 asked you to create an Rc<String>, clone it once at the top level and once more inside an inner { } block, and print Rc::strong_count at four points -- before any clone, after the first clone, inside the block after the second clone, and after the block ends -- confirming the counts read 1, 2, 3, 2:
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("shared"));
println!("{}", Rc::strong_count(&a)); // 1: only a
let b = Rc::clone(&a);
println!("{}", Rc::strong_count(&a)); // 2: a and b
{
let c = Rc::clone(&a);
println!("{}", Rc::strong_count(&a)); // 3: a, b, and c
let _ = &c;
} // c drops here, count falls back to 2
println!("{}", Rc::strong_count(&a)); // 2: a and b again
let _ = &b;
}
The count is nothing more than "how many live handles point at this allocation right now". Each Rc::clone bumps it by one; each handle going out of scope drops it by one. The inner { } block scopes c so it is destroyed at the closing brace, which is exactly why the count climbs to three and then settles back to two.
Exercise 2 wanted a small three-level tree built from Rc<Node> where Node { value: i32, children: RefCell<Vec<Rc<Node>>> }, plus a recursive function that sums every value in the tree:
use std::rc::Rc;
use std::cell::RefCell;
struct Node {
value: i32,
children: RefCell<Vec<Rc<Node>>>,
}
fn sum(node: &Rc<Node>) -> i32 {
// this node's value, plus the sum of every child's subtree
node.value + node.children.borrow().iter().map(|c| sum(c)).sum::<i32>()
}
fn main() {
let leaf_a = Rc::new(Node { value: 4, children: RefCell::new(vec![]) });
let leaf_b = Rc::new(Node { value: 5, children: RefCell::new(vec![]) });
let middle = Rc::new(Node { value: 2, children: RefCell::new(vec![leaf_a, leaf_b]) });
let root = Rc::new(Node { value: 1, children: RefCell::new(vec![Rc::clone(&middle)]) });
println!("{}", sum(&root)); // 1 + 2 + 4 + 5 = 12
}
The Rc<RefCell<Vec<...>>> shape from last episode is doing the heavy lifting: Rc gives each node shared ownership so a child can be pointed at from more than one place, and the RefCell around children would let us mutate the child list through a shared handle. The recursion just walks the borrow of each children vector and adds everything up.
Exercise 3 asked you to take a uniquely-owned Rc<i32>, mutate it in place with Rc::get_mut, then clone it and confirm that Rc::get_mut now returns None while Rc::ptr_eq reports the original and the clone as the same allocation:
use std::rc::Rc;
fn main() {
let mut a = Rc::new(10);
if let Some(v) = Rc::get_mut(&mut a) {
*v += 5; // allowed: a is currently the sole owner
}
println!("{a}"); // 15
let b = Rc::clone(&a); // now there are two owners
println!("{}", Rc::get_mut(&mut a).is_none()); // true: mutation refused
println!("{}", Rc::ptr_eq(&a, &b)); // true: same allocation
}
get_mut hands you a &mut into the inner value only while the strong count is exactly one -- the moment a second owner exists, it returns None, because handing out an exclusive reference while another handle is watching would break the aliasing rule. ptr_eq then confirms a and b are two handles into one allocation, not two separate 10s. Right, homework cleared -- now let us make reference counting thread-safe ;-)
Arc for shared read-only data
The simplest use of Arc is handing the same immutable value to several threads at once. Because Arc<T> is Send and Sync whenever T is (those marker traits from episode 25), every thread can own its own clone of the pointer and read through it safely:
use std::sync::Arc;
use std::thread;
fn main() {
let config = Arc::new(vec!["node1", "node2", "node3"]);
let mut handles = Vec::new();
for id in 0..3 {
let cfg = Arc::clone(&config); // atomic increment, then moved into the thread
handles.push(thread::spawn(move || {
println!("thread {id} sees {} nodes", cfg.len());
}));
}
for h in handles {
h.join().unwrap();
}
println!("owners left: {}", Arc::strong_count(&config)); // 1
}
Notice the pattern that recurs in almost every threaded Rust program: clone the Arc outside the closure, into a fresh binding, then move that binding into the thread. Each thread gets its own handle onto the one shared Vec, nothing is copied per thread, and when every thread has finished and its handle has dropped, the strong count falls all the way back to one. This is the go-to shape for sharing configuration, a lookup table, or any read-only data across a thread pool.
And exactly like Rc, the atomic strong count rises with each clone and falls as clones drop -- you just cannot see the atomicity, only its effect:
use std::sync::Arc;
fn main() {
let a = Arc::new(42);
println!("{}", Arc::strong_count(&a)); // 1
let b = Arc::clone(&a);
println!("{}", Arc::strong_count(&a)); // 2
drop(b);
println!("{}", Arc::strong_count(&a)); // 1
}
If you put this next to the Rc version from last episode, you would find them character-for-character identical apart from rc::Rc versus sync::Arc. That is deliberate -- switching from one to the other is meant to be a near-mechanical change, which we will lean on at the end.
Arc plus Mutex for shared mutation
Arc alone gives shared, read-only access -- Arc<T> derefs to &T, never &mut T, for the exact same reason Rc does: with several owners in play there is no single one entitled to mutate. To mutate shared data across threads you add a lock, giving Arc<Mutex<T>>, the thread-safe analogue of the Rc<RefCell<T>> we built last episode:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let total = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..5 {
let t = Arc::clone(&total);
handles.push(thread::spawn(move || {
let mut guard = t.lock().unwrap(); // block until the lock is ours
*guard += 10; // mutate the protected value
})); // guard drops here, unlocking the Mutex
}
for h in handles {
h.join().unwrap();
}
println!("total: {}", *total.lock().unwrap()); // 50
}
Two layers, two jobs, just like before. The Arc gives you shared ownership (five threads, five handles, freed when the last one goes). The Mutex gives you safe mutation: lock() blocks until this thread is the only one inside, hands back a MutexGuard, and that guard unlocks the Mutex the instant it drops. The mirror to the single-threaded world is exact -- where a single-threaded design would reach for Rc<RefCell<T>>, the multi-threaded one reaches for Arc<Mutex<T>>, and the type system routes you to the correct pair automatically.
One difference from RefCell is worth flagging. RefCell panics on a conflicting borrow; a Mutex instead blocks, making the second thread wait its turn. And where RefCell::borrow never fails for a good program, Mutex::lock returns a Result, because a lock can be "poisoned" if a thread panics while holding it. In practice you .unwrap() it, which simply propagates that panic -- a poisoned lock usually means something already went badly wrong.
Arc plus RwLock for many readers
A Mutex serialises everything -- even two threads that only want to read must take turns. When reads vastly outnumber writes, that is wasteful. RwLock fixes it by distinguishing the two: it allows any number of simultaneous readers or one exclusive writer, never both. So Arc<RwLock<T>> fits read-heavy shared state:
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let data = Arc::new(RwLock::new(vec![1, 2, 3]));
// one writer thread appends a value
let writer_handle = {
let w = Arc::clone(&data);
thread::spawn(move || {
let mut guard = w.write().unwrap(); // exclusive write lock
guard.push(4);
}) // write lock released here
};
writer_handle.join().unwrap();
// several reader threads all read concurrently
let mut readers = Vec::new();
for id in 0..3 {
let r = Arc::clone(&data);
readers.push(thread::spawn(move || {
let guard = r.read().unwrap(); // shared read lock
println!("reader {id} sees {} items", guard.len());
}));
}
for h in readers {
h.join().unwrap();
}
println!("final: {:?}", *data.read().unwrap()); // [1, 2, 3, 4]
}
write() demands exclusive access and blocks until every reader and writer has let go; read() grants shared access that many threads can hold at the same time. The rule of thumb: choose RwLock over Mutex only when reads genuinely dominate, because it carries more overhead per operation, and under heavy write contention it can actually end up slower than a plain Mutex. When in doubt, start with Mutex and measure -- do not reach for RwLock on a hunch.
When a lock is overkill: Arc plus an atomic
Sometimes the shared state is just a single number -- a counter, a flag, a running total. Wrapping one integer in a Mutex works, but it is heavier than it needs to be. For these cases the standard library gives you the atomic types (AtomicUsize, AtomicBool, and friends), which perform a whole read-modify-write as one indivisible hardware instruction, no lock required:
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
fn main() {
let counter = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for _ in 0..8 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
c.fetch_add(1, Ordering::Relaxed); // atomic +1, no lock
}
}));
}
for h in handles {
h.join().unwrap();
}
println!("counted: {}", counter.load(Ordering::Relaxed)); // 8000
}
Eight threads each add one, a thousand times, and the total comes out to exactly 8000 -- no lost updates, no lock. The Ordering argument controls how strictly this operation is synchronised against other memory accesses; Relaxed is the cheapest and is perfectly correct for a standalone counter like this, where all we care about is that the additions do not step on each other. Memory ordering is a deep topic in its own right, and we will not go further into it here -- just know that Arc<AtomicUsize> is the idiomatic way to share a plain counter, and it is exactly the same mechanism Arc uses internally for its own reference count.
What "atomic" costs, and why not just always use Arc
That last sentence is the key to this whole episode. An Arc's strong count is an atomic integer, and Arc::clone is essentially a fetch_add on it -- precisely the operation you just saw. So the natural question is: if atomics are this cheap, why not use Arc everywhere and forget Rc exists?
Because "cheap" is not "free". An atomic increment is not a single ordinary add instruction; it involves a read-modify-write that the CPU must guarantee no other core can interleave with, which typically means a locked instruction and a memory fence. On modern hardware that is a handful of nanoseconds, invisible in the vast majority of code -- but in a hot single-threaded loop that clones a handle millions of times, the difference between a plain increment (Rc) and an atomic one (Arc) is measurable and real. You are paying for thread-safety you are not using:
use std::rc::Rc;
use std::sync::Arc;
fn main() {
// single-threaded: Rc's plain, non-atomic increment is the leaner choice
let local = Rc::new(vec![1, 2, 3]);
let _clone = Rc::clone(&local); // ordinary integer increment
// crosses threads: only Arc's atomic count is sound here
let shared = Arc::new(vec![1, 2, 3]);
let _shared_clone = Arc::clone(&shared); // atomic increment + fence
println!("{} {}", local.len(), shared.len());
}
The reassuring part is that you can never get this wrong in the dangerous direction. The compiler will not let you smuggle an Rc across a thread boundary, because Rc is neither Send nor Sync. Try it, and you get a compile error rather than a data race:
use std::rc::Rc;
use std::thread;
fn main() {
let shared = Rc::new(5);
let clone = Rc::clone(&shared);
// This does NOT compile: `Rc` cannot be sent between threads safely,
// because Rc is neither Send nor Sync. Swap Rc for Arc and it compiles.
//
// thread::spawn(move || {
// println!("{}", clone);
// }).join().unwrap();
println!("{} {}", shared, clone);
}
So the only mistake Arc-everywhere protects you from is a performance one, never a soundness one -- Rust already has soundness covered in both directions. Using Arc where Rc would do simply burns cycles for nothing.
Choosing Rc or Arc
The rule is refreshingly mechanical, and it is the whole takeaway: use Rc by default, and switch to Arc only when a value must actually cross a thread boundary. For everything single-threaded -- and most code is -- Rc is the leaner tool. When the data genuinely has to be shared between threads, Arc is the only sound option, and the compiler will tell you the moment you need it by rejecting the Rc.
And because the two types have identical shapes and methods, switching later is close to a find-and-replace: change use std::rc::Rc to use std::sync::Arc, swap the type names, and if the inner data also needs to be mutated across threads, upgrade the RefCell to a Mutex (or RwLock) at the same time. That is genuinely all it takes. Having said that, do not pre-emptively reach for Arc "just in case you go multithreaded later" -- start with Rc, and let the compiler tell you exactly when and where you have outgrown it. You lose nothing either way, since neither choice can ever be unsound.
How Python sees this
Since quite some of you arrived here from the Learn Python Series, a glance sideways is illuminating, because Python's story here is almost the inverse of Rust's. As we noted last episode, CPython reference-counts every object -- and to keep those counts from being corrupted by threads, it wraps the entire interpreter in one big lock, the famous GIL (Global Interpreter Lock). Only one thread runs Python bytecode at a time, which means CPython never has to make individual refcounts atomic, but it also means threads cannot run CPU-bound Python code in parallel at all:
import threading
counter = 0
def work():
global counter
for _ in range(100_000):
counter += 1 # NOT atomic; safe only by luck of the GIL's timing
threads = [threading.Thread(target=work) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
print(counter) # often NOT 800000 -- the += can still interleave badly
Even with the GIL, that counter += 1 is three separate bytecode steps (read, add, write), and the GIL can switch threads in between, so the final total is frequently wrong -- Python programmers reach for threading.Lock or queue.Queue to fix it. Rust makes the opposite bargain: there is no global lock, threads really do run in parallel, and the price is that you choose the right sharing tool per value -- Arc for the shared ownership, and a Mutex, RwLock, or atomic for the mutation. More decisions to make, but genuine parallelism and a compiler that refuses to let you get the sharing wrong. I argue that is a very good deal for systems code.
What did we actually learn?
Arc<T>isRc's thread-safe twin: identical layout, identical methods, but the reference counts are updated with atomic operations, which is what makes it safe to share across threads.Arc<T>isSendandSyncwhenTis, so cloning anArcandmove-ing the clone into a thread is the standard way to share read-only data across a thread pool.Arc<Mutex<T>>is the thread-safe analogue ofRc<RefCell<T>>:Arcfor shared ownership,Mutexfor safe mutation. AMutexblocks rather than panics, andlock()returns aResultbecause locks can be poisoned.Arc<RwLock<T>>allows many concurrent readers or one exclusive writer -- use it only when reads clearly dominate, otherwise a plainMutexis often faster.Arc<AtomicUsize>(and the other atomics) share a single number without a lock, using the very same atomic instructionArcuses for its own count.- Atomics are cheap but not free -- an atomic increment costs more than a plain one -- so
Arceverywhere wastes cycles in single-threaded code. It can never be unsound, though; the compiler blocks the dangerous direction by makingRcneitherSendnorSync. - The rule:
Rcby default,Arconly when you cross a thread boundary, and switching later is nearly a find-and-replace.
Where we go next
We now have the full reference-counting picture: Rc for one thread, Arc for many, and RefCell/Mutex/RwLock layered inside for shared mutation. But there is still that loose end from episode 33 -- a cycle of strong handles leaks, because no count in the loop ever reaches zero. Both Rc and Arc carry a second, weak count for exactly this reason, and next time we finally put it to work: a way to point at a value without keeping it alive, and the standard recipe for breaking reference cycles so your graphs and trees free themselves cleanly.
Exercises
Three exercises, gentle to chewier. Type them yourself before the next episode -- that is where the understanding sticks.
- Share an
Arc<Vec<i32>>across four threads that each print the sum of the vector, then, after joining every thread, confirm thatArc::strong_counthas returned to one. - Build an
Arc<Mutex<Vec<u32>>>and spawn several threads that each push their own thread id into the shared vector. After joining, print the collected ids and confirm the length matches the number of threads. - Take a single-threaded
Rc<RefCell<i32>>counter and convert it into anArc<Mutex<i32>>that several threads each increment. Note how few lines actually had to change -- and try, just once, to send the originalRcversion into a thread to see the compiler stop you.
Right, that is Arc and the cost of atomics laid bare -- thanks for your time, and I'll see you in the next episode! ;-)