Ricardo Martins

Interior mutability in Rust, part 3: behind the curtain

Key takeaways

  • UnsafeCell is the keystone for building interior mutability types (Cell, RefCell, RwLock and Mutex)
  • UnsafeCell wraps around a value and provides a raw mutable pointer to it
  • It depends on a special compiler path that avoids undefined behavior related to the raw pointer
  • It lacks synchronization primitives and has a very bare-bones API
  • Cell and RefCell provide convenient APIs to access their UnsafeCell’s inner value, with negligible-to-none run-time overhead
  • RwLock and Mutex provide synchronized access to UnsafeCell’s inner value
  • Avoid using UnsafeCell except when you’re absolutely sure you don’t want any of the other types

This article is part of a series about interior mutability in Rust. You can read part 1 here and part 2 here.


In the previous articles, we looked at interior mutability in Rust from a practical standpoint, but we didn’t explore the magic that made it work. In this article we’ll pull back the curtain and stare at the man lurking behind.

A good place to start is reading the definition of Cell, RefCell, RwLock, and Mutex to gather some clues. Luckily, a pattern quickly emerges: all of them contain a field with a common type: UnsafeCell.1 We’ll first explore what it is, then figure out how it is used by those types.

What is UnsafeCell?

UnsafeCell is defined in the documentation as “the core primitive for interior mutability in Rust”. Indeed, we stumbled on it every time we looked at the definition of types that provide interior mutability. However, for all its apparent power, its definition seems rather boring:

