Home/Ownership & Borrowing Patterns/Interior Mutability Patterns

Interior Mutability Patterns

When to use Cell, RefCell, and Mutex

advanced
cellrefcellmutex
šŸŽ® Interactive Playground

What is Interior Mutability?

Interior mutability is a design pattern in Rust that allows you to mutate data even when there are immutable references to that data. This seemingly breaks Rust's borrowing rules, but it does so safely by moving the borrow checking from compile-time to runtime (or using atomics for thread-safety).

The Core Principle:
  • Normal Rust: &T = immutable, &mut T = mutable (enforced at compile-time)
  • Interior Mutability: &Cell or &RefCell = can mutate through immutable reference (checked at runtime)

When Do You Need Interior Mutability?

Use Cases:

  1. Caching: Storing computed results without requiring mutable access
  2. Reference Counting: Mutating reference counts while having shared ownership
  3. Observer Pattern: Notifying observers without mutable self
  4. Shared State in Concurrent Systems: Multiple threads need to modify shared data
  5. Breaking Circular Dependencies: Graph data structures, DOM trees

Real-World Example 1: HTTP Response Cache (Web)

use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;

/// An HTTP cache that can be shared across request handlers
/// Uses RefCell for interior mutability - cache updates don't require &mut self
struct HttpCache {
    cache: RefCell<HashMap<String, CachedResponse>>,
    max_size: usize,
}

#[derive(Clone)]
struct CachedResponse {
    body: String,
    headers: Vec<(String, String)>,
    status: u16,
    timestamp: u64,
}

impl HttpCache {
    fn new(max_size: usize) -> Self {
        Self {
            cache: RefCell::new(HashMap::new()),
            max_size,
        }
    }

    /// Get from cache - note the &self, not &mut self!
    fn get(&self, url: &str) -> Option<CachedResponse> {
        // Borrow the cache immutably
        let cache = self.cache.borrow();
        cache.get(url).cloned()
    }

    /// Store in cache - still only needs &self
    fn set(&self, url: String, response: CachedResponse) {
        // Borrow the cache mutably at runtime
        let mut cache = self.cache.borrow_mut();

        // Evict old entries if cache is full
        if cache.len() >= self.max_size {
            if let Some(oldest_key) = cache.keys().next().cloned() {
                cache.remove(&oldest_key);
            }
        }

        cache.insert(url, response);
    }

    /// Clear expired entries - also only needs &self
    fn evict_expired(&self, current_time: u64, ttl: u64) {
        let mut cache = self.cache.borrow_mut();
        cache.retain(|_, response| {
            current_time - response.timestamp < ttl
        });
    }
}

// Usage in a web server context
fn handle_request(url: &str, cache: &HttpCache) -> String {
    // Try cache first
    if let Some(cached) = cache.get(url) {
        println!("Cache HIT for {}", url);
        return cached.body;
    }

    println!("Cache MISS for {}", url);

    // Fetch from network (simulated)
    let response = CachedResponse {
        body: format!("Response for {}", url),
        headers: vec![("Content-Type".to_string(), "text/html".to_string())],
        status: 200,
        timestamp: current_timestamp(),
    };

    // Store in cache - note we can do this with just &cache!
    cache.set(url.to_string(), response.clone());

    response.body
}

fn current_timestamp() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs()
}

Why Interior Mutability is Perfect Here:

  1. Shared Ownership: Multiple request handlers can share the same cache
  2. No Mutable References: Each handler only has &HttpCache, not &mut HttpCache
  3. Runtime Flexibility: Cache can be updated during request handling without complex ownership patterns
  4. Clean API: Users don't need to worry about mutable borrows

Real-World Example 2: Connection Pool (Network)

use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;

/// A simple database connection pool
/// Uses RefCell to manage available connections without &mut self
struct ConnectionPool {
    available: RefCell<VecDeque<Connection>>,
    max_connections: usize,
    created_count: RefCell<usize>,
}

struct Connection {
    id: usize,
    // In real code: TCP stream, authentication state, etc.
}

impl ConnectionPool {
    fn new(max_connections: usize) -> Self {
        Self {
            available: RefCell::new(VecDeque::new()),
            max_connections,
            created_count: RefCell::new(0),
        }
    }

    /// Acquire a connection from the pool
    /// Note: &self, not &mut self
    fn acquire(&self) -> Result<Connection, &'static str> {
        // Try to get an available connection
        if let Some(conn) = self.available.borrow_mut().pop_front() {
            return Ok(conn);
        }

        // No available connections - create a new one if under limit
        let mut count = self.created_count.borrow_mut();
        if *count < self.max_connections {
            *count += 1;
            let conn = Connection { id: *count };
            println!("Created new connection {}", conn.id);
            Ok(conn)
        } else {
            Err("Connection pool exhausted")
        }
    }

    /// Return a connection to the pool
    fn release(&self, conn: Connection) {
        println!("Releasing connection {}", conn.id);
        self.available.borrow_mut().push_back(conn);
    }

    /// Get pool statistics without modifying anything
    fn stats(&self) -> (usize, usize) {
        (
            self.available.borrow().len(),
            *self.created_count.borrow(),
        )
    }
}

