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 the 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
SendandSync. They have intricate rules related to various types of references, e.g. when is&TSendorSync? 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 the 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 had 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 of 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 clearer suddenly after switching to a different viewpoint: 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 require 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 during 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 a 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
TisSend,Tcan be moved across the 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 the thread boundary, and vice versa. So
Sendalso indicates the safety of transferring exclusive access across threads. -
Sync: Indicates shared access can be safely transferred across the thread boundary.
From here, we can reason about the 3 rules regarding trait transitivity:
- By the definition of
Syncabove,&TisSendif and only ifTisSync. - By the definition and corollary of
Sendabove,&mut TisSendif and only ifTisSend. - For both
&Tand&mut T, their shared access (& &Tand& &mut T) grants and only grants shared access ofT. So&Tand&mut TareSyncif and only ifTisSync.
You don’t need to memorize any rules. Think clearly about which levels of access are allowed to happen across threads, and 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 is needed for exclusive access on the interior resource.-
Cellis the simplest one, exposing 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. -
RefCellalso 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
RefandRefMuthold access to the interior resourceTafter the methods return, it needs to perform a runtime check to make sure wheneverRefMutis returned no otherReforRefMutis live. -
MutexandRwMutexoffer a similar interface toRefCell, 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 the interior resource can only safely grant shared access. When it comes to exclusive access, they provide the following methods:pub fn get_mut(this: &mut Rc<T>) -> Option<&mut T> pub fn get_mut(this: &mut Arc<T>) -> Option<&mut T>which do a runtime check to make sure this is the sole owner, and return
Noneotherwise. Internally, they do reference counting, whileRcis cheaper but thread unsafe (!Send+!Sync), andArcis thread safe (Send+Sync) whenTis thread safe (Send+Sync). -
Smart wrappers for clone-on-write (
Cow) leave 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, the purpose, contract, and behavior of these smart wrappers become more easily explainable. The choice comes down to two questions: are you sharing access or sharing ownership, and does it need to cross threads?
Why this matters for a data engine
This mental model isn’t academic for us — it’s the daily reality of building CocoIndex’s engine. The core is async-first on Tokio, which means values routinely cross thread boundaries: a future started on one worker thread can be polled to completion on another. The moment you put a value into a task that the runtime may move between threads, the compiler demands that value be Send. Reading that requirement through the ownership lens above — “Send means ownership (and therefore exclusive access) can safely move across a thread boundary” — turns a cryptic compiler error into an obvious design question: should this resource really be handed off to another thread, and is everything it transitively owns safe to hand off too?
The smart-wrapper choices fall out of the same reasoning. State that’s genuinely shared across concurrent tasks — connection pools, cached schema, context values — lives behind Arc, because we need shared ownership with a lifetime that ends only when the last task lets go, and Arc is the thread-safe counter (Rc would be !Send and wouldn’t compile into a multi-threaded task). Where that shared state also needs mutation, we reach for the Sync interior-mutability wrappers (Mutex/RwLock) so that shared access to the wrapper can yield exclusive access to the interior, with the runtime check enforcing exclusivity. The single-writer discipline around our internal LMDB storage is exactly this principle applied at the I/O layer: many tasks hold shared access to the database handle, but writes are funneled so that mutation is serialized rather than racing.
The same separation of what a value is from how it may be accessed underpins our type-guided serialization: a value the engine owns can be moved into the serializer and reconstructed on the other side, while a value merely borrowed can only be read. Thinking in ownership and access keeps these decisions principled instead of trial-and-error against the borrow checker.
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, and these ownership and access ideas underpin how CocoIndex handles type-guided serialization and its internal storage. If this article is helpful to you, please drop a star ⭐ at GitHub to support this project.
Frequently asked questions.
What is the difference between ownership and access in Rust?
They are two distinct roles a variable can have over a resource. Ownership means you hold the resource's lifetime (it is moved or dropped when you go out of scope) and you have access to it. Access is the permission to use the resource, which can be held by the owner or by a reference. See Basic concepts: ownership and access.
What is the difference between shared and exclusive access in Rust?
Thinking in terms of shared versus exclusive access is clearer than mutable versus immutable. Shared access (the default) lets you use the resource without excluding others from also holding shared access; it is enough for reads. Exclusive access (mut) gives sole permission to use and modify the resource, and is required for writes. Exclusive access implies shared access. See Access: shared vs exclusive.
Why is exclusive access equivalent to ownership in terms of permission?
With exclusive access you can do almost everything: using std::mem::take() and std::mem::replace() you can even drop the resource. Lifetime-keeping is not a separate permission. So in terms of what you are allowed to do, exclusive access is equivalent to ownership — a key idea for reasoning about Send and Sync later. See Ownership implies lifetime keeper + exclusive access.
What is the difference between a move and a borrow in Rust?
A move permanently transfers ownership to another variable — a one-way ticket, after which the original owner loses ownership and access. A borrow temporarily grants access (shared or exclusive) to a reference without giving up ownership; an exclusive borrow suspends your own access until it ends, while a shared borrow leaves you with shared access. See Moves vs. borrows.
How do I understand the Send and Sync rules in Rust?
Anchor them to access. Send means ownership (and therefore exclusive access) can be transferred to another thread; Sync means shared access can be transferred across threads. From these, the three transitivity rules follow: &T is Send iff T is Sync; &mut T is Send iff T is Send; and both &T and &mut T are Sync iff T is Sync. See Send and Sync: access across threads.
When should I use Cell, RefCell, Mutex, Rc, Arc, or Cow?
They are smart wrappers that defer compile-time safety to runtime. For interior mutability, Cell and RefCell are !Sync (single-thread), while Mutex and RwLock are Sync and use locking for cross-thread safety. For shared ownership, Rc is cheaper but thread-unsafe and Arc is thread-safe; both expose exclusive access only via a runtime check that you are the sole owner. Cow leaves ownership vs. shared access undecided, cloning only when exclusive access is needed. See Smart wrappers to defer compile-time safety to runtime.
What is interior mutability and how do these wrappers provide it?
Interior mutability lets you obtain exclusive access to a resource while holding only shared access to its wrapper. Cell exposes no references and sets values via set(&self, val); RefCell returns Ref/RefMut proxies guarded by a runtime check so no RefMut coexists with another borrow; Mutex/RwLock offer a similar interface but are safe across threads via locks. See Smart wrappers to defer compile-time safety to runtime.