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:

  1. You can't get a reference to the contained value
  2. You can only get the value itself (if it implements Copy)
  3. 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):

  1. We're using UnsafeCell as our foundation - this is Rust's primitive for interior mutability
  2. The methods take &self (shared reference) but allow mutation
  3. We're implementing Clone for types that implement Copy

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:

  1. Each thread reads the current campaign status
  2. Each thread calculates the new total independently
  3. 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:

  1. Why interior mutability without synchronization is dangerous in multi-threaded code
  2. How data races can silently corrupt important business data
  3. Why Rust's standard library makes Cell !Sync
  4. 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 Mutex for 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

  1. Try implementing a thread-safe counter using:

    • Our broken Cell implementation
    • Mutex
    • atomic types Compare the results!
  2. Modify the donation platform to handle refunds. What new race conditions appear?

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

  1. Use RUST_BACKTRACE=1 to get better error traces
  2. Remember that Cell operations aren't atomic
  3. Watch out for nested Cells (they probably indicate a design issue)
  4. Consider using the loom crate 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! 😉