// Usage
fn perform_database_query(pool: &ConnectionPool, query: &str) -> Result<(), &'static str> {
    // Acquire connection
    let conn = pool.acquire()?;

    // Use connection
    println!("Executing query on connection {}: {}", conn.id, query);

    // Return to pool when done
    pool.release(conn);

    Ok(())
}

Why This Pattern Works:

  • Connection Reuse: Connections can be borrowed and returned without mutable pool access
  • Statistics Tracking: Can track created connections internally
  • Clean Separation: Pool management logic is encapsulated

Cell vs RefCell vs Mutex: When to Use What?

`Cell<T>` - For Copy Types

use std::cell::Cell;

struct Counter {
    count: Cell<u32>,  // u32 is Copy
}

impl Counter {
    fn new() -> Self {
        Self { count: Cell::new(0) }
    }

    fn increment(&self) {  // Note: &self not &mut self
        let current = self.count.get();
        self.count.set(current + 1);
    }

    fn get(&self) -> u32 {
        self.count.get()
    }
}
Use Cell when:
  • āœ… T implements Copy (integers, floats, bool, etc.)
  • āœ… You only need to replace entire values
  • āœ… Single-threaded code
  • āœ… No need for references to inner data

`RefCell<T>` - For Non-Copy Types

use std::cell::RefCell;

struct Logger {
    logs: RefCell<Vec<String>>,  // Vec is NOT Copy
}

impl Logger {
    fn new() -> Self {
        Self { logs: RefCell::new(Vec::new()) }
    }

    fn log(&self, message: String) {
        // Borrow mutably, modify, automatic drop releases borrow
        self.logs.borrow_mut().push(message);
    }

    fn print_logs(&self) {
        // Borrow immutably to read
        for log in self.logs.borrow().iter() {
            println!("{}", log);
        }
    }
}
Use RefCell when:
  • āœ… T does NOT implement Copy
  • āœ… You need references to inner data
  • āœ… Single-threaded code
  • āœ… Runtime borrow checking is acceptable
āš ļø Runtime Panic Risk:
let logger = Logger::new();
let borrow1 = logger.logs.borrow();
let borrow2 = logger.logs.borrow_mut();  // PANIC! Already borrowed immutably

`Mutex<T>` - For Thread-Safe Interior Mutability

use std::sync::Mutex;
use std::thread;

struct SharedCounter {
    count: Mutex<u32>,  // Thread-safe interior mutability
}

impl SharedCounter {
    fn new() -> Self {
        Self { count: Mutex::new(0) }
    }

    fn increment(&self) {
        let mut count = self.count.lock().unwrap();
        *count += 1;
    }  // Lock automatically released when guard drops

    fn get(&self) -> u32 {
        *self.count.lock().unwrap()
    }
}

