How do I work with UnsafeCell in Rust?

Walkthrough

UnsafeCell is the foundational primitive for interior mutability in Rust. It's the core building block upon which Cell, RefCell, Mutex, and other types are built. Unlike those safe wrappers, UnsafeCell provides raw access to interior mutability without any runtime checks or safety guarantees.

Key characteristics:

  • Zero overhead — No runtime cost, just wraps the value
  • No safety guarantees — You must uphold Rust's invariants manually
  • unsafe required — All access requires unsafe blocks
  • Interior mutability primitive — Foundation for safe abstractions

The only guarantee UnsafeCell provides: it prevents certain compiler optimizations that would be invalid with interior mutability (like assuming a &T never changes). All safety must be manually ensured.

When to use UnsafeCell:

  • Building safe abstractions (like Cell or RefCell)
  • FFI with C code that modifies through pointers
  • Performance-critical code where safe wrappers add overhead
  • Implementing custom synchronization primitives

Warning: Incorrect use of UnsafeCell leads to undefined behavior. Always prefer safe alternatives when possible.

Code Examples

Basic UnsafeCell Usage

use std::cell::UnsafeCell;
 
fn main() {
    let cell = UnsafeCell::new(5);
    
    // Safe: we have exclusive access, no other references exist
    unsafe {
        // Get a raw pointer to the inner value
        let ptr = cell.get();
        
        // Read the value
        let value = *ptr;
        println!("Value: {}", value);
        
        // Modify the value
        *ptr = 10;
        println!("Modified: {}", *ptr);
    }
}

Building a Simple Cell

use std::cell::UnsafeCell;
 
// A minimal implementation of Cell for Copy types
struct MyCell<T> {
    value: UnsafeCell<T>,
}
 
// Cell is not Sync because interior mutability without synchronization
// is not thread-safe
impl<T> !Sync for MyCell<T> {}
 
impl<T: Copy> MyCell<T> {
    fn new(value: T) -> Self {
        Self {
            value: UnsafeCell::new(value),
        }
    }
    
    fn get(&self) -> T {
        // Safe because T is Copy (no aliasing issues)
        // and we never return a reference, just a copy
        unsafe { *self.value.get() }
    }
    
    fn set(&self, value: T) {
        // Safe because we have a valid UnsafeCell and T is Copy
        unsafe {
            *self.value.get() = value;
        }
    }
}
 
fn main() {
    let cell = MyCell::new(5);
    println!("Initial: {}", cell.get());
    
    cell.set(10);
    println!("After set: {}", cell.get());
    
    // Works through shared reference!
    let reference = &cell;
    reference.set(20);
    println!("After set via ref: {}", cell.get());
}

Building a RefCell

use std::cell::UnsafeCell;
use std::cell::Cell;
 
struct MyRefCell<T> {
    value: UnsafeCell<T>,
    borrow_state: Cell<isize>, // 0 = none, >0 = immutable borrows, -1 = mutable
}
 
impl<T> MyRefCell<T> {
    fn new(value: T) -> Self {
        Self {
            value: UnsafeCell::new(value),
            borrow_state: Cell::new(0),
        }
    }
    
    fn borrow(&self) -> Ref<T> {
        let state = self.borrow_state.get();
        
        if state < 0 {
            panic!("Already mutably borrowed");
        }
        
        self.borrow_state.set(state + 1);
        
        Ref {
            value: unsafe { &*self.value.get() },
            state: &self.borrow_state,
        }
    }
    
    fn borrow_mut(&self) -> RefMut<T> {
        let state = self.borrow_state.get();
        
        if state != 0 {
            panic!("Already borrowed");
        }
        
        self.borrow_state.set(-1);
        
        RefMut {
            value: unsafe { &mut *self.value.get() },
            state: &self.borrow_state,
        }
    }
}
 
struct Ref<'a, T> {
    value: &'a T,
    state: &'a Cell<isize>,
}
 
impl<T> std::ops::Deref for Ref<'_, T> {
    type Target = T;
    fn deref(&self) -> &T {
        self.value
    }
}
 
