Skip to content

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

Medium Framework

static mut and the edition-2024 wall

Keep a counter in a static (one value shared by the whole program), update it from several threads, and report its value at chosen points.

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

The contract

Each element of ops is one operation. Matching is exact and case-sensitive:

  • "inc" adds 1 to the counter.
  • "dec" subtracts 1.
  • "read" records the counter's current value.
  • Any other string (for example "", "INC" or "noop") is ignored.

The counter starts at 0 on every call. The inc and dec operations since the previous read (or since the start) form a batch. When a read arrives, apply the pending batch, then append the counter's value to the output. After the last operation, apply any remaining batch and append the final value once more. The output therefore always has one more element than the number of "read" operations, so an empty list returns [0].

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

Each value is simply the number of "inc"s minus the number of "dec"s seen so far. The counter is an i64; an input would need more than nine quintillion operations to overflow it, so overflow does not arise.

Two requirements are part of the exercise but cannot be seen in the output, so the tests do not check them:

  • Apply each batch with scoped threads. The supplied apply helper splits a batch into at most four contiguous chunks and gives each chunk its own thread from std::thread::scope. Because addition is commutative and associative, the total after a batch is the same whichever thread applies which delta, provided no update is lost. That is what makes a concurrent update testable.
  • Keep the counter in a static, not a local variable. The exercise is about global mutable state, so a local counter would avoid the question rather than answer it.

The edition 2024 error

The starter keeps the counter in a static mut, a static that the program may modify. Every access to a static mut must be inside an unsafe block, because the compiler cannot check that two threads are not using it at once. The starter then takes a mutable reference to it:

static mut COUNTER: i64 = 0;

let c = unsafe { &mut COUNTER };

With Rust 1.98.1 and edition 2024 that line is rejected:

error: creating a mutable reference to mutable static
  |
  |     let c = unsafe { &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: 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
help: use `&raw mut` instead to create a raw pointer

Three details of this message matter.

It is a lint, set to deny by default. static_mut_refs is a named lint, not a fixed language rule. In edition 2024 it is an error unless you lower it, for example with #[allow(static_mut_refs)]; in edition 2021 the same lint is only a warning. Writing unsafe does not silence it, because the lint fires inside unsafe blocks. Allowing it does not make the code correct, so treat the error as a request to change the design.

It rejects references, not the static. The lint fires on &mut COUNTER and also on shared references such as &COUNTER, including the hidden reference that println!("{}", COUNTER) creates. Reading or writing the value directly, as in unsafe { COUNTER += 1 } or let v = unsafe { COUNTER };, still compiles. The reason is the promise a reference makes. A &mut T promises that nothing else uses the value while the reference is live, and a &T promises that nothing modifies it. Either promise is easy to break for a value that every function and thread can reach, and breaking it is undefined behavior. Direct access avoids the reference but not the problem: two threads doing COUNTER += 1 on a static mut at the same time is a data race, which is also undefined behavior.

It has no error code. Lint errors print without an E number. Older material sometimes cites E0796; rustc --explain E0796 now begins "this error code is no longer emitted by the compiler".

How this relates to Sync

A type is Sync when it is safe for several threads to hold &T to the same value at the same time. An ordinary static is reachable by & from every thread, so the compiler requires its type to be Sync: static C: Cell<i32> = Cell::new(0); fails with E0277 (Cell<i32> cannot be shared between threads safely). A static mut has no such requirement; the compiler instead requires unsafe for every access and leaves the synchronization to you. The edition 2024 lint narrows that unchecked path by rejecting references, but it does not prevent races through direct access.

Replacing static mut

These options are roughly in order of preference:

  1. An atomic. static COUNTER: AtomicI64 = AtomicI64::new(0); has no mut and needs no unsafe. It is allowed because AtomicI64::new is a const fn and AtomicI64 is Sync: its methods, such as fetch_add, update the value through &self as one indivisible operation. This is the intended fix here. (AtomicI64 exists only on targets with 64-bit atomic support, which includes common desktop and server targets.)

  2. Mutex or RwLock. Mutex::new and RwLock::new are const fns, so static LOG: Mutex<Vec<String>> = Mutex::new(Vec::new()); compiles. Use a lock when the state is larger than one value an atomic can hold.

  3. OnceLock or LazyLock for a value that is computed once and then only read.

  4. &raw mut COUNTER when foreign code needs a pointer to the static. This creates a raw pointer without first creating a reference, so the lint does not fire. Using the pointer is still unsafe, and you remain responsible for preventing concurrent conflicting access.

  5. A wrapper around UnsafeCell when nothing above fits:

    use std::cell::UnsafeCell;
    
    struct SyncCell<T>(UnsafeCell<T>);
    // SAFETY: every access must be synchronized by the code that uses it.
    unsafe impl<T: Send> Sync for SyncCell<T> {}
    
    static S: SyncCell<i64> = SyncCell(UnsafeCell::new(0));
    

    The unsafe impl is a promise to the compiler that all access is synchronized; the compiler does not check it. The T: Send bound fits a design that hands out exclusive access one thread at a time, as Mutex does; if threads read the value concurrently through &T, T must be Sync too. The standard library's std::cell::SyncUnsafeCell serves this purpose but is unstable on Rust 1.98.1 (using it on stable fails with E0658).

The static outlives the call

A static lives for the whole run of the program, so a second call to global_counter in the same process would start from whatever the first call left behind. Store 0 at the top of the function so that its result depends only on its argument. The test harness runs one call per process, so the tests cannot detect a missing reset; the contract above still requires it. In general, functions whose results depend on earlier calls are harder to test, which is why passing state as a parameter is often preferable to a global.

Why Relaxed ordering is enough

fetch_add is a single atomic read-modify-write, so no increment can be lost, whatever memory ordering you pass. The ordering only controls what other memory a thread is guaranteed to see, and this counter publishes no other data. The reads happen after thread::scope returns, and the scope waits for (joins) every thread it spawned; that join guarantees the reading thread sees all of the workers' updates. Ordering::Relaxed is therefore correct for the reset, the updates and the reads.

Check your understanding This program compiles. What does it print, and which diagnostics does Clippy report? Show answer
use std::sync::atomic::{AtomicI64, Ordering};

const COUNTER: AtomicI64 = AtomicI64::new(0);

fn bump() -> i64 {
    COUNTER.fetch_add(1, Ordering::Relaxed)
}

fn main() {
    println!("{} {} {}", bump(), bump(), bump());
}

It prints 0 0 0. With static instead of const, it prints 0 1 2.

A const is not a single location in memory. Each use of COUNTER creates a new temporary AtomicI64 with value 0, and fetch_add returns that temporary's old value (0) and increments the temporary, which is dropped at the end of the statement. Nothing is ever counted.

With Rust 1.98.1, cargo clippy reports three warnings:

clippy::declare_interior_mutable_const
clippy::borrow_interior_mutable_const
const_item_interior_mutations          (a rustc lint, also shown by cargo build)

All three are warn-by-default, and this course runs Clippy with warnings treated as errors, so a submission containing this mistake is rejected.

A related compiler check

rustc's invalid_atomic_ordering lint is deny-by-default. It rejects orderings that the atomic methods document as invalid and would panic on at run time: a load with Release or AcqRel, a store with Acquire or AcqRel, and a compare_exchange whose failure ordering is Release or AcqRel. (A failure ordering stronger than the success ordering, such as Relaxed on success with SeqCst on failure, is allowed and not reported; older material sometimes says otherwise.) The lint cannot tell you that Relaxed is too weak for what your program needs; that requires reasoning about which memory each thread must see.