// Can be safely shared across threads
fn use_across_threads() {
    let counter = std::sync::Arc::new(SharedCounter::new());
    let mut handles = vec![];

    for _ in 0..10 {
        let counter_clone = counter.clone();
        let handle = thread::spawn(move || {
            for _ in 0..100 {
                counter_clone.increment();
            }
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Final count: {}", counter.get());  // Should be 1000
}
Use Mutex when:
  • āœ… Multi-threaded code
  • āœ… Need exclusive access across threads
  • āœ… Can accept lock contention overhead
  • āœ… T doesn't need to be Copy

Real-World Example 3: Security Token Store (Security)

use std::cell::RefCell;
use std::collections::HashMap;

/// Secure token storage with automatic expiration
/// Uses RefCell for thread-local token management
struct TokenStore {
    tokens: RefCell<HashMap<String, Token>>,
}

struct Token {
    value: String,
    issued_at: u64,
    expires_at: u64,
    permissions: Vec<String>,
}

impl TokenStore {
    fn new() -> Self {
        Self {
            tokens: RefCell::new(HashMap::new()),
        }
    }

    /// Issue a new token
    fn issue_token(&self, user_id: &str, permissions: Vec<String>, ttl_secs: u64) -> String {
        let now = current_timestamp();
        let token_value = format!("token_{}_{}", user_id, now);

        let token = Token {
            value: token_value.clone(),
            issued_at: now,
            expires_at: now + ttl_secs,
            permissions,
        };

        self.tokens.borrow_mut().insert(token_value.clone(), token);
        token_value
    }

    /// Validate token and check permissions
    fn validate(&self, token_value: &str, required_permission: &str) -> Result<(), &'static str> {
        let now = current_timestamp();
        let tokens = self.tokens.borrow();

        let token = tokens.get(token_value).ok_or("Invalid token")?;

        // Check expiration
        if now > token.expires_at {
            return Err("Token expired");
        }

        // Check permissions
        if !token.permissions.contains(&required_permission.to_string()) {
            return Err("Insufficient permissions");
        }

        Ok(())
    }

    /// Revoke a token
    fn revoke(&self, token_value: &str) {
        self.tokens.borrow_mut().remove(token_value);
    }

    /// Clean up expired tokens
    fn cleanup_expired(&self) {
        let now = current_timestamp();
        self.tokens.borrow_mut().retain(|_, token| now <= token.expires_at);
    }
}

āš ļø Anti-Patterns and Common Mistakes

āš ļø āŒ Mistake #1: Holding RefCell Borrows Too Long

// BAD: Borrow held across function calls
fn bad_usage(cache: &HttpCache) {
    let cache_borrow = cache.cache.borrow();  // Borrow starts

    // ... lots of code ...

    some_function_that_might_borrow_cache(cache);  // PANIC if it tries to borrow_mut!

    // Borrow only released here
}

// GOOD: Minimize borrow scope
fn good_usage(cache: &HttpCache) {
    let data = {
        let cache_borrow = cache.cache.borrow();
        cache_borrow.get("key").cloned()
    };  // Borrow released here

    some_function_that_might_borrow_cache(cache);  // Safe!
}

āš ļø āŒ Mistake #2: Using RefCell When Mutex is Needed

// BAD: RefCell is NOT thread-safe!
use std::cell::RefCell;
use std::sync::Arc;

let data = Arc::new(RefCell::new(vec![1, 2, 3]));  // āŒ RefCell is not Send!
// Won't compile - RefCell cannot be shared across threads

// GOOD: Use Mutex for thread-safety
use std::sync::Mutex;

let data = Arc::new(Mutex::new(vec![1, 2, 3]));  // āœ… Mutex is Send + Sync

āš ļø āŒ Mistake #3: Unnecessary Interior Mutability

// BAD: Using RefCell when &mut self would work
struct Config {
    settings: RefCell<HashMap<String, String>>,
}

impl Config {
    fn update(&self, key: String, value: String) {  // Takes &self
        self.settings.borrow_mut().insert(key, value);
    }
}

// GOOD: Use &mut self if you control the API
struct Config {
    settings: HashMap<String, String>,
}

impl Config {
    fn update(&mut self, key: String, value: String) {  // Takes &mut self
        self.settings.insert(key, value);
    }
}
Rule of Thumb: Only use interior mutability when you truly cannot get &mut self.

When to Use Interior Mutability

āœ… Use Interior Mutability When:

  1. Shared Ownership with Mutation: Multiple owners need to modify shared data (Rc>)
  2. Caching/Memoization: Computing values lazily without &mut self
  3. Observer Pattern: Notifying observers without mutable access
  4. Breaking Circular References: Graph structures, UI trees
  5. API Constraints: External trait requires &self but you need mutation

āŒ Avoid Interior Mutability When:

  1. &mut self Works: Don't add runtime cost unnecessarily
  2. Thread Safety Needed: Use Mutex/RwLock instead of RefCell
  3. Performance Critical: Runtime checks have overhead
  4. Simple Use Cases: Cell for Copy types, direct mutation for others

Performance Characteristics

Cell<T>

  • Cost: Zero overhead for Copy types
  • Safety: Compile-time type safety + runtime move semantics

RefCell<T>

  • Cost: Two usize fields for borrow tracking (~16 bytes overhead)
  • Runtime checks: On every borrow/borrow_mut (~1-2 CPU cycles)
  • Panic risk: Violating borrow rules at runtime

Mutex<T>

  • Cost: Atomic operations + OS synchronization primitives
  • Overhead: 10-100x slower than RefCell for uncontended case
  • Contention: Can block threads, affecting latency

Comparison Table

| Type | Thread-Safe | Runtime Cost | Panic Risk | Use Case |

|------|-------------|--------------|------------|----------|

| Cell | āŒ | Zero | None | Copy types, single-threaded |

| RefCell | āŒ | Low | Yes | Non-Copy types, single-threaded |

| Mutex | āœ… | Medium-High | No (deadlock risk) | Multi-threaded |

| RwLock | āœ… | Medium-High | No (deadlock risk) | Multi-threaded, read-heavy |

| Atomic* | āœ… | Low | None | Primitive types, lock-free |

Exercises

Exercise 1: Implement a Simple LRU Cache

Create an LRU (Least Recently Used) cache using RefCell and track access order.

Hints:
  • Use HashMap for O(1) lookups
  • Use VecDeque or linked list for LRU ordering
  • Interior mutability needed for get() to update access time

Exercise 2: Observer Pattern

Implement an observer pattern where subjects notify observers without needing &mut self.

Hints:
  • observers: RefCell>>
  • Weak references to prevent cycles
  • Interior mutability for registration during callbacks

Exercise 3: Build a Simple Event Logger

Create a thread-local event logger that multiple components can write to without &mut access.

Hints:
  • Consider using thread_local! with RefCell
  • Implement log rotation when size limit reached
  • Use interior mutability for append-only operations

Further Reading

Real-World Usage

šŸ¦€ Tokio Runtime

Tokio's runtime uses RefCell internally for thread-local task queues.

View on GitHub

šŸ¦€ Serde

Serde uses interior mutability for deserializer state that needs mutation during deserialization.

View on GitHub

šŸ¦€ Actix Web

Actix uses Arc> for shared application state across handlers.

View on GitHub

šŸŽ® Try it Yourself

šŸŽ®

Interior Mutability Patterns - Playground

Run this code in the official Rust Playground