What is the difference between Rc and Arc

Rc is for single-threaded reference counting, while Arc provides thread-safe reference counting for sharing data across threads.

The shared texture problem

You are building a game engine. The texture loader reads a massive PNG from disk and decodes it into raw pixels in memory. The renderer needs those pixels to draw the character model. The particle system needs them to spawn a magic effect. The UI overlay needs them for a portrait frame. Three different systems, one texture.

You cannot move the texture to the renderer because the particle system still needs it. You cannot clone the pixels because that allocates gigabytes of duplicate memory. You need shared ownership. Multiple parts of your program must hold a reference to the same data, and the data must stay alive as long as anyone is using it.

Rust provides two tools for this job: Rc and Arc. Both implement reference counting. Both put data on the heap and track how many owners exist. When the count drops to zero, the data is dropped. The difference lies in how they update that count, and that difference dictates whether your code can touch multiple threads.

Reference counting in plain words

Reference counting solves the shared ownership problem by attaching a counter to the data. Every time a new owner appears, the counter goes up. Every time an owner disappears, the counter goes down. When the counter hits zero, there are no owners left, so the memory is freed.

Rc stands for Reference Counted. Arc stands for Atomic Reference Counted.

Think of a shared whiteboard in an office. Rc is like a whiteboard in a single room. Anyone in the room can walk up and write on the counter. Since only one person can write at a time, you don't need any special rules. Arc is like a whiteboard in a building with multiple floors. If someone on the first floor and someone on the tenth floor try to update the counter at the exact same millisecond, the counter could get corrupted. Arc uses atomic operations to ensure updates happen safely, even when threads race.

The atomic machinery costs CPU cycles. Rc is fast because it assumes no other thread is touching the counter. Arc is slower because it pays a tax for thread safety. You choose based on whether your data crosses thread boundaries.

Minimal examples

Here is the basic syntax. Both types wrap a value and provide a clone method that bumps the reference count instead of copying the data.

use std::rc::Rc;
use std::sync::Arc;

fn main() {
    // Rc wraps data for single-threaded sharing.
    // The counter is a plain integer.
    let single_thread = Rc::new(vec![1, 2, 3]);
    
    // Arc wraps data for multi-threaded sharing.
    // The counter is an atomic integer.
    let multi_thread = Arc::new(vec![1, 2, 3]);
    
    // Cloning an Rc bumps the counter with a fast integer increment.
    let rc_clone = Rc::clone(&single_thread);
    
    // Cloning an Arc bumps the counter with an atomic operation.
    let arc_clone = Arc::clone(&multi_thread);
}

Convention aside: use the explicit Rc::clone(&value) form instead of value.clone(). Both compile and both work. The explicit form signals to readers that you are cloning the reference count, not deep-copying the underlying data. value.clone() looks like it might duplicate the vector, which is misleading.

How the counters work

When you call Rc::new(value), Rust allocates memory on the heap. It stores your value alongside a counter initialized to one. The Rc handle holds a pointer to this heap block.

Calling Rc::clone does not copy the value. It increments the counter and returns a new Rc pointing to the same block. Each Rc that goes out of scope decrements the counter. When the counter reaches zero, the value is dropped and the memory is freed.

Arc follows the same lifecycle. The difference is the counter type. Rc uses a standard usize. Arc uses an AtomicUsize.

Atomic operations guarantee that the increment or decrement happens as a single indivisible step. On a multi-core processor, two threads executing a plain integer increment simultaneously can both read the same value, add one, and write back the same result. The counter would increase by one instead of two. An update is lost. Atomic operations prevent this race condition by using hardware instructions that serialize the update.

This serialization requires memory barriers. Memory barriers force the CPU to flush caches and synchronize state across cores. That synchronization is expensive. Rc::clone is essentially one CPU instruction. Arc::clone involves atomic fetch-add operations and memory ordering constraints that can stall the pipeline.

Rc is fast because it assumes no one else is touching the counter. If threads are involved, that assumption breaks, and so does your program.

Realistic scenario: a directed acyclic graph

Graphs are a classic use case for Rc. In a directed acyclic graph, a node can have multiple parents. A single node might be referenced by several other nodes. Ownership is shared, but the graph lives on one thread. Rc is the perfect fit.

use std::rc::Rc;

/// A node in a graph that can be shared by multiple parents.
#[derive(Debug)]
struct GraphNode {
    label: String,
    children: Vec<Rc<GraphNode>>,
}