impl<T> Drop for Ref<'_, T> {
    fn drop(&mut self) {
        self.state.set(self.state.get() - 1);
    }
}
 
struct RefMut<'a, T> {
    value: &'a mut T,
    state: &'a Cell<isize>,
}
 
impl<T> std::ops::Deref for RefMut<'_, T> {
    type Target = T;
    fn deref(&self) -> &T {
        self.value
    }
}
 
impl<T> std::ops::DerefMut for RefMut<'_, T> {
    fn deref_mut(&mut self) -> &mut T {
        self.value
    }
}
 
impl<T> Drop for RefMut<'_, T> {
    fn drop(&mut self) {
        self.state.set(0);
    }
}
 
fn main() {
    let cell = MyRefCell::new(vec![1, 2, 3]);
    
    {
        let mut borrowed = cell.borrow_mut();
        borrowed.push(4);
    }
    
    {
        let borrowed = cell.borrow();
        println!("Value: {:?}", *borrowed);
    }
}

FFI with C Code

use std::cell::UnsafeCell;
use std::ffi::c_int;
 
// A C library might expect to modify data through a pointer
// even though we only have a shared reference
extern "C" {
    fn c_modify_data(data: *mut c_int, len: usize);
}
 
struct CBuffer {
    // UnsafeCell tells the compiler this might change through a shared reference
    data: UnsafeCell<Vec<c_int>>,
}
 
impl CBuffer {
    fn new(data: Vec<c_int>) -> Self {
        Self {
            data: UnsafeCell::new(data),
        }
    }
    
    fn modify_via_c(&self) {
        unsafe {
            let data = &mut *self.data.get();
            c_modify_data(data.as_mut_ptr(), data.len());
        }
    }
    
    fn get_data(&self) -> &[c_int] {
        unsafe { &*self.data.get() }
    }
}
 
// Mock C function for demonstration
#[no_mangle]
unsafe extern "C" fn c_modify_data(data: *mut c_int, len: usize) {
    for i in 0..len {
        *data.add(i) *= 2;
    }
}
 
fn main() {
    let buffer = CBuffer::new(vec![1, 2, 3, 4, 5]);
    
    println!("Before: {:?}", buffer.get_data());
    
    // Called with &self, but C code modifies the data
    buffer.modify_via_c();
    
    println!("After: {:?}", buffer.get_data());
}

Zero-Cost Flag with UnsafeCell

use std::cell::UnsafeCell;
 
struct OnceFlag {
    triggered: UnsafeCell<bool>,
}
 
impl OnceFlag {
    const fn new() -> Self {
        Self {
            triggered: UnsafeCell::new(false),
        }
    }
    
    fn trigger(&self) {
        unsafe {
            *self.triggered.get() = true;
        }
    }
    
    fn is_triggered(&self) -> bool {
        unsafe { *self.triggered.get() }
    }
}
 
fn main() {
    let flag = OnceFlag::new();
    
    println!("Triggered: {}", flag.is_triggered());
    flag.trigger();
    println!("Triggered: {}", flag.is_triggered());
}

Splitting a Slice Safely

use std::cell::UnsafeCell;
 
struct SplitSlice<'a, T> {
    data: UnsafeCell<&'a mut [T]>,
}
 
impl<'a, T> SplitSlice<'a, T> {
    fn new(data: &'a mut [T]) -> Self {
        Self {
            data: UnsafeCell::new(data),
        }
    }
    
    // Get mutable access to a portion of the slice
    // Safety: each index can only be accessed once
    fn get_mut_portion(&self, start: usize, end: usize) -> Option<&mut [T]> {
        unsafe {
            let data = &mut *self.data.get();
            if start <= end && end <= data.len() {
                Some(&mut data[start..end])
            } else {
                None
            }
        }
    }
}
 
