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
unsaferequired ā 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
CellorRefCell) - 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:
- No aliasing mutable references ā Never create two
&mut Tto the same data - No reads during mutation ā Don't read while a mutable reference exists
- Thread synchronization ā Use atomics/locks for multi-threaded access
- Valid pointer dereference ā Ensure the pointer is always valid
Key Points:
UnsafeCellis the foundation for all interior mutability types- All access requires
unsafeblocks - You must manually uphold Rust's safety invariants
- The compiler won't assume
&TbehindUnsafeCellis immutable - Use safe wrappers (
Cell,RefCell,Mutex) whenever possible - Incorrect use leads to undefined behavior
UnsafeCellis notSyncby default (and shouldn't be made Sync without synchronization)
