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:
Rc::new(value): Creates a newRc<T>, allocating thevalueon the heap and setting the reference count to 1.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 newRc<T>pointer to the same heap-allocated data. This is an extremely cheap operation.Drop: When anRc<T>pointer goes out of scope, itsDropimplementation decrements the reference count.- 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.