fn main() {
    // Create a node that will be shared.
    let shared_node = Rc::new(GraphNode {
        label: "Shared".to_string(),
        children: vec![],
    });

    // Parent A holds a reference to the shared node.
    let parent_a = GraphNode {
        label: "A".to_string(),
        children: vec![Rc::clone(&shared_node)],
    };

    // Parent B also holds a reference to the same node.
    let parent_b = GraphNode {
        label: "B".to_string(),
        children: vec![Rc::clone(&shared_node)],
    };

    // The shared node has two owners now.
    // It will not be dropped until both parents are dropped.
    println!("Ref count: {}", Rc::strong_count(&shared_node));
    
    println!("{:?}", parent_a);
    println!("{:?}", parent_b);
}

The Rc::strong_count method lets you inspect the counter. This is useful for debugging ownership graphs. In production code, you rarely need to check the count. The compiler guarantees the count is correct.

Trust the borrow checker. It usually has a point. If you need to inspect the count, you are likely debugging a logic error, not implementing a feature.

Realistic scenario: a thread pool

When data must travel across threads, Rc cannot help. Rc is not Send. The Send trait marks types that can be moved to another thread. Rc lacks Send because its counter is not atomic. If you move an Rc to a new thread, the original thread might drop it while the new thread is using it. The counter updates would race, and the memory could be freed prematurely.

If you try to move an Rc into a thread, the compiler rejects it with E0277 (trait bound not satisfied). The error message will state that Rc does not implement Send.

Arc implements Send and Sync. Sync means that &Arc<T> can be shared across threads. The atomic counter ensures that reference count updates are safe even when threads interleave.

use std::sync::Arc;
use std::thread;

fn main() {
    // Config data shared across worker threads.
    let config = Arc::new("production".to_string());

    let mut handles = vec![];

    // Spawn three workers.
    for id in 0..3 {
        // Clone the Arc to get a new handle for the thread.
        let config_clone = Arc::clone(&config);

        let handle = thread::spawn(move || {
            // The closure owns config_clone.
            // The data stays alive until this thread finishes.
            println!("Worker {} sees config: {}", id, config_clone);
        });

        handles.push(handle);
    }

    // Wait for all threads to finish.
    for handle in handles {
        handle.join().unwrap();
    }
}

The move keyword forces the closure to take ownership of config_clone. The thread now owns one Arc handle. When the thread exits, the handle is dropped and the counter decrements. The data is freed only when the main thread drops its handle and all worker threads have finished.

Arc pays a tax for thread safety. Pay that tax only when you actually cross thread boundaries.

Pitfalls and compiler errors

Using Rc across threads is a compile-time error. The compiler catches this immediately. You cannot accidentally send an Rc to another thread. The type system prevents it.

Using Arc everywhere is a performance pitfall. Arc clones are significantly slower than Rc clones. If your data never leaves a single thread, Arc adds unnecessary overhead. Profile your code. If reference counting is a bottleneck, check whether you are using Arc where Rc would suffice.

Another pitfall is interior mutability. Rc<T> and Arc<T> only provide shared read access. You cannot mutate the data through an Rc or Arc. To mutate shared data, you need interior mutability wrappers.

On a single thread, pair Rc with RefCell. Rc<RefCell<T>> allows shared mutable state with runtime borrow checking. Across threads, pair Arc with Mutex or RwLock. Arc<Mutex<T>> allows shared mutable state with locking.

Never use Rc<RefCell<T>> across threads. RefCell is not Send. The compiler will block you. Never use Arc<Mutex<T>> on a single thread if you can avoid it. Locking has overhead. Rc<RefCell<T>> is faster for single-threaded mutation.

Convention aside: keep unsafe blocks small. If you ever need to bypass the borrow checker for performance, isolate the unsafe in a tiny helper function. The community calls this the minimum unsafe surface rule. Rc and Arc are safe abstractions. You rarely need unsafe with them.

Decision matrix

Choose the type that matches your concurrency model. The compiler will force your hand if you pick the wrong one.

Use Rc<T> when data lives on a single thread and multiple owners need read access. Use Rc<RefCell<T>> when you need shared mutable state on a single thread. Use Arc<T> when data must be shared across multiple threads. Use Arc<Mutex<T>> when you need to share mutable state across threads. Reach for plain references when lifetimes allow; reference counting is a fallback for complex ownership graphs where static lifetimes are impossible.

Counter-intuitive but true: the more you use Arc, the harder the rest of your code becomes to reason about. Shared ownership obscures data flow. Prefer moving data or using references whenever possible. Use Rc or Arc only when the ownership graph genuinely requires it.

Where to go next