Needing to change a value behind a shared reference does not automatically call for a lock. RefCell<T>, Mutex<T>, and RwLock<T> all permit mutation during shared access, but they detect conflicts at different times and handle them differently. Choose by the scope of sharing and the required failure policy, not by a vague guess about performance.
1. Start with &mut T
When one owner changes a value, start by owning T directly and mutating it through &mut T. The compiler checks the rule of one mutable reference or many immutable references before the program runs. No runtime borrow state or lock is needed. Wrapping a value in a cell or lock when the caller can pass a mutable reference only obscures the real ownership relationship.
Interior mutability is useful when the outer API must retain &self or &T while internal state changes. Ask three questions before choosing it: Does access stay on one thread? Must multiple readers proceed at once? Should a conflict panic, return a Result, or wait?
| Access method | Conflict checked | Conflict behavior | Value that keeps access active |
|---|---|---|---|
&mut T |
Compile time | Compile error | Mutable reference |
RefCell<T> |
Runtime borrow state | borrow* panics; try_borrow* returns Result |
Ref<T> or RefMut<T> |
Mutex<T> |
Lock acquisition | lock waits; try_lock reports immediately |
MutexGuard<T> |
RwLock<T> |
Read or write lock acquisition | read and write wait; try_* reports immediately |
Read or write guard |
Article 18 dealt with who owns a value through Rc and Arc. This article separates that decision from who may access the shared payload right now. Thread creation and the detailed rules for Send and Sync belong to Article 21. Article 22 will compare the broader architectures of channels and shared state.
2. RefCell and Runtime Borrowing
RefCell<T> checks borrowing rules at runtime within one thread. Several immutable borrows may coexist, but none may overlap a mutable borrow, and only one mutable borrow may exist. Interior mutability does not remove those rules. It moves enforcement from compile time to runtime.
The example's LocalEndpointState updates a counter inside record_success(&self), which receives a shared reference. It uses try_borrow_mut so the caller receives a conflict as an error.
pub struct LocalEndpointState {
successful_checks: RefCell<usize>,
}
impl LocalEndpointState {
#[must_use]
pub fn new() -> Self {
Self {
successful_checks: RefCell::new(0),
}
}
pub fn record_success(&self) -> Result<(), BorrowMutError> {
*self.successful_checks.try_borrow_mut()? += 1;
Ok(())
}
#[must_use]
pub fn read(&self) -> Ref<'_, usize> {
self.successful_checks.borrow()
}
}
borrow() and borrow_mut() panic when the requested borrow conflicts with an active one. That suits an API where such a conflict is a programming error. When the same condition needs ordinary error handling, use try_borrow() or try_borrow_mut() and handle the returned Result. A call to borrow() does not always panic; it fails only when it overlaps an active mutable borrow.
The borrow stays active for as long as the returned Ref or RefMut lives. The example holds a read guard, confirms that mutation returns an error, calls drop(read_guard), and then mutates successfully. A guard stored in a wider variable scope extends the conflict by the same amount.
RefCell<T> is not a synchronization primitive and does not implement Sync. Wrapping it in Arc<RefCell<T>> does not make it safe to share across threads. The following source deliberately crosses that boundary and fails to compile.
use std::cell::RefCell;
use std::sync::Arc;
use std::thread;
fn main() {
let checks = Arc::new(RefCell::new(0_usize));
let worker_checks = Arc::clone(&checks);
thread::spawn(move || {
*worker_checks.borrow_mut() += 1;
})
.join()
.expect("worker should finish");
}
Run this command from the crate directory.
rustc --edition=2024 --error-format=short fixtures/refcell_across_thread.rs
This is the complete E0277 diagnostic shown in short format.
fixtures/refcell_across_thread.rs:9:19: error[E0277]: `RefCell<usize>` cannot be shared between threads safely: `RefCell<usize>` cannot be shared between threads safely
error: aborting due to 1 previous error
3. The UnsafeCell Boundary
UnsafeCell<T> is Rust's low-level primitive for interior mutability. Safe abstractions such as RefCell<T> use it internally to permit controlled mutation behind a shared reference. It relaxes the immutability guarantee for shared references. The uniqueness guarantee for &mut T still applies, and UnsafeCell<T> neither synchronizes threads nor prevents data races.
Do not treat UnsafeCell<T> as a type that switches off the borrow checker. An author building a safe API on top of it must prove additional aliasing and concurrency invariants. This article and its example cover only that conceptual boundary; they contain no unsafe code or custom cell implementation.
4. Mutex and Exclusive Access
Mutex<T> permits one guard at a time to access the protected T. lock() may block the current thread until another guard releases the lock, then returns a MutexGuard<T> on success. The lock is released through RAII when the guard leaves scope or is explicitly dropped.
In the example, three workers share ownership of one result vector through Arc. Each worker locks the Mutex and appends one result. Scheduling order is unspecified, so the code sorts by name after joining the workers to make the output deterministic.
#[must_use]
pub fn aggregate_results(inputs: Vec<EndpointResult>) -> Aggregation {
let completed = Arc::new(Mutex::new(Vec::new()));
let handles: Vec<_> = inputs
.into_iter()
.map(|result| {
let completed = Arc::clone(&completed);
thread::spawn(move || {
completed
.lock()
.expect("worker result mutex should not be poisoned")
.push((thread::current().id(), result));
})
})
.collect();
for handle in handles {
handle.join().expect("endpoint worker should finish");
}
let mut completed = Arc::try_unwrap(completed)
.expect("all worker owners should be joined")
.into_inner()
.expect("worker result mutex should not be poisoned");
let worker_threads = completed
.iter()
.map(|(thread_id, _)| *thread_id)
.collect::<HashSet<_>>()
.len();
let mut results: Vec<_> = completed.drain(..).map(|(_, result)| result).collect();
results.sort_by(|left, right| left.name.cmp(&right.name));
Aggregation {
results,
worker_threads,
}
}
Lock acquisition returns LockResult<MutexGuard<_>>, which is Result<_, PoisonError<_>>. If a thread panics while holding a mutex guard, later acquisition can observe poison. Poisoning is advisory, not a complete integrity barrier. The absence of poison does not prove that the data is valid.
Read the policy of recover_poisoned_monitor narrowly. After the worker panics, the function uses PoisonError::into_inner to access the guard explicitly, increments the stored value from 41 to 42, and observes 42. It does not call clear_poison, restore a known-good state, or establish that any invariant has been repaired. A service that chooses recovery should first validate or replace the data to restore its invariants, then deliberately clear poison only if its policy requires that. Propagating the error is also a valid policy.
#[must_use]
pub fn recover_poisoned_monitor() -> PoisonRecovery {
let monitor = Arc::new(Mutex::new(0_usize));
let worker_monitor = Arc::clone(&monitor);
let worker = thread::spawn(move || {
let mut checks = worker_monitor.lock().expect("mutex starts healthy");
*checks = 41;
std::panic::resume_unwind(Box::new("deliberate worker failure"));
});
assert!(worker.join().is_err(), "worker should report its panic");
let mut recovered = match monitor.lock() {
Ok(_) => panic!("worker panic should poison the mutex"),
Err(poisoned) => poisoned.into_inner(),
};
*recovered += 1;
let recovered_checks = *recovered;
PoisonRecovery {
poison_observed: true,
recovered_checks,
}
}
Use try_lock() when code must report current availability instead of waiting. Calling lock() again while the same thread still holds a guard is not a recursive-lock contract. The second call may not return; it may panic or deadlock. Do not depend on either failure mode. Drop the existing guard first.
5. RwLock Reads and Writes
RwLock<T> permits either several read guards or one write guard. read() gives shared access, while write() gives exclusive mutable access. A write guard makes all other readers and writers wait. A writer cannot enter while even one read guard remains.
The example avoids scheduler assumptions and checks only this compatibility rule with try_read() and try_write(). It holds two readers, verifies that a write returns WouldBlock, drops both readers, and changes the value through a writer. While that writer remains alive, both a read and another write return WouldBlock.
#[must_use]
pub fn observe_rwlock_access() -> RwLockTrace {
let status = RwLock::new(1_usize);
let first_reader = status.read().expect("rwlock should be healthy");
let second_reader = status.try_read().expect("another reader should enter");
let two_readers_overlap = *first_reader == *second_reader;
let write_would_block_for_readers = matches!(status.try_write(), Err(TryLockError::WouldBlock));
drop((first_reader, second_reader));
let mut writer = status
.write()
.expect("writer should enter after readers leave");
*writer += 1;
let value_after_write = *writer;
let read_would_block_for_writer = matches!(status.try_read(), Err(TryLockError::WouldBlock));
let write_would_block_for_writer = matches!(status.try_write(), Err(TryLockError::WouldBlock));
drop(writer);
RwLockTrace {
two_readers_overlap,
write_would_block_for_readers,
value_after_write,
read_would_block_for_writer,
write_would_block_for_writer,
}
}
This test proves which access modes may coexist. It proves nothing about throughput or fairness. Reader and writer priority for the standard RwLock depends on the underlying operating-system implementation; Rust guarantees no particular policy. Code that assumes FIFO order, writer priority, or starvation prevention exceeds the portable contract.
Poisoning also differs slightly from Mutex. A standard RwLock can become poisoned only when a panic occurs while an exclusive write guard is held. A panic under a read guard does not poison the lock. Poison remains an advisory signal here too.
6. Ownership and Lock Scope
Arc<T> and a lock express separate decisions. Arc lets several owners keep one allocation alive. An inner Mutex or RwLock coordinates who may access its payload now. Arc<Mutex<T>> therefore combines cross-thread shared ownership with exclusive mutation. Arc alone does not add synchronization to an arbitrary T.
Keep a guard only as long as the work on protected data requires it. End an inner block or call drop(guard) before acquiring another lock, waiting for a worker to finish, or doing unrelated computation. RAII prevents forgotten unlock calls on ordinary control-flow paths, but it cannot eliminate a cycle caused by inconsistent lock ordering or a second lock of the same mutex. When code uses several locks, define and follow one acquisition order. Even that convention does not prove that arbitrary code is deadlock-free.
The complete Rust 2024 example has no external dependencies. Run these commands from the project directory; they enter the exact crate directory. The final rustc command is expected to fail with E0277.
cd examples/article-19-interior-mutability-sync
cargo fmt --all -- --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
rustc --edition=2024 --error-format=short fixtures/refcell_across_thread.rs
On Rust 1.98.1 and Cargo 1.98.1, all five Cargo commands should return exit code 0. The six integration tests include the compile-fail check. Program output is deterministic:
local: checks=2 conflict=borrow-error
workers: count=3 results=admin:up,api:down,status:up
rwlock: readers=2 reader-write=blocked value=2 writer-read=blocked writer-write=blocked
poison: observed=true recovered=42
The selection order is straightforward. Keep direct ownership and &mut T when they are sufficient. Consider RefCell<T> for dynamic borrowing behind a shared-reference API on one thread, Mutex<T> when access must be exclusive, and RwLock<T> when the API must distinguish multiple readers from one writer. Add Arc only when owners on several threads must keep the same allocation alive. Choosing a lock does not replace the channels-versus-shared-state architecture decision reserved for Article 22.
Full source code
The complete runnable source for this article is available in the Chapter 19 project on GitHub.
Sources
- The Rust Programming Language: RefCell<T> and the Interior Mutability Pattern
- The Rust Programming Language: Shared-State Concurrency
- Rust standard library 1.98.1: UnsafeCell
- Rust standard library 1.98.1: RefCell
- Rust standard library 1.98.1: Mutex
- Rust standard library 1.98.1: LockResult
- Rust standard library 1.98.1: RwLock
- Rust standard library 1.98.1: Arc
Leave a Reply