Skip to content

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.

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 Send and Sync as 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.

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 indirection
Rc<T> shared ownership, one thread
Arc<T> atomic shared ownership, cross-thread capable if T allows
Cell/RefCell<T> interior mutation, one thread
Mutex/RwLock<T> synchronized interior mutation
Atomic* specific lock-free atomic operations

Separate two questions:

  1. Who owns the allocation?
  2. How, if at all, can its contents change?

Arc answers only the first.

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> 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 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 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.

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.

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-Send futures.

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 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.

Ordering defines which memory operations synchronize across threads:

  • Relaxed provides atomicity but no cross-location ordering;
  • Acquire/Release establish one-way synchronization;
  • AcqRel combines them for read-modify-write;
  • SeqCst adds 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 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:

  • String is Send + Sync;
  • Rc<T> is neither;
  • Arc<T> is Send + Sync when T is;
  • RefCell<T> is not Sync;
  • Mutex<T> can be Sync when guarded T can 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.

Shared mutable state is not the only concurrency design:

many producers ──bounded commands──▶ one state owner
└── replies / events

Benefits:

  • 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.

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.

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:

  1. stop accepting new work;
  2. cancel or drain bounded jobs;
  3. wait with a deadline;
  4. flush trace/storage;
  5. release model/runtime resources on an allowed thread;
  6. report incomplete shutdown.

Reference counting alone does not order shutdown.

Run:

Terminal window
cd rust/rust-ai-engineering
cargo run -q -p mosaic-domain --example smart_pointers_and_shared_state
cargo test --workspace --examples

Expected output:

accepted reservations: 2; rejected: 1

The example:

  • uses Box<Plan> for recursive indirection;
  • shares one AtomicU32 budget through Arc;
  • 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:

  1. inspect lock acquisition and guard scope;
  2. add wait/hold duration metrics;
  3. reproduce with a scripted delayed provider;
  4. determine which data the provider call actually needs;
  5. release the guard before await or move state to one owner;
  6. 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:

  1. identify all Arc clone sites;
  2. inspect registries, callbacks, traces, and caches;
  3. use strong-count metrics only for diagnostics;
  4. look for cycles through callback captures;
  5. replace non-owning edges with Weak;
  6. add explicit registry eviction and lifecycle tests.

Reference counts make lifetime implicit across owners. Ownership diagrams remain necessary.

It combines two independent decisions and often centralizes contention.

It moves borrow errors to runtime and complicates architecture.

Readers can observe invalid cross-field states.

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.

Use weak/non-owning edges and lifecycle tests.

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:

  • Box adds allocation/indirection;
  • Rc increments non-atomic counts;
  • Arc increments 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.

  1. What two questions do Arc and Mutex answer separately?
  2. Why is Rc<T> not thread-safe?
  3. What does Sync mean?
  4. Why can atomics fail to preserve a multi-field invariant?
  1. Add a non-owning parent edge to a plan graph with Weak.
  2. Replace the atomic budget with a mutex-protected (remaining, spent) invariant.
  3. Build a one-owner budget task using a bounded Tokio channel.
  4. Add a cancellation test proving a semaphore permit is released on drop.
  5. Use Loom to model two threads reserving the last budget unit.

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.

Reproduce:

  • RefCell double mutable borrow panic;
  • Rc rejected by cross-thread spawn;
  • a lock guard held across delay;
  • an Arc cycle;
  • an atomic underflow in a naïve load-then-store implementation.

Repair each semantically; do not add unsafe auto-trait implementations.

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.

  • Does Arc<T> permit mutation of T?
  • When does the value inside an Arc drop?
  • Why can a tiny slice/callback keep a large Arc alive?
  • What does RefCell do when borrow rules are violated?
  • Can Arc<RefCell<T>> be shared across threads?
  • What proof justifies Relaxed ordering?
  • When is one-owner state easier than locks?
  • Does Send + Sync imply unlimited parallel inference?
  • I separate ownership from mutation.
  • I choose Box, Rc, and Arc from 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.