Skip to content

← Smart Pointers and Interior Mutability step 21 of 26

Medium Primitives

static mut is dead: what edition 2024 changed

Implement the same shared counter twice: once with an atomic integer and once with a hand-written synchronized cell. Both counters are static items, so each one is a single value shared by the whole process.

pub fn global_counter(ops: Vec<String>) -> Vec<i64>
pub fn sync_cell_counter(ops: Vec<String>) -> Vec<i64>

Each function uses its own static counter and sets it to zero when it starts. A static keeps its value between calls, so without the reset a second call in the same process would start from whatever the previous call left behind; the contract is that every call starts from zero. The functions are not required to work if two calls run at the same time.

Process the operations in order. Each operation is split on whitespace, and matching is case-sensitive:

operation effect
add <a> <b> <c> <d> (exactly four values) start four scoped threads; thread i adds its value to the shared counter 500 times, one addition at a time; wait for all four to finish
read (no extra words) append the counter's current value to the output
anything else ignored, including add with a different number of values

A value in an add line that does not parse as an i64 (for example x) counts as 0. After the last operation, append the counter's value once more, so the output always has at least one element. For an empty operation list the answer is [0].

So add 1 1 1 1 moves the counter by 4 × 500 × 1 = 2000, and add 1 2 3 4 moves it by 500 × (1 + 2 + 3 + 4) = 5000. You get these totals only if all four threads update the same counter and no update is lost. The tests use small values, so no partial total can overflow i64; overflow behavior is not part of the contract.

Both functions must return the same values. The tests check only the returned values, so they cannot tell which mechanism you used. The sections below explain the intended one for each function.

Why static mut with a reference is rejected

A static item is one location in memory that lives for the whole program. A static mut is a static that code may modify. It still exists in edition 2024, and accessing it still requires an unsafe block. The obvious way to update one looks like this:

static mut COUNTER: i64 = 0;

fn bump(delta: i64) {
    unsafe {
        let slot = &mut COUNTER;
        *slot += delta;
    }
}

On edition 2024, rustc 1.98.1 rejects it:

error: creating a mutable reference to mutable static
  |
  |         let slot = &mut COUNTER;
  |                    ^^^^^^^^^^^^ mutable reference to mutable static
  |
  = note: mutable references to mutable statics are dangerous; it's undefined
          behavior if any other pointer to the static is used or if any other
          reference is created for the static while the mutable reference lives
  = note: `#[deny(static_mut_refs)]` (part of `#[deny(rust_2024_compatibility)]`)
          on by default
help: use `&raw mut` instead to create a raw pointer

This error comes from a lint, static_mut_refs, not from a hard language rule, so it has no E error code. The lint is a warning on earlier editions and deny-by-default on edition 2024. Because it is a lint, #[allow(static_mut_refs)] makes the code compile again; that silences the check but does not make the code correct. Older material may mention error code E0796; rustc --explain E0796 now says that code is no longer emitted.

The lint targets references specifically. These still compile inside unsafe, because they read or write the static directly without creating a reference:

unsafe {
    COUNTER = 0;
    COUNTER += 2;
    let current = COUNTER; // copies the i64 out
}

Creating a shared reference (&COUNTER) is reported too, and so is a method call that borrows the static implicitly, such as LIST.push(1) on a static mut LIST: Vec<i32>.

The reason is the promise a reference makes. A &mut T promises that nothing else reads or writes that value while the reference is in use, and a &T promises that nothing writes it. Every function that can name a static mut can reach it, from any thread, so the compiler cannot check that promise. If the promise is broken, the behavior is undefined, even in a single thread. The unsafe block is your statement that you have checked it yourself, and for a counter updated from four threads that check would fail: the direct COUNTER += delta form compiles, but four threads running it at once is a data race, which is also undefined behavior.

