Arc<T> differs from Rc<T>: identical shape and methods, but the reference counts are updated with atomic operations;Arc by default;Arc<Mutex<T>> and Arc<RwLock<T>> give you shared, mutable state across threads;Arc alone is enough, for shared read-only data, and how Arc<AtomicUsize> handles a shared counter without a lock;Rc and Arc.Send/Sync (episode 25), and Rc internals (episode 33);Learn Rust Series):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 ;-)
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 ;-)
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 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.
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.
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.
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.
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.
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.
Arc<T> is Rc'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> is Send and Sync when T is, so cloning an Arc and move-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 of Rc<RefCell<T>>: Arc for shared ownership, Mutex for safe mutation. A Mutex blocks rather than panics, and lock() returns a Result because locks can be poisoned.Arc<RwLock<T>> allows many concurrent readers or one exclusive writer -- use it only when reads clearly dominate, otherwise a plain Mutex is often faster.Arc<AtomicUsize> (and the other atomics) share a single number without a lock, using the very same atomic instruction Arc uses for its own count.Arc everywhere wastes cycles in single-threaded code. It can never be unsound, though; the compiler blocks the dangerous direction by making Rc neither Send nor Sync.Rc by default, Arc only when you cross a thread boundary, and switching later is nearly a find-and-replace.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.
Three exercises, gentle to chewier. Type them yourself before the next episode -- that is where the understanding sticks.
Arc<Vec<i32>> across four threads that each print the sum of the vector, then, after joining every thread, confirm that Arc::strong_count has returned to one.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.Rc<RefCell<i32>> counter and convert it into an Arc<Mutex<i32>> that several threads each increment. Note how few lines actually had to change -- and try, just once, to send the original Rc version 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! ;-)