Shared Ownership in Single-Threaded Rust with Rc<T>

Rust’s ownership model is strict: a value can only have one owner at a time. This compile-time guarantee prevents a whole class of memory safety bugs. But what if you legitimately need multiple “owners” for a single piece of data?

For single-threaded scenarios, Rust provides Rc<T>, the Reference Counted smart pointer.

1. The Problem: The Need for Multiple Owners

Imagine you are building a data structure, like a graph or a list, where different parts of the structure need to share access to the same node.

Let’s use a “cons list” example. Suppose we have list a, and we want to create two new lists, b and c, which both share the tail of list a.

enum List {
    Cons(i32, Box<List>),
    Nil,
}
 
use List::{Cons, Nil};
 
fn main() {
    let a = Box::new(Cons(5, Box::new(Cons(10, Box::new(Nil)))));
    
    // We want b and c to "share" ownership of a.
    let b = Cons(3, a); // `a` is moved here.
    let c = Cons(4, a); // Error! `a` was already moved and cannot be used here.
}

This code won’t compile because a is moved into b, and its ownership is transferred. We cannot use a again to create c. We could clone the data inside a, but that would be an expensive deep copy, and b and c would have independent data, not shared data.

2. The Solution: Rc<T>

Rc<T> solves this by enabling multiple ownership. It wraps a value and keeps track of the number of active references to it. This count is called the strong count.

How Rc<T> Works:

  1. Rc::new(value): Creates a new Rc<T>, allocating the value on the heap and setting the reference count to 1.
  2. Rc::clone(&rc_value): This is the key operation. It does not perform a deep copy of the data. Instead, it simply increments the reference count and returns a new Rc<T> pointer to the same heap-allocated data. This is an extremely cheap operation.
  3. Drop: When an Rc<T> pointer goes out of scope, its Drop implementation decrements the reference count.
  4. Cleanup: If the reference count reaches 0, it means there are no more owners, and the heap-allocated data is safely deallocated.

Example Revisited with Rc<T>:

Let’s fix our previous example using Rc<T>.

use std::rc::Rc;
 
enum List {
    Cons(i32, Rc<List>),
    Nil,
}
 
use List::{Cons, Nil};
 
fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("Count after creating a = {}", Rc::strong_count(&a)); // 1
 
    // To create b and c, we clone the Rc, not the data.
    let b = Cons(3, Rc::clone(&a));
    println!("Count after creating b = {}", Rc::strong_count(&a)); // 2
 
    let c = Cons(4, Rc::clone(&a));
    println!("Count after creating c = {}", Rc::strong_count(&a)); // 3
 
    // We can still access a, b, and c independently.
    // When main ends, a, b, and c go out of scope in reverse order.
    // The reference count will decrement until it reaches 0, and the list data is freed.
}

Now the code compiles! b and c share ownership of the list a points to. The Rc::clone calls just bumped the reference count, allowing multiple owners to coexist peacefully.

3. The Big Limitation: Single-Thread Only

Rc<T> does not use any atomic operations for updating its reference count. This makes it very efficient, but it also means it is not thread-safe. If you try to send an Rc<T> to another thread, your code will fail to compile. Rust’s type system protects you from causing a race condition.

use std::rc::Rc;
use std::thread;
 
let five = Rc::new(5);
thread::spawn(move || {
    // This will not compile!
    // `Rc<i32>` cannot be sent safely between threads.
    println!("{}", five); 
});

For sharing data across threads, you must use its thread-safe cousin, Arc<T>.

Conclusion

Rc<T> is an indispensable tool for managing shared ownership in single-threaded contexts. It provides a simple, efficient, and ergonomic way to overcome the single-owner limitation when your program’s logic requires it, all while maintaining Rust’s core memory safety guarantees.