
1. I replaced a lock with a single CPU instruction
No refactor. No new crate. No architecture diagram taped to a whiteboard.
I deleted a Mutex<bool>, wrote AtomicBool, and moved on with my day.
The service got faster. The code got shorter. A subtle class of contention disappeared entirely.
That’s the whole story — and it’s worth telling, because most Rust developers reach for Mutex out of habit, not necessity. Sometimes the fastest code isn't clever. It's just the right primitive for the job you actually have, not the job you assumed you had.
2. The habit: Mutex<bool> by default
I see this pattern constantly — in my own old code, in every second PR review:
struct Worker {
running: Mutex<bool>,
}A flag — “is this running,” “was shutdown requested,” “is the cache warm” — wrapped in a full mutex. Lock to read. Lock to write. Every time.
Why? Because Mutex<T> is the first synchronization primitive Rust teaches, and it works. It's safe, obvious, the borrow checker is happy, and you move on. Nobody gets paged at 3 a.m. because a Mutex<bool> misbehaved.
But “works” and “correct tool” are different claims. A Mutex protects a critical section — logic that needs exclusive access across multiple operations. A boolean flag isn't a critical section. It's one word in memory. Locking around it is renting a conference room to answer a yes/no question.
The habit isn’t stupidity. It’s momentum. And momentum is exactly what you should interrogate the moment performance starts to matter.
3. Less is more: what a flag actually needs
Ask what a shutdown flag, a “healthy” flag, or a feature toggle genuinely requires:
- Locking? No — there’s no multi-step invariant to protect. The value is true or false. Full stop.
- Waiting? No — a reader doesn’t need the writer to finish anything; it just needs a valid value, right now.
- Ownership transfer? No — nobody needs exclusive custody of a boolean to do work on it.
- Context switching? Definitely not — and this is the cost nobody budgets for. A contended Mutex can put a thread to sleep and wake it later via the OS scheduler. That's a syscall and a context switch, just to move one bit of information.
A flag has one job: let one thread set it, let others see the new value fast. No dependent operations, no sequence to protect — just a single atomic read or write.
The CPU already has an instruction for that job. It’s called an atomic load and an atomic store. You don’t need a lock to do what the hardware already does in one cycle.
4. The right tool: AtomicBool
In the service I was working on, a background worker needed to know — cheaply, constantly — whether a shutdown had been requested. Original code:
use std::sync::Mutex;
struct Worker {
shutdown: Mutex<bool>,
}
impl Worker {
fn request_shutdown(&self) {
*self.shutdown.lock().unwrap() = true;
}
fn should_stop(&self) -> bool {
*self.shutdown.lock().unwrap()
}
}should_stop() ran on every loop iteration — thousands of times a second, across multiple worker threads. Every call took a lock, for a value that changes maybe once in the process's entire lifetime. Pure overhead on the hottest path in the service.
The replacement:
use std::sync::atomic::{AtomicBool, Ordering};
struct Worker {
shutdown: AtomicBool,
}
impl Worker {
fn request_shutdown(&self) {
self.shutdown.store(true, Ordering::Relaxed);
}
fn should_stop(&self) -> bool {
self.shutdown.load(Ordering::Relaxed)
}
}No .lock(). No Result to unwrap. No poisoning to reason about if a thread panics mid-section — a Mutex failure mode people forget exists. Just a load and a store, each a single atomic CPU instruction.
Reads are lock-free. Writes are lock-free. Multiple threads can call should_stop() concurrently with zero contention — there's no lock to fight over, so there's nothing to wait for. AtomicBool is Sync, so it drops straight into an Arc<Worker> and shares across threads exactly like the mutex version — minus the tax.
Rule of thumb: if your shared state is one flag, one counter, or one pointer — not a struct, not several fields that must change together — Atomic* is very likely the better fit.
5. Relaxed doesn’t mean unsafe
Ordering::Relaxed is the part that makes people nervous — it sounds like "I didn't think hard enough." That's backwards. Relaxed is a precise statement of what guarantee you need, not a shortcut around one.
The intuition, no memory-model theory required: every atomic operation on a single variable is seen in a consistent order by every thread. No thread ever sees a bool half-flipped — that's what makes it atomic. What Relaxed gives up is any guarantee about other memory around it; it doesn't order unrelated reads and writes relative to this one.
For a shutdown flag, that’s exactly what you want. should_stop() doesn't care about any other data the writer touched before or after — it just needs the current value of this one atomic. Nothing else needs to be "published" alongside it. One variable, one guarantee, no dependencies. Relaxed is correct because the flag is the entire message.
Where it stops being enough: the moment the flag signals that something else is now safe to read. Classic case — a producer writes into a buffer, then flips a ready flag for a consumer:
// NOT safe with Relaxed:
buffer.write(data);
ready.store(true, Ordering::Relaxed); // consumer might see `ready`
// before it sees `data`The flag is now carrying an implicit promise — “everything I wrote before this is visible to you” — and Relaxed doesn't transmit that promise. That's when you reach for Ordering::Release on the store and Ordering::Acquire on the load, which guarantee everything before the release is visible after the matching acquire.
Simple rule: standalone flag → Relaxed. Flag that's a green light for other memory → Release/Acquire. Unsure? Start stricter, and only relax once you can name the exact invariant you're relying on.
6. What I gained
Nothing exotic. That’s the point. The gains were boring, cumulative, and real:
- Lower overhead per call. No syscall path, no scheduler involvement, no lock/unlock bookkeeping — just a load or store the CPU runs natively.
- No lock contention. Multiple threads polling the flag every iteration meant occasional serialization on something conceptually read-only 99.999% of the time. The atomic version has no serialization point for reads at all.
- Cleaner code. No .lock().unwrap(), no Result threading through call sites, no poisoning edge case to reason about. The intent — "this is a flag, not a critical section" — is visible directly in the type.
- Better throughput under load. The gap compounds with thread count. A Mutex gets more expensive as concurrency rises; a Relaxed atomic load stays flat. The busier the service got, the bigger the win — exactly the direction you want a tradeoff to break in.
None of this took tuning, benchmarking frameworks, or unsafe code. It took recognizing the data structure didn’t match the problem.
7. Lessons
- Don’t optimize first — model the problem first. The win wasn’t “atomics are fast.” It was “this was never a locking problem.”
- Choose the simplest primitive that satisfies your actual invariant. Not the most familiar one. Not the safest-feeling one. The one that matches what the data needs.
- Every lock has a cost, even uncontended. Locks aren’t free — they carry atomic operations internally, plus the ergonomic tax of Results and poisoning. You're paying for machinery you don't need.
- **A **Mutex protects a critical section. A single primitive value usually isn't one. One bool, one integer, one pointer? Check Atomic* before reaching for Mutex.
- Relaxed is a claim about your data, not a shortcut. Use it once you can say precisely why no other memory needs ordering around this operation. If you can't say that yet, that's useful information too.
Sometimes the deepest architectural insight is refusing to add architecture at all. One atomic. Zero locks. Occasionally, that really is the whole fix.