Rc and Arc - Shared Ownership and Thread Safety
Understand how Rc and Arc provide shared ownership in Rust, the thread-safety guarantees each offers, and when to choose one over the other to write safe concurrent and single-threaded code.
Rust’s ownership model insists that every value has exactly one owner. That rule keeps memory management predictable — no double frees, no dangling pointers — but it also makes sharing data across different parts of a program feel impossible. A GUI framework might need multiple widgets to read the same configuration. A concurrent server might need several worker threads to hold a reference to a shared cache. These scenarios demand something that single ownership cannot provide.
Rc<T> and Arc<T> are the smart pointers that break the single-owner constraint without breaking Rust’s safety guarantees. They implement shared ownership through reference counting, so a single heap allocation stays alive as long as at least one owner still exists. The difference between them is thread safety: Rc uses non‑atomic counters for single‑threaded code, while Arc uses atomic counters to make the same pattern safe across threads.
Why Shared Ownership Exists
The problem is easiest to see in a concrete scenario. Imagine a text editor where a Document struct holds the raw text, and a SpellChecker and a SyntaxHighlighter both need read access to it. If the Document were owned by one of them, the other would have to borrow it — and lifetime constraints would quickly tangle the whole application. You could clone the document, but duplicating a large buffer for every consumer wastes memory and time.
Reference‑counted pointers solve this by placing the data on the heap and keeping a counter of how many pointers reference it. Each time a new reference is created, the counter increments. Each time an owner is dropped, the counter decrements. When the counter hits zero, the heap memory is freed. There is no garbage collector scanning the heap; the cleanup is deterministic and happens exactly when the last reference goes out of scope.
Rc and Arc are the two concrete implementations of this idea. They share the same conceptual model but live in different concurrency domains.
Rc<T> — Reference Counting for Single‑Threaded Code
Rc<T> (Reference Counted) is defined in std::rc. It manages a non‑atomic reference count, which makes it fast but also means it cannot be sent to or shared safely between threads. The compiler will refuse any attempt to move an Rc across a thread boundary — a compile‑time guarantee that prevents a data race on the counter itself.
Creating and Cloning Rc
An Rc is created with Rc::new(data). To add another owner, you call Rc::clone(&rc) — note that this does not deep‑copy the data; it only bumps the reference count.
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("hello"));
println!("count after creating a = {}", Rc::strong_count(&a)); // 1
let b = Rc::clone(&a);
println!("count after cloning = {}", Rc::strong_count(&a)); // 2
{
let c = Rc::clone(&a);
println!("inner count = {}", Rc::strong_count(&a)); // 3
} // c dropped here
println!("after inner block = {}", Rc::strong_count(&a)); // 2
}
The Rc::strong_count function returns the current strong reference count. The output confirms that each clone increments and each drop decrements. The inner String is not copied; all three Rc pointers point to the same heap allocation.
Rc::clone is the idiomatic way to express the operation. You can also call rc.clone(), but Rc::clone(&rc) explicitly signals that only the reference count is being duplicated, not the data inside.
Immutable Access and Limitations
Rc<T> implements Deref<Target = T>, so you can call methods on T directly through an Rc. However, because multiple owners may be reading the same data simultaneously (even in a single thread), Rc only provides shared (&) access to its inner value. You cannot get a &mut T from an Rc unless you are the sole owner (which Rc::get_mut checks). To mutate data inside an Rc, you combine it with RefCell — the pattern Rc<RefCell<T>> gives you runtime‑checked interior mutability in single‑threaded code. That pattern is covered in detail in the RefCell section; here, the key point is that Rc alone is an immutable‑sharing primitive.
Rc is not Send or Sync:
Because the reference counter uses ordinary integer increments and decrements, moving an Rc to another thread would allow multiple threads to mutate that counter simultaneously — a data race. Rust prevents this at compile time. If you attempt to spawn a thread that captures an Rc, you will get an error stating that Rc<i32> cannot be sent between threads safely.
Arc<T> — Atomic Reference Counting for Multi‑Threaded Code
Arc<T> (Atomically Reference Counted) lives in std::sync. It provides the same shared‑ownership model as Rc but uses atomic CPU instructions for the reference count, making it safe to send and share across threads. The cost is a small runtime overhead — atomics are a bit slower than plain integer operations — but you gain the ability to write concurrent programs without data races.
Sharing Immutable Data Across Threads
The canonical example is a read‑only dataset that multiple threads need to inspect.
use std::sync::Arc;
use std::thread;
fn main() {
let numbers = Arc::new(vec![1, 2, 3, 4, 5]);
let mut handles = vec![];
for i in 0..3 {
let numbers = Arc::clone(&numbers);
handles.push(thread::spawn(move || {
let sum: i32 = numbers.iter().sum();
println!("thread {i} sum = {sum}");
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("all threads done, numbers still owned: {:?}", numbers);
}
Each call to Arc::clone bumps the atomic counter. The cloned Arc is moved into the spawned thread’s closure, giving that thread its own owned reference. The original numbers remains valid in the main thread after all workers finish because the reference count never hits zero while the main thread still holds an Arc.
Thread‑safe sharing works when T is Sync:
If the above code compiles and runs without error, you have verified that Vec<i32> (the inner type) implements both Send and Sync. Arc will only implement Send and Sync when its inner type does, preserving Rust’s thread‑safety guarantees.
What “Atomic” Means for the Counter
When one thread clones an Arc, the counter must be incremented in a way that another thread dropping its Arc at the same instant cannot corrupt the counter. Atomics guarantee that each increment and decrement is an indivisible operation, often using hardware instructions like LOCK CMPXCHG on x86. This eliminates data races on the counter itself.
The implication for beginners: if your program does not involve threads, use Rc. The performance difference is measurable in hot loops, and the compiler’s refusal to let you misuse Rc across threads gives you a safety net you never have to think about. Reserve Arc for when you genuinely spawn threads.
How Thread Safety Works — Send, Sync, and the Arc Guarantee
To understand why Arc is safe, you need to understand the two marker traits that govern thread safety in Rust: Send and Sync.
Send: A type isSendif ownership of it can be safely transferred to another thread. Most types that own their data areSend;Rcis not.Sync: A type isSyncif it is safe to share a reference to it with another thread. In other words,&TimplementsSendwhenT: Sync. Primitives likei32areSync;RefCellis not.
Arc<T> implements Send and Sync if and only if T: Send + Sync. This is the crucial point that often trips up newcomers. Wrapping a non‑thread‑safe type T in an Arc does not magically make T thread‑safe. Arc makes the ownership sharing safe across threads, but it does not change the safety properties of the data being shared.
Consider Arc<RefCell<i32>>. RefCell<i32> is Send (you can move it to another thread) but it is not Sync (its borrow counter uses non‑atomic operations, so sharing a reference would risk a data race). Because RefCell<i32> is not Sync, Arc<RefCell<i32>> does not implement Sync, and therefore you cannot share a reference to it across threads. The compiler blocks the exact dangerous pattern that could cause a data race on the borrow counter.
Arc protects the counter, not the content:
Think of Arc as making the reference‑counting mechanism thread‑safe. The safety of the inner value is still governed by that value’s own Send and Sync implementations. If you need mutable shared state, you combine Arc with a synchronization primitive like Mutex or RwLock.
Mutating Data Inside an Arc
Arc itself only provides immutable (&T) access through Deref. When you need to modify the shared data, you have three main strategies, each appropriate in a different situation.
Arc<Mutex<T>> for Exclusive Access
Mutex<T> provides interior mutability with mutual exclusion. Only one thread can hold the lock at a time, and the lock guard grants a &mut T for the duration of the lock.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
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!("final count = {}", *counter.lock().unwrap());
}
Each thread acquires the lock, increments the integer, and releases the lock when the MutexGuard goes out of scope. The final count is always 10, regardless of how the threads interleave.
Arc<RwLock<T>> for Read‑Heavy Workloads
When reads vastly outnumber writes, RwLock<T> allows multiple concurrent readers or one exclusive writer, reducing contention.
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let data = Arc::new(RwLock::new(vec![10, 20, 30]));
let mut handles = vec![];
// multiple readers
for i in 0..5 {
let data = Arc::clone(&data);
handles.push(thread::spawn(move || {
let guard = data.read().unwrap();
println!("reader {i} sees sum = {}", guard.iter().sum::<i32>());
}));
}
// one writer
{
let data = Arc::clone(&data);
handles.push(thread::spawn(move || {
let mut guard = data.write().unwrap();
guard.push(40);
println!("writer added element");
}));
}
for handle in handles {
handle.join().unwrap();
}
}
RwLock::read() gives a shared guard; RwLock::write() gives an exclusive guard. The standard library’s implementation prioritizes fairness by default, preventing writer starvation.
Arc::make_mut for Clone‑on‑Write
Arc::make_mut(&mut self) -> &mut T provides mutation without any lock, but it requires a unique reference to the Arc itself (&mut self). If there are other owners, it clones the inner data to ensure uniqueness — an approach sometimes called clone‑on‑write. This method is useful when you hold the only mutable reference to an Arc and want to avoid locking overhead.
use std::sync::Arc;
fn main() {
let mut data = Arc::new(vec![1, 2, 3]);
// Only owner — modifies in place.
Arc::make_mut(&mut data).push(4);
println!("data = {:?}", *data); // [1, 2, 3, 4]
let other = Arc::clone(&data);
// Now shared — make_mut clones the inner Vec before modifying.
Arc::make_mut(&mut data).push(5);
println!("data = {:?}", *data); // [1, 2, 3, 4, 5]
println!("other = {:?}", *other); // [1, 2, 3, 4]
}
Because make_mut needs &mut self, it cannot be called from multiple threads simultaneously on the same Arc variable without additional synchronization, so it does not replace Mutex in concurrent contexts. It shines in single‑threaded code or when you have carefully arranged that only one thread holds a mutable handle.
Deadlock risk with nested locks:
When combining Arc<Mutex<T>> or Arc<RwLock<T>>, be careful to always acquire locks in a consistent order across all threads. Reversing the order can cause two threads to each hold a lock the other needs, resulting in a deadlock — a silent halt with no error message.
Weak References and Cycle Prevention
Both Rc and Arc support weak references through Weak<T>. A weak reference does not own the data — it does not increase the strong count — but it can be temporarily upgraded to a strong reference if the data is still alive. This is the primary tool for breaking reference cycles that would otherwise leak memory.
Creating and Upgrading Weak Pointers
With Arc, you create a Weak by calling Arc::downgrade. To use the data, you call weak.upgrade(), which returns Some(Arc<T>) if the value still exists, or None if all strong references have been dropped.
use std::sync::{Arc, Weak};
fn main() {
let strong = Arc::new(42);
let weak = Arc::downgrade(&strong);
println!("weak upgrade = {:?}", weak.upgrade().map(|a| *a)); // Some(42)
drop(strong);
println!("after drop = {:?}", weak.upgrade()); // None
}
The allocation itself stays alive as long as there are any Weak pointers, but the inner value is dropped once the strong count reaches zero. This separation prevents weak pointers from keeping data alive indefinitely while still allowing you to check whether the data is accessible.
Using Weak to Break Cycles
A graph or tree where parent and child nodes both hold Arc references to each other would never be deallocated — the reference counts would never drop to zero. The standard fix: strong references (Arc) go from parent to child, and weak references (Weak) go from child back to parent.
use std::sync::{Arc, Weak, Mutex};
struct Node {
parent: Mutex<Weak<Node>>,
children: Mutex<Vec<Arc<Node>>>,
}
impl Node {
fn new() -> Arc<Self> {
Arc::new(Node {
parent: Mutex::new(Weak::new()),
children: Mutex::new(vec![]),
})
}
fn set_parent(child: &Arc<Node>, parent: &Arc<Node>) {
*child.parent.lock().unwrap() = Arc::downgrade(parent);
}
fn add_child(parent: &Arc<Node>, child: &Arc<Node>) {
parent.children.lock().unwrap().push(Arc::clone(child));
}
}
When the root node is dropped, its strong count becomes zero and it is deallocated. The children’s weak parent pointers simply return None on upgrade, and the rest of the tree follows, all without leaks.
Weak counts keep the allocation alive:
Even after all strong Arc pointers are gone, the backing heap memory is not freed until all Weak handles are also dropped. This is almost never a problem in practice, but it means a long‑lived Weak can delay the release of memory that no longer holds any data.
Choosing Between Rc and Arc
The decision hinges on one question: does the shared data need to cross a thread boundary?
- Single‑threaded programs: Use
Rc. It is lighter because the counter increments and decrements avoid atomic overhead. If you later try to send anRcto a thread, the compiler will refuse, which serves as an automatic safety check. - Multi‑threaded programs: Use
Arc. Atomic reference counting is the only way to share ownership safely across threads. The performance cost is usually negligible compared to the work the threads themselves are doing. Profiling can confirm whether replacingArcwith a scoped thread pattern (which does not requireArcat all) yields a measurable gain. - Libraries that may be used in either context: Library authors often choose
Arcto give consumers the most flexibility, even if the library itself does not spawn threads. The caller can then freely share the value across threads without repackaging.
If you need mutable shared state, layer in Mutex or RwLock inside the Arc, regardless of whether you started with Rc or Arc. An Rc<Mutex<T>> is valid when all users are on a single thread; Arc<Mutex<T>> is the multi‑threaded version.
Common Mistakes
Assuming Arc Makes the Inner Data Thread‑Safe
Arc only makes the reference counting thread‑safe. The inner type T must still satisfy the thread‑safety contracts on its own. Arc<RefCell<T>> is not thread‑safe and will fail to compile if you try to share it across threads. The fix is to replace RefCell with Mutex or RwLock.
Forgetting That Arc Clone Does Not Copy the Data
Arc::clone is cheap, but Arc::make_mut may clone the entire payload if there are multiple strong references. In performance‑sensitive code, understand the ownership picture before calling make_mut repeatedly in a loop where the reference count might be greater than one.
Holding Locks Across Expensive Operations
With Arc<Mutex<T>>, the lock should be held for the shortest possible time. Move any computation outside the critical section to reduce contention.
// Bad: lock held during heavy computation
let mut guard = data.lock().unwrap();
let result = expensive_calculation(guard.clone());
*guard = result;
// Good: compute first, lock only for the write
let result = expensive_calculation(data.lock().unwrap().clone());
*data.lock().unwrap() = result;
Mixing Up Rc and Arc in Multi‑Threaded Code
If you start a project using Rc and later add threading, the compiler will point to every place an Rc crosses a thread boundary. While it is tempting to just switch everything to Arc, first consider whether you can keep data thread‑local and use message passing. Overusing Arc can make ownership boundaries fuzzy and make programs harder to reason about.
Summary
Rc<T> and Arc<T> extend Rust’s ownership system into the realm of shared ownership. Rc keeps the mechanism lightweight and single‑threaded; Arc trades a small atomic overhead for the ability to safely participate in concurrent programs. Neither adds mutation capability to its inner value — that responsibility falls to interior‑mutability types like Mutex and RwLock, which combine naturally with both pointers.
The most important takeaway is that Arc does not override the safety rules of its payload. It makes the sharing itself safe across threads, but the data inside must still uphold Send and Sync on its own. Understanding this separation prevents the most common concurrency bugs that newcomers face.