I've been exploring Rust's Smart Pointers lately. While reading the documentation is helpful, I find breaking things teaches me more. So let's create our own minimal versions of these containers and break some rules.
Getting Started
First, create a new Rust library project:
cargo new --lib cell-experiments
cd cell-experiments
That's all we need to get started! We won't need any external dependencies for this experiment - we'll be using only Rust's standard library.
Let's create our first test file:
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
let result = 2 + 2;
assert_eq!(result, 4);
}
}
Now we can run cargo test to make sure everything is set up correctly.
Understanding Interior Mutability
Before we dive into Cell, let's understand what problem it solves. In Rust, you typically need a mutable reference (&mut) to modify data:
fn main() {
let mut x = 42;
increment(&mut x); // Need mut reference
println!("x is now {}", x);
}
fn increment(value: &mut i32) {
*value += 1;
}
But sometimes you need to modify data even when you only have shared references. This is where interior mutability comes in. It's like having a magic box that lets you change what's inside even when the box itself is immutable.
A Simple Example
Here's a common use case - a counter that needs to be modified through a shared reference:
use std::cell::Cell;
struct Counter {
value: Cell<i32>
}
impl Counter {
fn new() -> Self {
Self { value: Cell::new(0) }
}
// Note: this takes &self, not &mut self
fn increment(&self) {
let current = self.value.get();
self.value.set(current + 1);
}
fn get(&self) -> i32 {
self.value.get()
}
}
fn main() {
let counter = Counter::new();
counter.increment(); // Works without mut!
println!("Count: {}", counter.get());
}
How Cell Works
Cell provides interior mutability through a simple principle: it ensures that you can only access the data through the Cell's methods, never directly. This means:
- You can't get a reference to the contained value
- You can only get the value itself (if it implements Copy)
- You can replace or modify the value through Cell's methods
Here's a visualization:
Regular Rust: Cell<T>:
┌───────────┐ ┌───────────┐
│ │ │ │
│ 42 │◄─────┤ 42 │
│ │ &mut │ │
└───────────┘ └───────────┘
No direct references!
Building Our Own Cell
Now let's build our own Cell implementation step by step:
mod cell {
use std::cell::UnsafeCell;
use std::mem;
pub struct Cell<T> {
value: UnsafeCell<T>,
}
impl<T> Cell<T> {
pub fn new(value: T) -> Self {
Self {
value: UnsafeCell::new(value),
}
}
pub fn replace(&self, value: T) -> T {
mem::replace(unsafe { &mut *self.value.get() }, value)
}
pub fn set(&self, value: T) {
self.replace(value);
}
}
impl<T: Copy> Cell<T> {
pub fn get(&self) -> T {
unsafe { *self.value.get() }
}
}
impl<T: Copy> Clone for Cell<T> {
fn clone(&self) -> Cell<T> {
Cell::new(self.get())
}
}
}
Let's break down what makes this implementation interesting (and potentially dangerous):
- We're using
UnsafeCellas our foundation - this is Rust's primitive for interior mutability - The methods take
&self(shared reference) but allow mutation - We're implementing
Clonefor types that implementCopy
Breaking the Rules: Making Cell Sync
The standard library's Cell is intentionally !Sync - meaning it can't be shared between threads. But what if we ignore that restriction? Let's build something that could go terribly wrong: a donation platform that tracks fundraising campaigns.
// This is a bad idea in real code!
unsafe impl<T> Sync for Cell<T> {}
#[derive(Clone, Copy)]
struct Campaign {
current_amount: u64,
donor_count: u32,
}
struct DonationPlatform {
campaign: Cell<Campaign>
}
impl DonationPlatform {
fn new() -> Self {
Self {
campaign: Cell::new(Campaign {
current_amount: 0,
donor_count: 0,
})
}
}
fn donate(&self, amount: u64) {
let current = self.campaign.get();
// Simulate some processing time (payment verification, etc)
thread::sleep(Duration::from_millis(1));
self.campaign.replace(Campaign {
current_amount: current.current_amount + amount,
donor_count: current.donor_count + 1,
});
}
fn get_status(&self) -> Campaign {
self.campaign.get()
}
}
Now let's simulate what happens during a viral fundraising campaign where many donations come in simultaneously:
#[test]
fn test_donation_spike() {
let platform = Arc::new(DonationPlatform::new());
let mut handles = vec![];
// Simulate viral campaign with different donation amounts
let donation_amounts = vec![5, 10, 20, 50, 100];
for amount in donation_amounts {
for _ in 0..20 { // 20 people donating each amount
let platform_clone = platform.clone();
handles.push(thread::spawn(move || {
platform_clone.donate(amount);
}));
}
}
// Add some "whale" donors during the spike
for _ in 0..5 {
let platform_clone = platform.clone();
handles.push(thread::spawn(move || {
platform_clone.donate(1000);
}));
}
// Wait for all donations
for handle in handles {
handle.join().unwrap();
}
let final_status = platform.get_status();
let expected_amount = (5 * 20 + 10 * 20 + 20 * 20 + 50 * 20 + 100 * 20 + 1000 * 5) as u64;
let expected_donors = 105; // 100 regular donors + 5 whales
println!("Expected amount: ${}", expected_amount);
println!("Actual amount: ${}", final_status.current_amount);
println!("Expected donors: {}", expected_donors);
println!("Actual donors: {}", final_status.donor_count);
// These will likely fail due to race conditions
assert_eq!(final_status.current_amount, expected_amount,
"Donations were lost during the spike!");
assert_eq!(final_status.donor_count, expected_donors,
"Donor count is incorrect!");
}
Why This Breaks (And Why It's Scary)
This example demonstrates a particularly nasty type of race condition. When multiple threads try to update the campaign simultaneously:
- Each thread reads the current campaign status
- Each thread calculates the new total independently
- Each thread writes back its calculation
The problem? If Thread A reads the total as $1000 and Thread B reads it at the same time:
- Thread A adds its $50 donation → writes $1050
- Thread B adds its $100 donation → writes $1100
- The $50 donation effectively disappears!
Running the test produces output like:
Expected amount: $7000
Actual amount: $5230
Expected donors: 105
Actual donors: 87
Not only did we lose money, but we also lost track of donors! In a real system, this could mean:
- Missing donation records
- Incorrect donor recognition
- Financial discrepancies
- Angry donors who don't see their contributions reflected
This is exactly why Cell isn't Sync in the standard library. When data races can lead to lost money or corrupted records, we need proper synchronization primitives like Mutex or RwLock.
Learning from Our Mistakes
By breaking Cell's safety rules, we've learned:
- Why interior mutability without synchronization is dangerous in multi-threaded code
- How data races can silently corrupt important business data
- Why Rust's standard library makes Cell !Sync
- When to reach for proper synchronization primitives instead
Sometimes the best way to understand why rules exist is to break them - just don't do it in production code, especially when handling donations! 😉
In the next article, we'll look at how RefCell provides runtime borrowing checks, and what happens when we break those too.
Making It Better: Safe Alternatives
Instead of breaking Cell's safety guarantees, here's how we could implement the donation platform correctly:
use std::sync::Mutex;
struct SafeDonationPlatform {
campaign: Mutex<Campaign>
}
impl SafeDonationPlatform {
fn new() -> Self {
Self {
campaign: Mutex::new(Campaign {
current_amount: 0,
donor_count: 0,
})
}
}
fn donate(&self, amount: u64) -> Result<(), String> {
let mut campaign = self.campaign.lock()
.map_err(|_| "Lock poisoned")?;
campaign.current_amount += amount;
campaign.donor_count += 1;
Ok(())
}
}
This version:
- Uses
Mutexfor proper synchronization - Prevents data races
- Still allows interior mutability
- Is safe for multi-threaded use
When to Use What
Here's a quick guide for choosing the right tool:
Use
Cell<T>when:- You need interior mutability in single-threaded code
- Your type implements Copy
- You don't need references to the contained value
Use
RefCell<T>when:- You need interior mutability in single-threaded code
- You need to borrow the contained value
- You want runtime borrow checking
Use
Mutex<T>when:- You need interior mutability across threads
- You need exclusive access to the value
- You want deadlock protection
Exercises for the Reader
Try implementing a thread-safe counter using:
- Our broken Cell implementation
- Mutex
- atomic types Compare the results!
Modify the donation platform to handle refunds. What new race conditions appear?
Add logging to the donation platform to see the exact sequence of events during a race condition.
Common Pitfalls and Debugging Tips
When working with Cell and interior mutability:
- Use
RUST_BACKTRACE=1to get better error traces - Remember that Cell operations aren't atomic
- Watch out for nested Cells (they probably indicate a design issue)
- Consider using the
loomcrate for testing concurrent code
Conclusion
By breaking Cell's safety rules, we've learned valuable lessons about:
- Interior mutability
- Rust's safety guarantees
- Race conditions
- Proper synchronization
Remember: understanding why rules exist is valuable, but in production code, follow them! Use the right tool for the job, and when in doubt, prefer safety over convenience.
Next time, we'll look at RefCell and its runtime borrowing checks. Stay tuned! 😉