Skip to content

← Smart Pointers and Interior Mutability step 21 of 26

Medium Primitives

`static mut` is dead: what edition 2024 changed

Drive one process-global counter from four threads, twice, two different ways.

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

Each function owns its own global counter and resets it to zero on entry (the same process runs both, and the tests would otherwise accumulate). Then:

op effect
add <a> <b> <c> <d> run one round: four scoped threads, thread i adding its delta 500 times to the shared counter
read append the counter’s current value
anything else ignored

After the last op, append the final total. Return the appended values. So a round of add 1 1 1 1 moves the counter by 500 × 4 = 2000 — and only if the state really is shared and synchronised. Lose updates and the number comes out low; use four separate counters and it comes out wrong differently.

Both functions must return the same answers.

The instinct, and the refusal

A global mutable counter is the first thing a C programmer reaches for, and the spelling is right there in the language:

static mut COUNTER: i64 = 0;

unsafe {
    let slot = &mut COUNTER;
    *slot += delta;
}

On edition 2024 that is a hard error, with no flag to turn it off:

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

Note what it is and is not. It has no error codestatic_mut_refs is a lint, denied by default. (You may find E0796 in older material. Run rustc --explain E0796 and it answers “this error code is no longer emitted by the compiler.” Do not learn it.)

And note the subtlety the lint encodes, because it explains the whole design: reading or writing the static directly inside unsafe still compiles. unsafe { COUNTER = 0; } is fine. unsafe { COUNTER } is fine. What is banned is taking a reference, because a reference carries the aliasing promise — and in a program with threads there is no way for you to keep that promise, and no way for the compiler to check that you did. The unsafe block was never enough; it just moved the blame.

The migration ladder

Every rung solves the same problem with a different amount of machinery. Climb only as far as you must.

  1. Atomics. static COUNTER: AtomicI64 = AtomicI64::new(0); — no unsafe, no lock, fetch_add is one instruction on every machine you care about. For a counter this is the whole answer, and it is what global_counter should use. (1.95 also added update and try_update for the read-modify-write cases fetch_* does not cover.)
  2. Mutex / RwLock. Both are const-constructible now, so static TABLE: Mutex<Vec<u8>> = Mutex::new(Vec::new()); works directly in a static. Use when the state is bigger than one integer.
  3. OnceLock / LazyLock. For state that is expensive to build and never changes afterwards — a compiled table, a parsed configuration. The thread-safe twins of the cells from the previous problem.
  4. &raw mut STATIC. The sanctioned escape when you genuinely must hand a pointer to C. It is a raw pointer, so it makes no aliasing promise, which is exactly why it is allowed where &mut is not. A tool, not a loophole.
  5. A hand-rolled SyncUnsafeCell. Which is the second half of this problem.

Rung five, by hand

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

// SAFETY: every access goes through a function that holds LOCK for the
// duration, so no two threads ever touch the value at once.
unsafe impl<T: Send> Sync for SyncCell<T> {}

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

A static must be Sync, and UnsafeCell is not — that is the whole reason you cannot simply put one in a static and be done. unsafe impl Sync is you promising what the compiler cannot prove, and the Mutex is what makes the promise true. Every access must hold the lock; a single one that does not makes the unsafe impl a lie and the program unsound.

This is what std::cell::SyncUnsafeCell would give you, and it is still unstable on 1.95, which is why you are writing it out.

Two details in the implementation are worth care. The lock guard must be bound to a namelet _guard = LOCK.lock()…, never let _ = LOCK.lock()…, which releases immediately and is caught by clippy’s deny-by-default let_underscore_lock. And UnsafeCell::get() returns a *mut T, so the dereference is where the unsafe block genuinely belongs.

const is not static, and the difference is stark

Worth burning in while you are here. A const is a value inlined at every use site; a static is one place in memory.

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

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

The const version gives every use its own fresh copy, so it never counts anything. Three separate diagnostics fire on that one mistake — rustc’s const_item_interior_mutations plus clippy’s declare_interior_mutable_const and borrow_interior_mutable_const — which tells you how often people hit it.

Determinism

The threads race on purpose, but nothing you return depends on who wins. Additions commute, the round is joined before anything is read, and the tests assert totals rather than timings. That is the general rule for testing concurrent code: assert on a deterministic artefact — a final count, a sorted merge, an event log — never on a duration or an interleaving.

Loading visualization…