Thinking in Rust: Ownership, Access, and Memory Safety
I'm an experienced C++ programmer, but still feel that I need a mindset shift when starting to work with Rust.
- References can be immutable (
&
) or mutable (&mut
), which is straightforward for simplest cases. There are more complex cases, e.g. there are data types with interior mutability (e.g.RefCell
), there are multi-level references (& &mut
). - Rust also models memory safety in multi-thread scenarios, so there's
Send
andSync
. They have intricate rules related to various types of references, e.g. when&T
isSend
orSync
? How about&mut T
? - There are various data types, like
Rc
,Arc
,Cell
,RefCell
,Mutex
,RwLock
,Cow
. When to pick which?
Each single rule is well documented from bottom up, but when putting things together to design a complex system, it's easy to get overwhelmed by all these intricacies. So it would be very helpful if we have a clear mental model to help us think in a more rationalized way.
This article is not a tutorial to introduce these concepts, but provides a mental framework, as a simple and natural interpretation on Rust's memory safety models. I hope with this, Rust's rules around memory safety and capability of related built-in data types will be more easily understandable, based on rationalized coherent ideas instead of memorizing many rules.
Basic Concepts: Ownership and Accessโ
A variable represents some resource in the memory. When talking about the relationship between the variable and the relevant resource, there are two types of roles: ownership and access.
let s: String = "Hello".to_string(); // Variable `s` owns the data
println!("{}", s); // Variable `s` has access to the data
let p: &String = &s; // Create a reference `p`
println!("{}", p); // `p` also has access to the same data
Access: Shared vs Exclusiveโ
Rust classifies variables and their references as immutable (default) and mutable (mut
). When it comes to some more intricate data types, the boundary is less clear. e.g. if my data type uses a cache to accelerate access, is it mutating things? What about some statistics collections? What about talking to a remote backend?
I find things become more clear suddenly after switching to a different view point: thinking in terms of shared access and exclusive access.
- Shared Access (default): You can access the resource, without excluding others from holding shared access simultaneously.
- Exclusive Access (
mut
): You have sole permission to use and modify the resource. No one else can access it during this period.
The level of access you have determines what you can do with the resource. Shared access is sufficient for read operations. Write operations requires exclusive access. For methods of a resource, its receiver type (&self
or &mut self
) indicates what level of access is needed by this method. When you wrap a resource with something offering interior mutability (e.g. Mutex
), the wrapped resource can be accessed in a shared way, while its implementation provides run-time protection to only grant access exclusively at runtime.
Exclusive access implies shared access, i.e. what you can achieve with shared access, can always be achieved with exclusive access.
Ownership Implies Lifetime Keeper + Exclusive Accessโ
When a variable owns a resource, there are two implications:
- Lifetime Keeper: You hold the lifetime of the resource. Before you're out of scope (e.g. end of the block that defines the variable), the ownership is either moved, or the resource needs to be dropped.
- Access: You have access to it through the entire lifetime of the ownership (unless the period when it's lent exclusively).
Note that the access here can also be shared or exclusive, depending on whether you used mut
when introducing it. However, even without mut
, since you have the ownership, you can easily upgrade it to a mutable variable, e.g.
fn foo(s: String) {
// You cannot do this, as you don't have exclusive access yet
//! s.push_str(" world");
// However, since you have ownership, it's easy to claim exclusive access
let mut s = s;
// Now we have exclusive access, so can modify it.
s.push_str(" world");
...
}
So once you own a resource, whether or not you have mut
isn't part of the contract. You have the same permission as exclusive access, i.e. ownership implies exclusive access.
Lifetime keeping isn't another permission. For a resource, you can do almost everything with exclusive access: with std::mem::take()
and std::mem::replace()
, you can even drop the resource with exclusive access. Hence exclusive access is equivalent to ownership in terms of permission. Keep this in mind - it will be helpful to understand rules around Send
and Sync
later.
Moves vs. Borrows: Transferring Ownership or Accessโ
Move and borrow are two very unique concepts in Rust. Both are about passing around resources: one for ownership, one for access.
Rust provides two ways to pass around resources:
-
Move: Permanently transfers ownership to another variable. It's a one-way ticket, and after moving, the original owner loses ownership and access.
let s = "Hello".to_string();
let t = s; // ownership moves to t; s is no longer valid -
Borrow (Exclusive or Shared Access): Temporarily grants access to reference without giving up ownership. It will suspend your exclusive access (as you no longer exclusively hold it) until the end of the borrow.
let mut s = String::from("hello");
let r = &mut s; // exclusive access borrowed
r.push_str(" world");
// Borrow ends here; s is usable againYou can grant the access as long as you have the same level of access, even if you don't have the ownership. Once an exclusive access is lent out, your access is suspended until the borrow ends, by the definition of exclusivity. Once shared access is lent out, you only retain shared access, as you no longer exclusively hold the access.
Technically, when you grant access from &T
or &mut T
, slightly different things happen:
- It's called reborrow when you pass on access from
&mut T
. - A copy happens when you grant access from
&T
, as shared access can be freely shared without suspending the current access.
Send and Sync: Access Across Threadsโ
Rust ensures thread-safety explicitly using two traits: Send
and Sync
. There are 3 rules around trait transitivity on &T
and &mut T
, however, anchoring them to the concepts of ownership and access will make things simpler to understand.
To define Send
and Sync
:
-
Send: Indicates that ownership can safely be transferred to another thread. Hence when
T
isSend
,T
can be moved across thread boundary.Corollary: As we discussed earlier, exclusive access is equivalent to ownership in terms of permission. So whenever we allow transferring ownership across threads, nothing can go wrong for shared access across thread boundary, and vice versa. So
Send
also indicates the safety of transferring exclusive access across threads. -
Sync: Indicates shared access can be safely transferred across thread boundary.
From here, we can reason about the 3 rules regarding trait transitivity:
- By the definition of
Sync
above,&T
isSend
if and only ifT
isSync
. - By the definition and corollary of
Send
above,&mut T
isSend
if and only ifT
isSend
. - For both
&T
and&mut T
, their shared access (& &T
and& &mut T
) grants and only grants shared access ofT
. So&T
and&mut T
areSync
if and only ifT
isSync
.
You don't need to memorize any rules. Think clearly about which levels of access are allowed to happen across threads, nothing can go wrong.
Smart Wrappers to Defer Compile-time Safety to Runtimeโ
Rust tries hard to enforce safety at compile time, but it can be rigid sometimes. Some scenarios need more flexibility. Rust provides smart wrappers to defer compile-time safety guards to runtime:
-
Smart wrappers for interior mutability (
Cell
,RefCell
,Mutex
,RwMutex
). With these, only shared access on the wrapper needed for exclusive access on interior resource.-
Cell
is the simplest one, which exposes no API to return references with exclusive access. The main API to set the value is:pub fn set(&self, val: T)
It doesn't expose any exclusive access outside the function. Also, it declares to be
!Sync
, so all methods are guaranteed not to be called simultaneously. Hence it guarantees exclusive access isn't held concurrently without any extra runtime check. -
RefCell
also declares to be!Sync
, while it exposes methods that return proxy to access:// Expose shared access to T
pub fn borrow(&self) -> Ref<T>
// Expose exclusive access to T
pub fn borrow_mut(&self) -> RefMut<T>While
Ref
andRefMut
hold access to the interior resourceT
after the methods returns, it needs to perform runtime check to make sure wheneverRefMut
is returned no otherRef
orRefMut
are live. -
Mutex
andRwMutex
offer similar interface asRefCell
, but are bothSync
- they're safely shared across threads. To achieve safety, it uses lock and read-write lock internally.
-
-
Smart wrappers for shared ownership (
Rc
,Arc
). With these, ownership on the interior resource can be shared among multiple parties, and it's dropped when the last owner no longer holds the smart wrapper. Shared ownership on interior resource can only safely grant shared access. When it comes to exclusive access, they provide the following methodspub fn get_mut(this: &mut Rc<T>) -> Option<&mut T>
pub fn get_mut(this: &mut Arc<T>) -> Option<&mut T>which do runtime check to make sure this is the sole owner, and return
None
otherwise. Internally, they do reference counting, whileRc
is cheaper but thread unsafe (!Send
+!Sync
), andArc
is thread safe (Send
+Sync
) whenT
is thread safe (Send
+Sync
). -
Smart wrappers for clone-on-write (
Cow
) leaves ownership versus shared access undecided at compile time. Shared access is always guaranteed without overhead. Whenever exclusive access is needed, it clones the interior resource.
When thinking from the angle of ownership and access, purpose, contract and behavior of these smart wrappers become more easily explainable.
Conclusionโ
By clearly separating and defining ownership and exclusive versus shared access, Rust's complexity transforms into logical clarity. Moves, borrows, Send, Sync, and runtime checks become intuitive and predictable tools in your programming toolbox.
With this mental model, you can confidently explore even advanced Rust, while keeping your understanding grounded and practical.
Support usโ
CocoIndex is an open source project built on Rust. Rust is the number one choice for any modern data engine. If this article is helpful to you, please drop star โญ at GitHub to support this project.