Shared Ownership
Shared ownership is an ownership topology where a single heap-allocated value is owned simultaneously by two or more handles, and the value remains alive until the final handle drops. In Rust, shared ownership is represented explicitly through reference-counted smart pointers: Rc<T> for single-threaded graphs and Arc<T> for multi-threaded sharing.
Tree Ownership vs. Directed Acyclic Graph (DAG) Ownership
With Box<T> or direct struct fields, every value has a unique parent, forming a strict tree hierarchy. When domain models require a Directed Acyclic Graph (DAG) — such as multiple build targets depending on the same shared library node, or multiple AST expressions referencing the same interned symbol — unique ownership forces either data duplication or lifetime-bound arena references. Reference-counted pointers allow multiple parent nodes to co-own the same child node without tying the graph to a lexical lifetime 'a.
Unique Ownership (Tree via Box<T>) Shared Ownership (DAG via Rc<T> / Arc<T>)
[Root] [App A] [App B]
/ \ \ /
v v v v
[Child 1] [Child 2] [Shared Core Library]
(strong_count = 2)
use std::rc::Rc;
#[derive(Debug)]
struct Package {
name: String,
dependencies: Vec<Rc<Package>>,
}
fn main() {
let core_lib = Rc::new(Package {
name: String::from("serde"),
dependencies: vec![],
});
let service_a = Package {
name: String::from("auth-service"),
dependencies: vec![Rc::clone(&core_lib)],
};
let service_b = Package {
name: String::from("billing-service"),
dependencies: vec![Rc::clone(&core_lib)],
};
println!("core_lib owners: {}", Rc::strong_count(&core_lib));
println!(
"{} -> {}",
service_a.name, service_a.dependencies[0].name
);
println!(
"{} -> {}",
service_b.name, service_b.dependencies[0].name
);
}
core_lib owners: 3
auth-service -> serde
billing-service -> serde
Why Shared Ownership Defaults to Immutable Access
Rust's aliasing rule requires that an object never have an active mutable reference (&mut T) at the same time as any other reference (&T or &mut T). Because Rc<T> and Arc<T> allow arbitrary aliases to coexist at runtime, unconditionally providing &mut T through two Rc<T> clones would violate aliasing and cause undefined behavior.
Consequently:
Rc<T>andArc<T>provide shared&Taccess by default.- Mutating shared data requires pairing the shared pointer with an interior mutability primitive that enforces borrowing or locking rules at runtime:
- Single-threaded:
Rc<Cell<T>>orRc<RefCell<T>> - Multi-threaded:
Arc<Mutex<T>>,Arc<RwLock<T>>, orArc<Atomic*>
- Single-threaded:
Choosing Between Borrowing (&'a T) and Shared Ownership (Rc<T> / Arc<T>)
| Criterion | Borrowed Reference (&'a T) |
Shared Ownership (Rc<T> / Arc<T>) |
|---|---|---|
| Owner lifetime | Single lexical owner must outlive all &'a T |
No single owner; allocation lives until last handle drops |
| Struct type signature | Requires lifetime parameter struct View<'a> |
Self-contained ('static-compatible, no lifetime contagion) |
| Runtime cost | Zero (plain pointer, no counter updates) | Heap allocation + counter increment/decrement on clone/drop |
| Async / thread spawning | Cannot satisfy 'static bound required by thread::spawn or tokio::spawn |
Satisfies 'static bound (Arc<T> for multi-threaded tasks) |
| Cyclic graphs | Cannot form self-referentialowned cycles easily | Can form cycles; requires Weak<T> back-edges to avoid leaks |
Zero-Copy Slice Sharing with Rc<[T]> and Arc<[T]>
When sharing immutable buffers (such as network packets, audio samples, or configuration blobs), converting a Vec<T> into Rc<[T]> or Arc<[T]> via .into() creates a reference-counted slice without double indirection (the elements sit directly inside the reference-counted heap block alongside the strong/weak counters).
use std::sync::Arc;
fn main() {
let buffer: Arc<[u8]> = Arc::from([10_u8, 20, 30, 40]);
let worker_handle = Arc::clone(&buffer);
println!("size of Arc<[u8]> fat pointer: {} bytes", size_of_val(&buffer));
println!("shared buffer contents: {:?}", worker_handle);
}
size of Arc<[u8]> fat pointer: 16 bytes
shared buffer contents: [10, 20, 30, 40]