fn main() {
    let mut data = vec![1, 2, 3, 4, 5, 6];
    let split = SplitSlice::new(&mut data);
    
    // This would be UB if called twice with overlapping ranges!
    // Only use this pattern when you can guarantee non-overlapping access
    if let Some(portion) = split.get_mut_portion(0, 3) {
        for item in portion.iter_mut() {
            *item *= 2;
        }
    }
    
    println!("Data: {:?}", data);
}

Custom Mutex Implementation

use std::cell::UnsafeCell;
use std::sync::atomic::{AtomicBool, Ordering};
 
struct SimpleMutex<T> {
    locked: AtomicBool,
    value: UnsafeCell<T>,
}
 
// Safe because we use atomic operations for synchronization
unsafe impl<T: Send> Sync for SimpleMutex<T> {}
unsafe impl<T: Send> Send for SimpleMutex<T> {}
 
impl<T> SimpleMutex<T> {
    fn new(value: T) -> Self {
        Self {
            locked: AtomicBool::new(false),
            value: UnsafeCell::new(value),
        }
    }
    
    fn lock(&self) -> MutexGuard<T> {
        // Spin until we acquire the lock
        while self.locked.compare_exchange(
            false,
            true,
            Ordering::Acquire,
            Ordering::Relaxed,
        ).is_err() {
            // In real code, use a proper wait mechanism
            std::hint::spin_loop();
        }
        
        MutexGuard {
            mutex: self,
        }
    }
}
 
struct MutexGuard<'a, T> {
    mutex: &'a SimpleMutex<T>,
}
 
impl<T> std::ops::Deref for MutexGuard<'_, T> {
    type Target = T;
    fn deref(&self) -> &T {
        unsafe { &*self.mutex.value.get() }
    }
}
 
impl<T> std::ops::DerefMut for MutexGuard<'_, T> {
    fn deref_mut(&mut self) -> &mut T {
        unsafe { &mut *self.mutex.value.get() }
    }
}
 
impl<T> Drop for MutexGuard<'_, T> {
    fn drop(&mut self) {
        self.mutex.locked.store(false, Ordering::Release);
    }
}
 
fn main() {
    let mutex = SimpleMutex::new(vec![1, 2, 3]);
    
    {
        let mut data = mutex.lock();
        data.push(4);
    }
    
    println!("Data: {:?}", *mutex.lock());
}

UnsafeCell with const fn

use std::cell::UnsafeCell;
 
// UnsafeCell is the only way to have interior mutability in const context
const COUNTER: UnsafeCell<u32> = UnsafeCell::new(0);
 
// Note: This is extremely dangerous in practice!
// Modifying static data without synchronization is undefined behavior
// in multi-threaded contexts.
 
fn increment_counter() -> u32 {
    unsafe {
        let ptr = COUNTER.get();
        *ptr += 1;
        *ptr
    }
}
 
fn main() {
    println!("Counter: {}", increment_counter());
    println!("Counter: {}", increment_counter());
    println!("Counter: {}", increment_counter());
}

Alias Analysis and UnsafeCell

use std::cell::UnsafeCell;
 
fn main() {
    // WITHOUT UnsafeCell: Compiler assumes &x never changes
    let mut x = 5;
    let ref_x = &x;
    // x = 10; // This would fail to compile
    println!("ref_x: {}", ref_x);
    
    // WITH UnsafeCell: Compiler knows &x might change
    let cell = UnsafeCell::new(5);
    
    unsafe {
        let ptr1 = cell.get();
        let ptr2 = cell.get(); // Multiple pointers to same location!
        
        // The compiler won't assume *ptr1 stays the same
        *ptr1 = 10;
        
        // This is valid because UnsafeCell disables the no-alias optimization
        println!("ptr2: {}", *ptr2); // Will print 10
    }
}

Building a Lazy Cell

use std::cell::UnsafeCell;
use std::sync::atomic::{AtomicU8, Ordering};
 
struct LazyCell<T, F = fn() -> T> {
    state: AtomicU8,  // 0 = not initialized, 1 = initializing, 2 = initialized
    value: UnsafeCell<Option<T>>,
    init: F,
}
 
