The page says mutable statics need unsafe because threads could race. The scary part is what the race looks like: no crash, no error, no warning — just a total that is quietly too small, different on every run. Wrapping the access in unsafe tells the compiler to stop checking; it does not make the access safe.
use std::thread;
static mut COUNTER: u64 = 0;
let handles: Vec<_> = (0..4).map(|_| {
thread::spawn(|| {
for _ in 0..100_000 {
unsafe { COUNTER += 1; }
}
})
}).collect();
for h in handles { h.join().unwrap(); }
println!("expected 400000, got {}", unsafe { COUNTER });
// debug build, 5 runs: 308261, 333075, 213152, 273938, 221915An unsafe block unlocks exactly five capabilities: dereferencing a raw pointer, calling an unsafe function, accessing or mutating a mutable static variable, implementing an unsafe trait, and accessing a union's fields. It is a common misconception that unsafe turns off the borrow checker or other safety checks entirely — it does not; those rules still apply everywhere. unsafe simply means the programmer is manually vouching for a small number of specific invariants the compiler cannot verify by itself.
Dereferencing a raw pointer
Raw pointers (*const T, *mut T) can be created safely, but dereferencing one — the actual read or write — requires unsafe, since the compiler cannot verify the pointer is valid.
let mut num = 5;
let r1 = &num as *const i32; // creating a raw pointer: SAFE
let r2 = &mut num as *mut i32;
unsafe {
println!("{}", *r1); // dereferencing: requires unsafe
*r2 = 10;
}Calling an unsafe function
A function marked unsafe fn can only be called from inside an unsafe block — the keyword documents that the function has invariants the caller must uphold that the compiler cannot check.
unsafe fn dangerous() {
println!("doing something the compiler can't verify");
}
unsafe {
dangerous(); // must be called from within unsafe
}The other three: statics, unsafe traits, unions
Mutable static variables require unsafe to read or write, since multiple threads accessing one could race. Implementing an unsafe trait (like Send or Sync manually) requires unsafe because the implementer is promising an invariant the compiler cannot check. Accessing a union's field requires unsafe because the compiler cannot know which field is currently valid. In every case, safe Rust code is typically wrapped around the unsafe operations, exposing a safe public API while the unsafe details stay internal — this is exactly how much of the standard library itself, like Vec, is implemented.
The fix isn't more care around the unsafe block — it's not needing one. A shared counter belongs in an atomic type, which the compiler already knows is safe to touch from many threads, so no unsafe appears at all. One caution from verifying this: the same racy program built with -O printed the correct 400000 on every one of 30 runs here. A data race that passes some builds is not a safe program, only a harder one to catch:
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
static COUNTER: AtomicU64 = AtomicU64::new(0); // no `mut`, no `unsafe`
let handles: Vec<_> = (0..4).map(|_| {
thread::spawn(|| {
for _ in 0..100_000 {
COUNTER.fetch_add(1, Ordering::Relaxed);
}
})
}).collect();
for h in handles { h.join().unwrap(); }
println!("expected 400000, got {}", COUNTER.load(Ordering::Relaxed));
// expected 400000, got 400000 (every run, debug and release)