Understanding static mut and Reentrancy in Rust
That's a great question, as it gets to the heart of why Rust is so strict about global state.
In a single-threaded program, reentrancy means a function's execution is paused in the middle, and then code is run that calls back into that function (or another function that modifies the same data) before the first call has finished.
The static mut variable is like a single whiteboard for the entire program. A reentrancy problem happens when you're in the middle of writing something on the whiteboard, you get interrupted, and the "interruption" also tries to read or write to that same whiteboard — messing up your original, unfinished thought.
Here are the most common non-threading examples:
1. Interrupts (Embedded / Bare-metal)
This is the most classic example. Imagine you're writing code for a microcontroller.
Your main loop is running. It reads a static mut COUNTER (value: 10) and is about to add 1 to it.
static mut COUNTER: u32 = 0; fn main_loop() { unsafe { // 1. Reads COUNTER (value: 10) let x = COUNTER; // <-- 3. INTERRUPT HAPPENS RIGHT HERE! // 6. Resumes. x is still 10. let y = x + 1; COUNTER = y; // 7. Writes 11 } }
A hardware timer interrupt fires. The CPU immediately pauses main_loop and jumps to the interrupt service routine (ISR).
#![allow(unused)] fn main() { fn timer_interrupt_handler() { unsafe { // 4. Interrupt code runs. Reads COUNTER (value: 10) let a = COUNTER; let b = a + 1; COUNTER = b; // 5. Writes 11 } } // Interrupt finishes, returns to main_loop }
Result: The counter was incremented twice, but the final value is 11, not 12.
This is a classic race condition — all within a single thread.
2. Signal Handlers (Unix-like Systems)
This is the operating system equivalent of an interrupt. A user can send a signal (like SIGINT via Ctrl+C) at any time.
Your program is in the middle of modifying a static mut variable.
A signal arrives. The OS pauses your code and runs your registered signal handler.
If that signal handler also tries to read or modify that same static mut variable, you have the exact same race condition as the interrupt example.
The handler is re-entering the logic that accesses the shared data.
3. Nested Callbacks
This is a "pure" software version. It happens when you pass a function (a closure) to another function, and that function calls your closure in a way you didn't expect.
Imagine you have a global state for a simple logger:
#![allow(unused)] fn main() { static mut LOG_PREFIX: &str = "MAIN"; // A function that sets a temporary prefix, does work, and restores it. fn do_work_with_log(new_prefix: &str, work: fn()) { unsafe { let old_prefix = LOG_PREFIX; // 1. Backs up "MAIN" LOG_PREFIX = new_prefix; // 2. Sets prefix to "WORK" work(); // 3. Calls the work function // 6. Resumes. But LOG_PREFIX is "RECURSIVE"! LOG_PREFIX = old_prefix; // 7. Restores "RECURSIVE"?? No, "MAIN". // But the state is all messed up. } } // A function that also logs fn recursive_call() { do_work_with_log("RECURSIVE", || { // 5. We are here. LOG_PREFIX is "RECURSIVE" println!("{} - Oh no!", unsafe { LOG_PREFIX }); }); } }
Now, what if you do this?
fn main() { do_work_with_log("WORK", || { // 4. We are here. LOG_PREFIX is "WORK". // But what if this work... calls do_work_with_log again? recursive_call(); }); }
The first call to do_work_with_log("WORK") is paused at step 3.
The second call (recursive_call) runs, and it also calls do_work_with_log("RECURSIVE").
This second, nested call will overwrite LOG_PREFIX. When recursive_call finishes, the original, outer do_work_with_log resumes — but the LOG_PREFIX it depended on has been changed by the nested call.
Summary
This is why static mut requires an unsafe block:
You are promising the compiler,
"I know about all these reentrancy issues (threads, interrupts, signals, callbacks) and I have personally handled them."
In safe Rust, you typically use abstractions like Mutex, Cell, or RefCell to handle interior mutability and avoid these pitfalls.