Skip to content

← Atomics, Send/Sync and the Memory Model step 9 of 12

Medium Framework

static mut and the edition-2024 wall

Drive a process-global counter from four threads, and report what it read along the way.

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

Each op is "inc", "dec" or "read"; anything else is ignored. The inc and dec ops between two reads form a batch, applied concurrently by four scoped threads. A "read" acts as a sequence point: apply the pending batch, then push the counter’s value. At the end, apply the last batch and push the final value.

["inc","inc","read","dec","read"]  ->  [2, 1, 1]
[]                                 ->  [0]
["read"]                           ->  [0, 0]

Addition commutes, so a batch’s result does not depend on which thread applied which delta — that is what makes a genuinely concurrent update testable.

The counter must be a real static, not a local. That is the point: this is the global-mutable-state problem, seen from the concurrency side.

The wall

A C programmer writes this without a second thought:

static mut COUNTER: i64 = 0;

and edition 2024 stops them:

error: creating a mutable reference to mutable static
   |
   |     let c = unsafe { &mut COUNTER };
   |                      ^^^^^^^^^^^^ mutable reference to mutable static
   |
   = note: for more information, see
           <https://doc.rust-lang.org/edition-guide/rust-2024/static-mut-references.html>
   = note: `#[deny(static_mut_refs)]` (part of `#[deny(rust_2024_compatibility)]`)
           on by default

Two things about that message are worth reading carefully.

It is deny-by-default, not a warning. No flag turns it off in this build, and no amount of unsafe helps — unsafe lets you do the operation, and this operation is not permitted at all.

It is the reference that is banned, not the static. Reading or writing COUNTER directly inside an unsafe block still compiles today. That distinction is the whole content of the lint: a &mut carries an aliasing promise — “no other reference to this exists” — and for a global reachable from every thread and every function in the program, that promise is essentially never true. Two &mut COUNTER in scope at once is instant undefined behaviour, and nothing stops you creating them.

(Note the error has no E code. It is the uncoded static_mut_refs lint. You may see E0796 cited in older material; rustc --explain E0796 now answers “this error code is no longer emitted by the compiler”.)

Why this restatement of Sync is the compact one

A &'static mut T obtained from a global is trivially aliasable from two threads — just call the function twice, from two threads, and both get one. That is the entire Sync story in one sentence: a type is shareable between threads only if &T cannot be used to break its invariants, and &mut obtained from a global breaks the most basic invariant there is.

static mut was the one hole in that story, and edition 2024 closed it.

The migration ladder

In the order you should try them:

  1. An atomic. static COUNTER: AtomicI64 = AtomicI64::new(0); — works because AtomicI64::new is a const fn, needs no unsafe, and is Sync so every thread can reach it through &. This is the answer here. (1.95 added update/try_update for read-modify-write closures.)

  2. Mutex/RwLock. Both are const-constructible now, so static LOG: Mutex<Vec<String>> = Mutex::new(Vec::new()); just works. Use this when the state is more than a machine word.

  3. OnceLock/LazyLock for state that is initialised once and then only read.

  4. &raw mut STATIC — the sanctioned escape when you must hand a pointer to C. It creates a raw pointer without going through a reference, so no aliasing promise is made. This is a supported feature, not a loophole.

  5. A hand-rolled SyncUnsafeCell when nothing above fits:

    struct SyncCell<T>(UnsafeCell<T>);
    unsafe impl<T: Send> Sync for SyncCell<T> {}

    and then you provide the synchronisation yourself. std::cell::SyncUnsafeCell exists but is still unstable on 1.95, which is why people keep writing this by hand.

The static outlives your function

A static lives for the whole process, so a second call to global_counter sees whatever the first one left behind. Reset it at the top, so the function is idempotent and its result depends only on its argument.

This is not a harness quirk, it is what globals are. Any function whose answer depends on process history is a function you cannot test, and “make it idempotent or make it take its state as a parameter” is the fix in every language.

::: question const COUNTER: AtomicI64 = AtomicI64::new(0); compiles. What does a program using it print, and how many diagnostics does it get? It prints 0 0 0 where the static version prints 0 1 2, and it collects three diagnostics from two tools.

A const is a value inlined at every use site, so every mention of COUNTER constructs a fresh atomic, increments it, and discards it. The counter never changes and the program silently counts nothing.

clippy::declare_interior_mutable_const
clippy::borrow_interior_mutable_const
const_item_interior_mutations          (rustc's own)

All three are warn-by-default, so all three fail this course’s gate. Three independent lints firing on one line is the toolchain teaching the const versus static distinction directly — and it is a good general signal: when several unrelated analyses agree, the code is not merely unidiomatic. :::

One free gate worth knowing

rustc’s invalid_atomic_ordering is deny-by-default and catches the syntactically impossible orderings — a load(Release), a store(Acquire), a compare_exchange whose failure ordering is stronger than its success ordering. It grades that class of mistake for free. It cannot tell you that Relaxed was too weak for what you meant; only reasoning does that.

Loading visualization…