Safer replacements, from simplest to most manual

  1. Atomics. static COUNTER: AtomicI64 = AtomicI64::new(0); needs no unsafe, no lock and no mut. fetch_add performs the read, the addition and the write as one indivisible operation, so no update is lost. For a single counter this is the natural choice, and it is what global_counter should use. For other read-modify-write steps there are fetch_update and the newer update and try_update methods. AtomicI64 exists only on targets that support 64-bit atomic operations.
  2. Mutex / RwLock. Mutex::new and RwLock::new are const fn, so static TABLE: Mutex<Vec<u8>> = Mutex::new(Vec::new()); works directly. Use a lock when the shared state is more than one integer or when several values must change together.
  3. OnceLock / LazyLock. For state that is built once and then only read, such as a parsed configuration. OnceLock is initialized by the first call to a method like get_or_init; LazyLock runs its initializer on first access.
  4. &raw mut STATIC. Creates a raw pointer (*mut T) to a static mut without creating a reference, which is what the lint's help line suggests. Taking the pointer is safe; reading or writing through it needs unsafe, and you still must prevent data races yourself. One common use is passing the address to foreign (for example C) code.
  5. A hand-written synchronized cell. This is the second half of the exercise.

A synchronized cell by hand

UnsafeCell<T> is the standard library's basic building block for interior mutability: UnsafeCell::get() returns a raw *mut T to its contents, and dereferencing that pointer is unsafe. It is the type that Cell, RefCell, Mutex and the atomics are built on.

use std::cell::UnsafeCell;
use std::sync::Mutex;

struct SyncCell<T>(UnsafeCell<T>);

// SAFETY: every access to CELL goes through one function that holds LOCK
// for the whole access, so no two threads use the value at the same time.
unsafe impl<T: Send> Sync for SyncCell<T> {}

static CELL: SyncCell<i64> = SyncCell(UnsafeCell::new(0));
static LOCK: Mutex<()> = Mutex::new(());

The type of a (non-mut) static must implement Sync, because every thread can reach it. UnsafeCell<T> does not implement Sync, so static CELL: UnsafeCell<i64> fails to compile with E0277. Writing unsafe impl Sync is a promise the compiler cannot check, and the Mutex is what keeps that promise. This Sync implementation would be unsound for arbitrary uses of SyncCell; it is acceptable here only because the field is private to this module and every access to CELL holds LOCK. One access that skips the lock would allow a data race, which is undefined behavior.

The standard library has std::cell::SyncUnsafeCell for this purpose, but it is still unstable on Rust 1.98.1, so this exercise writes the wrapper by hand.

Two details matter in the implementation:

  • Bind the lock guard to a name: let _guard = LOCK.lock().... The pattern let _ = LOCK.lock(); drops the guard, and so releases the lock, immediately. rustc rejects that pattern with the deny-by-default let_underscore_lock lint.
  • UnsafeCell::get() itself is safe. The unsafe block belongs around the dereference of the *mut T it returns, while the guard is alive.

const is not static

A const is a value that is copied into every place it is used. A static is one location in memory.

use std::sync::atomic::{AtomicUsize, Ordering};

const C: AtomicUsize = AtomicUsize::new(0);
static S: AtomicUsize = AtomicUsize::new(0);

fn main() {
    for _ in 0..3 { print!("{} ", C.fetch_add(1, Ordering::SeqCst)); } // 0 0 0
    for _ in 0..3 { print!("{} ", S.fetch_add(1, Ordering::SeqCst)); } // 0 1 2
}

Each use of C creates a fresh atomic that starts at zero and is thrown away after the call, so it never counts. rustc warns about this with const_item_interior_mutations, and Clippy adds declare_interior_mutable_const and borrow_interior_mutable_const. A shared counter must be a static.

Why the output is deterministic

The four threads in a round run in an unpredictable order, but the result does not depend on that order. Integer addition gives the same total in any order (when nothing overflows), and std::thread::scope waits for every thread it spawned before it returns, so each read happens after the whole round has finished. The tests compare totals, not timings or interleavings.