Smart Pointers, Interior Mutability, Send, and Sync
Rust’s pointer-like types combine ownership, indirection, sharing, mutation, and thread-safety in different ways. There is no universal “smart pointer.”
AI applications make the distinction concrete: model runtimes may be expensive shared objects, decoded media can be immutable shared data, run state should often have one owner, and rate budgets may need coordinated updates.
Learning objectives
Section titled “Learning objectives”After this chapter, you will be able to:
- choose
Box,Rc,Arc,Cell,RefCell,Mutex,RwLock, and atomics from requirements; - explain why
Arc<T>does not imply mutation or automatic thread safety; - understand
SendandSyncas auto-trait properties of composed fields; - use indirection for recursive and dynamically sized values;
- identify runtime borrow panics and lock/await hazards;
- model a shared atomic budget with explicit memory ordering;
- choose message passing or one-owner state over broad locks;
- run a recursive plan plus concurrent bounded-reservation example.
Dependencies and downstream use
Section titled “Dependencies and downstream use”This chapter depends on ownership, borrowing, traits, closures, and runtime trait objects. It prepares for Tokio tasks/channels, provider registries, shared model residency, caches, trace sinks, and native runtime handles.
Box<T> one owner + heap indirectionRc<T> shared ownership, one threadArc<T> atomic shared ownership, cross-thread capable if T allowsCell/RefCell<T> interior mutation, one threadMutex/RwLock<T> synchronized interior mutationAtomic* specific lock-free atomic operationsSeparate two questions:
- Who owns the allocation?
- How, if at all, can its contents change?
Arc answers only the first.
Box<T>: one owner, one indirection
Section titled “Box<T>: one owner, one indirection”Box<T> owns a value on the heap:
enum Plan { Step(String), Sequence { first: Box<Plan>, second: Box<Plan>, },}The enum needs indirection because otherwise Plan would contain itself with infinite compile-time
size. A box has known pointer size.
Other uses:
- owning a dynamically sized trait object (
Box<dyn Tool>); - isolating a very large variant behind indirection;
- owning an FFI/runtime handle in a stable wrapper;
- recursive syntax trees and execution plans.
Moving a box moves its pointer/ownership, not the allocation. Boxing every value is not an optimization; it adds allocation and pointer chasing.
Rc<T>: shared ownership on one thread
Section titled “Rc<T>: shared ownership on one thread”Rc<T> reference-counts an allocation without atomic operations:
use std::rc::Rc;
let schema = Rc::new(schema);let parser_schema = Rc::clone(&schema);Use it for single-threaded graphs, trees with shared nodes, or UI/local parser structures. Rc<T> is
not Send or Sync; the counter itself is not thread-safe.
If an async runtime can move a task across worker threads, capturing Rc prevents that future from
being Send. Do not change to Arc automatically. First decide whether the state should remain on
one owner thread/task.
Reference cycles leak logical memory:
parent Rc ─▶ child Rc ▲ │ └──── Rc ◀──┘Use Weak<T> for non-owning backreferences and design clear graph ownership. Weak upgrade can
fail after all strong owners drop; handle None.
Arc<T>: shared ownership with an atomic count
Section titled “Arc<T>: shared ownership with an atomic count”Arc<T> lets separately owned components keep one allocation alive:
let model: Arc<dyn Model> = registry.resolve(&id)?;let for_run = Arc::clone(&model);It is Send/Sync only when T satisfies the corresponding requirements. Arc<RefCell<T>> does
not become thread-safe because the outer count is atomic.
Good uses:
- immutable configuration snapshots;
- model adapters safe for concurrent calls;
- immutable media buffers (
Arc<[u8]>); - shared registries;
- tracing/cancellation primitives designed for sharing.
Costs:
- atomic count updates on clone/drop;
- delayed release while any owner remains;
- ownership can become hard to audit;
- cycles remain possible.
Use Arc::clone(&value) to signal a handle clone during review.
Interior mutability separates access from outer mutability
Section titled “Interior mutability separates access from outer mutability”Normally, mutation requires &mut T. Interior-mutability types permit mutation through &self while
enforcing rules in another way.
Cell<T>
Section titled “Cell<T>”Cell supports get/set/replace for suitable values without handing out references to the inner
value. It is useful for small single-threaded flags/counters.
RefCell<T>
Section titled “RefCell<T>”RefCell enforces shared-versus-exclusive borrow rules at runtime:
let state = RefCell::new(State::default());state.borrow_mut().advance();Violating the rules panics (or returns an error through try_borrow). This is appropriate only when
single-threaded structure makes runtime borrowing manageable.
Rc<RefCell<T>> is common but can produce:
- runtime borrow panics;
- reference cycles;
- unclear mutation ownership;
- code that is hard to make concurrent later.
Use it intentionally, not as a universal borrow-checker escape hatch.
Mutexes and read-write locks
Section titled “Mutexes and read-write locks”Mutex<T> allows one guard at a time. RwLock<T> allows either multiple readers or one writer.
let guard = state.lock().map_err(StateError::Poisoned)?;The standard-library mutex can become poisoned after a panic while held. Decide whether recovery, inspection, or process termination is appropriate; do not blindly unwrap in a long-running service.
RwLock helps only when read parallelism and contention patterns justify its extra complexity.
Writer starvation/fairness depends on implementation.
Do not hold a guard across .await
Section titled “Do not hold a guard across .await”A synchronous lock guard across suspension can:
- block an executor worker or other tasks;
- create lock-order deadlocks;
- make cancellation retain the lock unexpectedly;
- produce non-
Sendfutures.
Extract/cloned bounded data under the lock, release the guard, then await. If state must be serialized through async operations, use one owner task and a bounded channel or a carefully designed async mutex with documented critical sections.
Atomics for narrow shared state
Section titled “Atomics for narrow shared state”Atomics provide operations on particular primitive values. The companion uses AtomicU32 for a
shared call budget:
fn try_reserve(&self) -> bool { self.remaining .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { remaining.checked_sub(1) }) .is_ok()}fetch_update applies the closure to the latest observed value and retries if another thread wins.
checked_sub prevents underflow.
Atomics are not “a faster mutex” for arbitrary invariants. If several fields must change together, one atomic per field can expose inconsistent combinations. Use a lock, one-owner state machine, or a carefully designed atomic protocol proven under concurrency.
Memory ordering
Section titled “Memory ordering”Ordering defines which memory operations synchronize across threads:
Relaxedprovides atomicity but no cross-location ordering;Acquire/Releaseestablish one-way synchronization;AcqRelcombines them for read-modify-write;SeqCstadds the strongest global ordering model.
The example uses Acquire/AcqRel conservatively. In a standalone counter where no other data is
published, Relaxed may be sufficient. Choose from the proof, not intuition. Concurrency model tests
and tools such as Loom can explore schedules, but they do not replace a documented invariant.
Send and Sync
Section titled “Send and Sync”Send means ownership of a value can transfer to another thread safely.
Sync means shared references &T can be used from multiple threads safely; equivalently, &T is
Send.
They are unsafe auto traits: safe composed types receive them based on fields. Examples:
StringisSend + Sync;Rc<T>is neither;Arc<T>isSend + SyncwhenTis;RefCell<T>is notSync;Mutex<T>can beSyncwhen guardedTcan be sent.
You can assert requirements at compile time:
fn assert_send_sync<T: Send + Sync>() {}assert_send_sync::<SharedBudget>();Never write unsafe implementations for a native pointer wrapper just to satisfy Tokio. Prove the foreign runtime contract and isolate it. A thread-confined runtime often belongs behind a dedicated worker.
One owner plus messages
Section titled “One owner plus messages”Shared mutable state is not the only concurrency design:
many producers ──bounded commands──▶ one state owner │ └── replies / eventsBenefits:
- invariants update in one sequential place;
- no lock held across provider await if the owner controls the sequence;
- cancellation and queue capacity are explicit;
- easier event tracing.
Costs:
- the owner can bottleneck;
- request/reply and shutdown protocols need design;
- messages may copy/move data;
- durable recovery still requires persistence.
Use shared immutable state freely when appropriate. For mutation, prefer one clear owner unless measurement and access patterns justify shared synchronization.
Shared budgets and semaphores
Section titled “Shared budgets and semaphores”The atomic example reserves a scalar budget but does not wait. Production concurrency limits often use a semaphore:
- acquire permit before expensive work;
- own permit for the exact resource lifetime;
- release on drop, including error/cancellation;
- cap waiting queue/time;
- separate global, tenant, provider, and GPU capacities.
Do not use one atomic remaining count as a complete rate limiter. Time windows, fairness, retries, and distributed replicas require stronger designs.
Drop and shutdown
Section titled “Drop and shutdown”Dropping the last Arc runs the inner destructor. For native model runtimes, that destructor may be
expensive or thread-sensitive.
Production services need explicit lifecycle:
- stop accepting new work;
- cancel or drain bounded jobs;
- wait with a deadline;
- flush trace/storage;
- release model/runtime resources on an allowed thread;
- report incomplete shutdown.
Reference counting alone does not order shutdown.
Runnable companion implementation
Section titled “Runnable companion implementation”Run:
cd rust/rust-ai-engineeringcargo run -q -p mosaic-domain --example smart_pointers_and_shared_statecargo test --workspace --examplesExpected output:
accepted reservations: 2; rejected: 1The example:
- uses
Box<Plan>for recursive indirection; - shares one
AtomicU32budget throughArc; - starts three operating-system threads competing for two permits;
- accepts exactly two without underflow;
- compile-time asserts
SharedBudget: Send + Sync; - tests repeated reservation at zero.
The order of winning threads is intentionally unspecified; the invariant is the accepted count.
Failure investigation: service hangs under provider latency
Section titled “Failure investigation: service hangs under provider latency”Symptom: unrelated requests stall while one provider call is slow.
Likely cause: a global mutex guard remains held across .await.
Investigation:
- inspect lock acquisition and guard scope;
- add wait/hold duration metrics;
- reproduce with a scripted delayed provider;
- determine which data the provider call actually needs;
- release the guard before await or move state to one owner;
- add timeout/cancellation tests.
Do not “fix” by increasing thread count; lock serialization remains.
Failure investigation: memory never releases
Section titled “Failure investigation: memory never releases”Symptom: model/artifact should unload, but the last owner is unclear.
Actions:
- identify all
Arcclone sites; - inspect registries, callbacks, traces, and caches;
- use strong-count metrics only for diagnostics;
- look for cycles through callback captures;
- replace non-owning edges with
Weak; - add explicit registry eviction and lifecycle tests.
Reference counts make lifetime implicit across owners. Ownership diagrams remain necessary.
Common failure modes
Section titled “Common failure modes”Arc<Mutex<T>> by reflex
Section titled “Arc<Mutex<T>> by reflex”It combines two independent decisions and often centralizes contention.
Rc<RefCell<T>> as a borrow-checker escape
Section titled “Rc<RefCell<T>> as a borrow-checker escape”It moves borrow errors to runtime and complicates architecture.
One atomic per related field
Section titled “One atomic per related field”Readers can observe invalid cross-field states.
Holding guards across await
Section titled “Holding guards across await”Narrow critical sections or use one-owner/message architecture.
Assuming Arc makes native handles thread-safe
Section titled “Assuming Arc makes native handles thread-safe”Thread safety comes from T and its foreign contract.
Reference cycles
Section titled “Reference cycles”Use weak/non-owning edges and lifecycle tests.
Security and performance implications
Section titled “Security and performance implications”Security:
- do not make authority tokens cloneable/shared without policy;
- prevent shared state from becoming a capability grab bag;
- treat poisoned/inconsistent state as an incident, not a routine unwrap;
- cap queues and shared registries;
- isolate unsafe foreign handles and prove
Send/Sync.
Performance:
Boxadds allocation/indirection;Rcincrements non-atomic counts;Arcincrements atomic counts and can contend under extreme clone rates;- locks add contention, scheduling, and cache-line traffic;
- false sharing can make independent atomics contend;
- one-owner channels add queueing and message overhead.
Inference dominates many paths, but shared-state design determines tail latency and overload
behavior. Measure lock wait/hold time, queue depth, permit saturation, and retained Arc lifetime.
Exercises
Section titled “Exercises”Recall
Section titled “Recall”- What two questions do
ArcandMutexanswer separately? - Why is
Rc<T>not thread-safe? - What does
Syncmean? - Why can atomics fail to preserve a multi-field invariant?
Implementation
Section titled “Implementation”- Add a non-owning parent edge to a plan graph with
Weak. - Replace the atomic budget with a mutex-protected
(remaining, spent)invariant. - Build a one-owner budget task using a bounded Tokio channel.
- Add a cancellation test proving a semaphore permit is released on drop.
- Use Loom to model two threads reserving the last budget unit.
Design
Section titled “Design”Choose an ownership/mutation strategy for:
- immutable model configuration;
- thread-confined local inference runtime;
- global read-mostly model registry;
- per-run trace buffer;
- tenant rate limits across replicas;
- callback graph with parent references;
- decoded video shared by two readers.
State owner, mutation, thread boundary, shutdown, and overload behavior.
Failure lab
Section titled “Failure lab”Reproduce:
RefCelldouble mutable borrow panic;Rcrejected by cross-thread spawn;- a lock guard held across delay;
- an
Arccycle; - an atomic underflow in a naïve load-then-store implementation.
Repair each semantically; do not add unsafe auto-trait implementations.
Solution guidance
Section titled “Solution guidance”The mutex pair updates both fields in one critical section and checks
remaining + spent == initial. Return a typed exhausted error.
The one-owner task should receive reservation requests and send responses through oneshot channels. Bound its input channel and define shutdown.
A semaphore permit should be owned by the task performing the bounded operation; dropping the future/task releases it. Test with a timeout and a second acquisition.
Distributed tenant limits cannot rely on process-local atomics. Use a centralized/partitioned rate service or datastore algorithm with stated consistency.
Check your understanding
Section titled “Check your understanding”- Does
Arc<T>permit mutation ofT? - When does the value inside an
Arcdrop? - Why can a tiny slice/callback keep a large
Arcalive? - What does
RefCelldo when borrow rules are violated? - Can
Arc<RefCell<T>>be shared across threads? - What proof justifies
Relaxedordering? - When is one-owner state easier than locks?
- Does
Send + Syncimply unlimited parallel inference?
Completion checklist
Section titled “Completion checklist”- I separate ownership from mutation.
- I choose
Box,Rc, andArcfrom lifetime/thread needs. - I treat interior mutability as an explicit invariant mechanism.
- I avoid guards across await.
- I use atomics only for narrow proven state.
- I never force foreign handles to be
Send/Sync. - I design shutdown beyond “drop the last Arc.”
- I ran the concurrent reservation example and zero-budget test.