#[lang = "unsafe_cell"]
#[stable(feature = "rust1", since = "1.0.0")]
pub struct UnsafeCell<T: ?Sized> {
    value: T,

That’s it? The keystone for interior mutability looks like a newtype struct with some weird stuff before?

Those strange lines right above the structure definition are compiler attributes. We can mostly ignore the second, as it simply marks the function as stable since version 1.0.0 of the compiler, meaning it can be used in any channel (stable, beta, and nightly). The first one is more interesting: it’s a lang item, a sort of special wink and nudge to the compiler. I’ll get to it later.

Apart from new() and into_inner(), which are humdrum methods, there’s also a get() method:

pub fn get(&self) -> *mut T {
    &self.value as *const T as *mut T

Even though it’s a small method, we can tell it’s somewhat unusual: it takes an immutable reference to the inner value (&self.value) and then casts it twice: first into a raw constant pointer (*const T), and then into a raw mutable pointer (*mut T), which is returned to the caller.

Raw pointers, eh? That’s new. Let’s take a look.

Raw pointers

Raw pointers are similar to references, which we are used to. Both point to a memory address, but there are important differences. References are smart: they have safety guarantees (such as pointing to valid memory and never being null), borrow checking and lifetimes. Raw pointers, on the other hand, are… well, raw. They have none of those features, and simply point to a memory address, like pointers in C. Most importantly, they don’t have aliasing or mutability guarantees, unlike references. There are two kinds of raw pointers: constant (*const T) and mutable (*mut T). The difference between them is that mutation is not directly allowed on constant pointers, but we can easily cast them into mutable pointers and then mutate the value.

You might have noticed that get() isn’t marked unsafe, even though we are coercing an immutable reference into a raw mutable pointer and dropping Rust’s safety guarantees along the way. Shouldn’t this trip up some alarms?

Coercing (i.e., converting) an immutable reference into a raw constant pointer isn’t too bad. It could be argued that it’s safe, since the immutability of the reference is preserved and we’re starting from a reference, which has the safety guarantees we saw above, so the resulting raw pointer won’t be invalid or null. Going from a raw constant pointer to a raw mutable pointer, on the other hand, seems much worse, because we’re suddenly saying it’s fine to mutate a value we have no business mutating. Going from there back to a mutable reference is definitively bad.

Thinking some more about it, those conversions aren’t actually dangerous by themselves, as they have no side-effects on their own. It’s actually using (i.e., dereferencing) the raw pointers that’s potentially dangerous. As such, when we try to dereference the pointer returned by get() like in this example, the compiler tells us that we’re about to do something potentially dangerous:

error: dereference of raw pointer requires unsafe function or block [E0133]

Surrounding the dereference with an unsafe block signals to the compiler that we’re taking responsibility for all potential badness that may come. In the example, we know the pointer is safe because it was created from a reference and, most importantly, we didn’t mutate its value, so it’ll work as expected.

Even so, mutating non-mutable data is considered undefined behavior:

Mutating non-mutable data (that is, data reached through a shared reference or data owned by a let binding), unless that data is contained within an UnsafeCell<U>.

So, we’re back at the beginning: what makes UnsafeCell special?

✨ A dash of magic ✨

Remember the lang item that preceded UnsafeCell’s definition? Here it is again:

#[lang = "unsafe_cell"]

It triggers special treatment by the compiler: UnsafeCell gets a special marker that follows it during the various compilation phases, until it finally triggers a particular code path. This section of code checks for the presence of that marker and, if it is present, avoids setting two LLVM attributes that get applied to regular types: namely, that the value is read-only and there are no aliases. Those attributes, when present, may allow some additional optimization by LLVM, which can interfere with the behavior we want for UnsafeCell.

Now, that doesn’t mean we absolutely can’t get similar results without the lang item — we can2, but that’s purely coincidental and relies on undefined behavior which may eat our laundry, depending on the compiler’s mood (i.e., the generated code may change unexpectedly).

That aside, notice that there is no synchronization inside get(), which makes UnsafeCell unsafe to be used by multiple threads and is thus marked !Sync.

A little experiment shows that using an UnsafeCell incurs no run-time costs after compiler optimization, and is nearly indistinguishable from a “raw” variable, as expected from Rust’s zero-overhead abstractions.

And that’s it for UnsafeCell. It’s a little amazing, but this tiny structure with a very simple method and some compiler support is enough to build upon and create all the other interior mutability types we explored before. A dash of magic is enough. ✨

How is it used?

Now that we know what UnsafeCell is like, let’s explore how the other types build upon it.


Let’s start with Cell, the simplest of the bunch. Its definition is exceedingly succinct:

pub struct Cell<T> {
    value: UnsafeCell<T>,

This suggests that Cell merely provides a nicer API to access the inner value contained in UnsafeCell. If we take a gander at Cell::get and Cell::set, we can see that UnsafeCell::get() is enough to implement both methods, and that the implementation details, such as the unsafe blocks, are hidden from the user:

pub fn get(&self) -> T {
    unsafe{ *self.value.get() }

pub fn set(&self, value: T) {
    unsafe {
        *self.value.get() = value;

There’s an important detail in Cell’s impl declaration: it’s restricted to Copy types.

impl<T:Copy> Cell<T> {

Without this restriction, we would be able to create instances of Cell around !Copy types, such as &mut T (mutable references), which is a terrible idea: doing so would create aliased mutable references and break the aliasing rules.


RefCell is only slightly more complicated than Cell. It contains an UnsafeCell field with the inner value, just like Cell, and a Cell with a borrow flag to track the borrow state:

pub struct RefCell<T: ?Sized> {
    borrow: Cell<BorrowFlag>,
    value: UnsafeCell<T>,

We saw in the first article of the series that we need to call borrow or borrow_mut on RefCell before accessing the value inside. Both methods have similar implementations and convert the raw pointer they get from UnsafeCell::get() to the value back into a reference with &*. For brevity’s sake, we’ll look at just RefCell::borrow(), since RefCell::borrow_mut() is very similar.

pub fn borrow(&self) -> Ref<T> {
    match BorrowRef::new(&self.borrow) {
        Some(b) => Ref {
            value: unsafe { &*self.value.get() },
            borrow: b,
        None => panic!("RefCell<T> already mutably borrowed"),

There’s the call to UnsafeCell::get() and conversion back into a reference. Where’s the update to RefCell’s borrow state, though?

Reading BorrowRef::new() makes things clear. Roughly, when that method is called, it checks RefCell’s borrow field (which it received as argument) and updates it if the value can be borrowed. Since the borrow field is a Cell, BorrowRef can mutate it, even though it received an immutable reference to it!

RefCell::borrow_mut() is very similar, except that it calls BorrowRefMut::new() instead of BorrowRef::new(), and returns a mutable reference (to be precise, it returns a RefMut, which implements DerefMut).

We can conclude, then, that Cell and RefCell are lightweight wrappers around UnsafeCell, giving us a convenient API to access UnsafeCell’s inner value and shielding us from dangerous pointer dereferences. In the case of Cell, there’s no run-time cost after optimization, while RefCell’s dynamic borrow checking mechanism incurs a small overhead. Additionally, Cell and RefCell are “tainted” by UnsafeCell’s !Sync marker.

RwLock and Mutex

In contrast to Cell and RefCell, RwLock and Mutex are more complex data structures which provide synchronized access to the inner value, allowing them to implement Sync and honor that guarantee. We won’t be diving deep into them, but looking at RwLock::read() we can see similarities to RefCell::borrow():

pub fn read(&self) -> LockResult<RwLockReadGuard<T>> {
    unsafe {
        RwLockReadGuard::new(&*self.inner, &self.data)

Untangling that a little, we can see that first an inner lock3 is locked, then a ReadLockReadGuard is created. Similarly to what BorrowRef does for RefCell, RwLockReadGuard::new() converts the raw pointer it got after calling UnsafeCell::get() into a reference:

unsafe fn new(lock: &'rwlock StaticRwLock, data: &'rwlock UnsafeCell<T>)
              -> LockResult<RwLockReadGuard<'rwlock, T>> {
    poison::map_result(lock.poison.borrow(), |_| {
        RwLockReadGuard {
            __lock: lock,
            __data: &*data.get(),

Both RwLock::write() and Mutex::lock() follow a similar pattern to RwLock::read(), so there’s no need to explore them individually.

Wrapping up

In this article we learned that far from relying on arcane magic, interior mutability is achieved thanks to UnsafeCell, a neat little structure that allows the creation of more ergonomic abstractions on top of it.

Since its API involves unsafe operations, using it directly is a little cumbersome. In nearly every case where we need interior mutability, we are better served by Cell, RefCell, RwLock, and Mutex instead. There are a few cases where you might want to avoid the overhead of those types by UnsafeCell directly, such as implementing new locks and concurrent data structures, but those aren’t common tasks unless you work in academia. 😉

Whew! This concludes the series on interior mutability. I hope you enjoyed it! Special thanks to everyone who has commented on both this and the previous articles. Your support and corrections are deeply appreciated. 💜

What should we explore next? Tell me on Twitter (@meqif) or send me an email (words@ricardomartins.cc). You can also discuss the article on reddit. If you don’t want to miss the next articles, sign up for my newsletter in the form below. 👇

  1. Many other types depend on UnsafeCell, such as Condvar, the Sender and Receiver structures used created by std::sync::mpsc::channel, thread-local variables (created with the thread_local macro), private data structures used in the compiler implementation, and some others I won’t bother to list.

  2. Compare the result when running this example in debug mode and release mode. In release mode the compiler assumed that the value is never changed, so the call to println! doesn’t show the update! Thanks to /u/derKha and /u/notriddle for their examples.

  3. That inner lock is actually a native OS lock. You can read the implementation for the Unix one and the Windows one. Both rely on UnsafeCell as well.

Enjoyed the article?

Subscribe my newsletter to get the latest Rust tips and articles by email.

Powered by ConvertKit
You're already on the mailing list, thank you! 💜