// Safety: we use atomic operations for synchronization
unsafe impl<T: Send + Sync, F: Send + Sync> Sync for LazyCell<T, F> {}
unsafe impl<T: Send, F: Send> Send for LazyCell<T, F> {}
 
impl<T, F: FnOnce() -> T> LazyCell<T, F> {
    const fn new(f: F) -> Self {
        Self {
            state: AtomicU8::new(0),
            value: UnsafeCell::new(None),
            init: f,
        }
    }
    
    fn get(&self) -> &T {
        match self.state.load(Ordering::Acquire) {
            2 => unsafe {
                // Already initialized
                (*self.value.get()).as_ref().unwrap()
            },
            _ => self.initialize(),
        }
    }
    
    #[cold]
    fn initialize(&self) -> &T {
        // Simple spin lock for demonstration
        while self.state.compare_exchange(
            0,
            1,
            Ordering::AcqRel,
            Ordering::Acquire,
        ).is_err() {
            if self.state.load(Ordering::Acquire) == 2 {
                return unsafe {
                    (*self.value.get()).as_ref().unwrap()
                };
            }
            std::hint::spin_loop();
        }
        
        let value = (self.init)();
        unsafe {
            *self.value.get() = Some(value);
        }
        self.state.store(2, Ordering::Release);
        
        unsafe { (*self.value.get()).as_ref().unwrap() }
    }
}
 
fn main() {
    let lazy = LazyCell::new(|| {
        println!("Computing value...");
        42
    });
    
    println!("First access");
    println!("Value: {}", lazy.get());
    
    println!("Second access");
    println!("Value: {}", lazy.get());
}

Common Pitfalls

use std::cell::UnsafeCell;
 
fn main() {
    let cell = UnsafeCell::new(5);
    
    // PITFALL 1: Creating aliasing mutable references
    // This is UNDEFINED BEHAVIOR!
    // unsafe {
    //     let ref1 = &mut *cell.get();
    //     let ref2 = &mut *cell.get(); // UB! Two mutable refs to same data
    //     *ref1 = 10;
    //     *ref2 = 20;
    // }
    
    // PITFALL 2: Returning reference with wrong lifetime
    // This would compile but is UB if called multiple times:
    // fn bad_borrow(cell: &UnsafeCell<i32>) -> &mut i32 {
    //     unsafe { &mut *cell.get() }
    // }
    // let r1 = bad_borrow(&cell);
    // let r2 = bad_borrow(&cell); // UB!
    
    // CORRECT: Ensure exclusive access
    unsafe {
        let ptr = cell.get();
        *ptr = 10;  // Only one access at a time
    }
    
    // PITFALL 3: Thread safety
    // UnsafeCell is not Sync by default for a reason!
    // Use atomics or Mutex for thread-safe interior mutability
    
    println!("Safe usage: {}", unsafe { *cell.get() });
}

Summary

UnsafeCell Methods:

Method Description
new(value) Create new UnsafeCell
get() Get *mut T pointer (unsafe to use)
into_inner() Consume and return inner value (safe)
raw_get() Get *const T pointer (unsafe to use)

When to Use UnsafeCell:

Use Case Appropriate?
Building safe abstractions āœ… Yes
FFI with C code āœ… Yes
Performance optimization āš ļø Rarely needed
Simple interior mutability āŒ Use Cell/RefCell
Thread-safe sharing āŒ Use Mutex/RwLock

Safety Invariants to Maintain:

  1. No aliasing mutable references — Never create two &mut T to the same data
  2. No reads during mutation — Don't read while a mutable reference exists
  3. Thread synchronization — Use atomics/locks for multi-threaded access
  4. Valid pointer dereference — Ensure the pointer is always valid

Key Points:

  • UnsafeCell is the foundation for all interior mutability types
  • All access requires unsafe blocks
  • You must manually uphold Rust's safety invariants
  • The compiler won't assume &T behind UnsafeCell is immutable
  • Use safe wrappers (Cell, RefCell, Mutex) whenever possible
  • Incorrect use leads to undefined behavior
  • UnsafeCell is not Sync by default (and shouldn't be made Sync without synchronization)