When I first got comfortable with RefCell and Mutex, I thought I was done. I could mutate things Rust normally wouldn't let me touch. Problem solved.
Then I actually tried to share that mutable value between two parts of my program, and Rust handed me this:
let shared = Rc::new(RefCell::new(0));
Two wrappers. Stacked. For one number. I had no idea why one wasn't enough.
So let's do this the same way we did closures — no jargon first. Just a real-life situation.
A Real Life Example First
Imagine your team shares one whiteboard.
Person 1 — Everyone in the Same Room
Five people work in one office. They all want to write on the same whiteboard sometimes. There's no lock on the door — you're all trusted adults in the same room, so you just take turns. If two people grab the marker at the exact same moment, someone yells "wait, I'm writing" and the other backs off.
Person 2 — People in Different Rooms
Now the team is spread across five different rooms, maybe different buildings. You can't just "take turns" anymore — nobody can see who's holding the marker. So you install an actual lock on the whiteboard cabinet. Whoever wants to write has to get the key first. Everyone else physically waits until the key comes back.
That's it. That's the whole idea behind Rc<RefCell<T>> and Arc<Mutex<T>>.
📝 Rc<RefCell<T>> = Same room — multiple people share the whiteboard, checked at "wait, I'm writing" speed
🔐 Arc<Mutex<T>> = Different rooms — multiple threads share the whiteboard, checked with an actual lock
Keep this in your head. Let's bring it into Rust now.
What Even Is Interior Mutability Doing Here?
If you read the last post, you already know RefCell and Mutex let you mutate something through what looks like an immutable reference. That solves "can I change this?"
But it doesn't solve a completely different question: "can more than one part of my program hold onto this at the same time?"
That second question is what Rc and Arc answer. Rc<RefCell<T>> and Arc<Mutex<T>> aren't one feature — they're two separate answers stacked together, and that's exactly why they show up glued like that.
Rc> — The Same Room
use std::rc::Rc;
use std::cell::RefCell;
let counter = Rc::new(RefCell::new(0));
let counter_a = Rc::clone(&counter);
let counter_b = Rc::clone(&counter);
*counter_a.borrow_mut() += 1;
*counter_b.borrow_mut() += 1;
println!("{}", counter.borrow()); // 2
Rc::clone doesn't copy the number — it hands out another reference to the same whiteboard. Rc is just counting how many people are currently holding a reference to it.
RefCell is the "wait, I'm writing" rule. It checks at runtime that nobody's already mid-write when you try to borrow_mut(). If two borrows overlap, it panics instead of letting the data get corrupted.
The Person 1 analogy: Same room, same whiteboard, multiple people holding a marker at different moments. Nobody needs a key because you can all see each other.
Where you'll see this in real code
fn increment_all(counters: &[Rc<RefCell<i32>>]) {
for c in counters {
*c.borrow_mut() += 1;
}
}
This is the classic single-threaded "shared state" pattern — a tree of nodes each holding a shared parent reference, a shared cache multiple parts of your app read and update, a shared config object. As long as everything runs on one thread, Rc<RefCell<T>> is the answer.
Arc> — The Different Rooms
use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("{}", *counter.lock().unwrap()); // 5
Arc is Rc, but the reference count is atomic — safe to update from multiple threads at once without corrupting the count itself.
Mutex is the actual lock. .lock() is you walking up to the cabinet and waiting for the key. Whoever's holding it gets exclusive access; everyone else physically blocks until it's returned.
The Person 2 analogy: Different rooms, no way to just "see" who's writing, so you need a real lock and key changing hands.
Where you'll see this in real code
fn spawn_workers(shared_state: Arc<Mutex<Vec<String>>>) {
for _ in 0..3 {
let state = Arc::clone(&shared_state);
thread::spawn(move || {
state.lock().unwrap().push("done".to_string());
});
}
}
This is what you reach for the moment threads enter the picture — worker pools, shared caches across async tasks, any state multiple threads need to read and write.
Why Rc> Refuses to Cross Threads
Here's something most explanations skip: try sending Rc<RefCell<T>> into a thread and Rust stops you before you even get a chance to test it.
// this will NOT compile
let counter = Rc::new(RefCell::new(0));
std::thread::spawn(move || {
*counter.borrow_mut() += 1;
});
Rust knows Rc's reference count isn't atomic — two threads bumping it at once could corrupt the count and cause a use-after-free. So Rc (and RefCell) simply don't implement Send, and the compiler refuses to let them cross a thread boundary at all. Not a runtime panic. Not a warning. A flat compile error.
That's the whole reason Arc and Mutex exist as separate types instead of Rust just making Rc<RefCell<T>> "thread-safe by default" — the atomic counting and the locking both cost a little performance, and Rust won't make you pay that cost unless you're actually using threads.
The Two Questions — This Is the Key Insight
Every time you're reaching for one of these four types, you're really answering two independent questions:
Question 1: Do I need more than one owner?
No → just use T directly, or Box<T>
Yes → Rc<T> (single thread) or Arc<T> (multi-thread)
Question 2: Do I need to mutate it through a shared reference?
No → Rc<T> / Arc<T> alone is enough
Yes → wrap the inner value in RefCell<T> (single thread) or Mutex<T> (multi-thread)
Once you see it this way, you stop memorizing four types and start just picking a row and a column:
| Single-threaded | Multi-threaded | |
|---|---|---|
| Multiple owners | Rc<T> |
Arc<T> |
| + Mutation | Rc<RefCell<T>> |
Arc<Mutex<T>> |
Real Example — Putting Both Together
Let's use the same notification system from the closures post, so it's familiar.
use std::rc::Rc;
use std::cell::RefCell;
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Rc<RefCell<T>> — single-threaded shared counter
let sent_count = Rc::new(RefCell::new(0));
let count_a = Rc::clone(&sent_count);
let count_b = Rc::clone(&sent_count);
*count_a.borrow_mut() += 1; // email sent
*count_b.borrow_mut() += 1; // sms sent
println!("Notifications sent (single thread): {}", sent_count.borrow());
// Arc<Mutex<T>> — multi-threaded shared counter
let queue = Arc::new(Mutex::new(Vec::new()));
let mut handles = vec![];
for channel in ["email", "sms", "push"] {
let queue = Arc::clone(&queue);
handles.push(thread::spawn(move || {
queue.lock().unwrap().push(format!("{} sent", channel));
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("{:?}", queue.lock().unwrap());
}
Output:
Notifications sent (single thread): 2
["email sent", "sms sent", "push sent"]
The Trade-Off Nobody Mentions Upfront
RefCell and Mutex don't remove Rust's borrowing rules — they just move the check to runtime.
let cell = RefCell::new(5);
let _b1 = cell.borrow_mut();
let _b2 = cell.borrow_mut(); // panics at runtime: already borrowed
With RefCell, breaking the rule means a panic. With Mutex, breaking the rule the wrong way (locking in inconsistent order across threads) means a deadlock — your program doesn't even panic, it just hangs forever. You traded a compile-time guarantee for a runtime one, and you're the one responsible for holding up your end of it.
Quick Reference — The One Table You Need
| Type | Solves | Thread-safe | Failure mode if misused |
|---|---|---|---|
Rc<T> |
Multiple owners | No | N/A — read-only |
Arc<T> |
Multiple owners | Yes | N/A — read-only |
Rc<RefCell<T>> |
Multiple owners + mutation | No | Panics on conflicting borrow |
Arc<Mutex<T>> |
Multiple owners + mutation | Yes | Deadlocks on bad lock order |
When to Use Which — The Simple Rule
Use Rc<RefCell<T>> when:
- Everything runs on one thread
- You're building trees/graphs with shared parent or sibling references
- You have shared state (cache, config, counter) that never leaves the main thread
Use Arc<Mutex<T>> when:
- You're spawning real OS threads or sharing state across async tasks
- Multiple workers need to read and write the same data
- You need the compiler to physically stop unsynchronized access, not just runtime-check it
Why This Is "The Heart of Rust" (Again)
Same story as closures. Most languages let you share mutable state by default and hope you're careful. Rust splits the problem into two honest questions — ownership and mutation — and makes you answer both explicitly before it lets you compile.
It's not extra ceremony for its own sake. It's the compiler refusing to let you pay for thread-safety you don't need, and refusing to let you skip it when you do.
📝 Rc<RefCell<T>> = same room, take turns, checked at write-time
🔐 Arc<Mutex<T>> = different rooms, real lock, checked with a key
Two separate questions. Always.
If this clicked for you, drop a comment — I'd love to know if you've hit the "Rc doesn't implement Send" error before you understood why. And if you missed the previous post on Interior Mutability with Cell, RefCell, and Mutex, that's the one to read first — this post builds directly on it. 👇
Top comments (0)