#280 Jul 2026

280. Mutex::get_mut — You Have &mut, Stop Paying for lock()

A Mutex guards against other threads — but during setup and teardown there are no other threads. If you hold &mut Mutex<T> or own it outright, the borrow checker already proves exclusive access, and you can skip the lock entirely.

lock() works everywhere, so it’s easy to write it everywhere:

1
2
3
4
use std::sync::Mutex;

let m = Mutex::new(vec![1, 2, 3]);
m.lock().unwrap().push(4); // runtime lock — for nobody

Nothing else can even see m yet, but you still pay for an atomic operation and drag in the whole poisoning story. With a mutable binding, get_mut hands you the data directly:

1
2
let mut m = Mutex::new(vec![1, 2, 3]);
m.get_mut().unwrap().push(4); // no lock taken

This can’t block and can’t deadlock: &mut Mutex<T> is the proof that no one else holds the lock, checked at compile time instead of runtime. The unwrap only covers poisoning — a previous panic while locked — never contention.

The teardown twin is into_inner, which consumes the Mutex and gives the data back. The classic spot: after your threads are done, don’t lock to read the result — unwrap the wrapper:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
use std::thread;

let data = [1, 2, 3, 4, 5, 6];
let total = Mutex::new(0);

thread::scope(|s| {
    for chunk in data.chunks(2) {
        s.spawn(|| {
            *total.lock().unwrap() += chunk.iter().sum::<i32>()
        });
    }
});

// scope over — all threads joined, `total` is ours again
let sum = total.into_inner().unwrap();
assert_eq!(sum, 21);

Inside the scope, threads share &Mutex and must lock. After the scope, ownership snaps back, into_inner moves the value out, and the Mutex is gone — no final lock(), no clone() of the contents.

The same pair exists on RwLock, and the pattern generalizes: Arc::into_inner unwraps an Arc once the last clone is dropped, so an Arc<Mutex<T>> can be fully peeled back to a plain T after the last worker exits.

Locks are for sharing. When the type system says you’re alone, take the &mut and go.

Both stable since Rust 1.6.

#279 Jul 2026

279. thread::park — Block a Thread Without Dragging In a Mutex

This morning’s bite (278) needed a Mutex + Condvar pair just to make one thread wait for another. When exactly one thread sleeps and you hold its handle, thread::park does it with no lock at all.

Every thread owns a park token. thread::park() blocks until the token is available, then consumes it; unpark() makes it available. That token is the whole trick:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::thread;
use std::time::Duration;

let h = thread::spawn(|| {
    thread::sleep(Duration::from_millis(50));
    thread::park(); // returns instantly — token already there
});

h.thread().unpark(); // fires BEFORE the park
h.join().unwrap();

With a naive flag-and-sleep scheme, waking a thread before it goes to sleep means the wakeup is lost. The token can’t be lost: an early unpark is banked, and the next park returns immediately. That’s the race Condvar needs its Mutex to prevent — park solves it for free.

One Condvar lesson still applies, though: park may also wake spuriously, so pair it with a flag and re-check in a loop:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;

let go = Arc::new(AtomicBool::new(false));
let go2 = Arc::clone(&go);

let worker = thread::spawn(move || {
    while !go2.load(Ordering::Acquire) {
        thread::park(); // sleep until unparked
    }
    // ... do the work
});

go.store(true, Ordering::Release);
worker.thread().unpark();
worker.join().unwrap();

The flag decides whether to run; park only decides when to stop burning CPU. Store with Release, load with Acquire, and the write is visible when the loop exits — the exact handoff from bite 277.

Note what you didn’t need: no Mutex, no guard rebinding, no Condvar. The waiter is addressed directly through its Thread handle (worker.thread() — cloneable, Send, happy to be stashed anywhere).

There’s a deadline-aware sibling, thread::park_timeout(dur), for “wake me up when signaled, or after 10ms, whichever comes first” — the classic backoff loop in lock-free code.

Reach for Condvar when many threads wait on shared state; reach for park when one known thread naps until another taps it. It’s what mpsc channels and most executors use under the hood.

Stable since Rust 1.0.

#278 Jul 2026

278. Condvar::wait_while — The Wait Loop You Keep Writing (and Getting Backwards)

A Condvar can wake up for no reason at all — so every correct wait is a loop. wait_while writes that loop for you.

The textbook pattern: a worker flips a flag under a Mutex, and a waiting thread sleeps until it’s flipped. The naive version is broken:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::sync::{Condvar, Mutex};

let lock = Mutex::new(false);
let cvar = Condvar::new();

// waiter — WRONG
let ready = lock.lock().unwrap();
if !*ready {
    let _ready = cvar.wait(ready).unwrap(); // may wake spuriously!
}

wait is allowed to return even though nobody called notify_one — a spurious wakeup. The OS primitives underneath make no stronger promise, so neither does Rust. Correct code re-checks in a loop:

1
2
3
4
let mut ready = lock.lock().unwrap();
while !*ready {
    ready = cvar.wait(ready).unwrap();
}

That works, but it’s boilerplate with two sharp edges: forget the loop and you have a heisenbug that only fires under load; shuffle the guard rebinding and it won’t compile. wait_while folds the whole dance into one call:

1
2
3
4
let ready = cvar
    .wait_while(lock.lock().unwrap(), |ready| !*ready)
    .unwrap();
assert!(*ready);

The predicate answers “should I keep waiting?” — it returns true to sleep, false to wake. That inversion is the one thing to internalize: you pass |ready| !*ready, not |ready| *ready. Read it as “wait while not ready.”

Full picture with a notifying thread:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::sync::{Arc, Condvar, Mutex};
use std::thread;

let pair = Arc::new((Mutex::new(false), Condvar::new()));
let pair2 = Arc::clone(&pair);

thread::spawn(move || {
    let (lock, cvar) = &*pair2;
    *lock.lock().unwrap() = true;
    cvar.notify_one();
});

let (lock, cvar) = &*pair;
let ready = cvar
    .wait_while(lock.lock().unwrap(), |ready| !*ready)
    .unwrap();
assert!(*ready);

The predicate runs with the lock held, so it can inspect any state the mutex guards — a queue’s length, a counter, a shutdown flag — not just booleans. And because wait_while re-checks after every wakeup, spurious or real, the guard it returns is guaranteed to satisfy your condition.

There’s a deadline-aware sibling, wait_timeout_while, which additionally hands back a WaitTimeoutResult so you can tell “condition met” from “gave up.”

If all you’re guarding is “has this one value been initialized,” OnceLock::wait is the simpler tool. Condvar earns its keep when the condition is richer than init-once — and wait_while is how you use it without rediscovering spurious wakeups in production.

Stable since Rust 1.42.

#277 Jul 2026

277. Release/Acquire — Publish Data Through a Flag, Not Just the Flag

This morning’s swap bite ended with a warning: once the winning thread writes data that other threads read, Relaxed stops being enough. Here’s the two-line fix.

The classic pattern: one thread computes a result, then raises a flag so others know it’s ready. With Relaxed everywhere, that’s broken:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};

static RESULT: AtomicU64 = AtomicU64::new(0);
static READY: AtomicBool = AtomicBool::new(false);

// writer thread
RESULT.store(42, Ordering::Relaxed);
READY.store(true, Ordering::Relaxed); // may be reordered!

// reader thread
if READY.load(Ordering::Relaxed) {
    // can legally observe RESULT == 0 here
    let r = RESULT.load(Ordering::Relaxed);
}

Relaxed only makes each individual operation atomic — it says nothing about the order two different atomics become visible in. The compiler or CPU may commit the READY store before the RESULT store, so a reader sees the flag up but the data stale.

The fix is a pair, one ordering on each side of the flag:

1
2
3
4
5
6
7
8
9
// writer: Release on the store that publishes
RESULT.store(42, Ordering::Relaxed);
READY.store(true, Ordering::Release);

// reader: Acquire on the load that checks
while !READY.load(Ordering::Acquire) {
    std::hint::spin_loop();
}
assert_eq!(RESULT.load(Ordering::Relaxed), 42); // guaranteed

When an Acquire load sees the value written by a Release store, everything the writer did before the store is visible to the reader after the load. The flag becomes a one-way gate for all the writes behind it — note RESULT itself can stay Relaxed; the flag pair carries the synchronization.

Full picture, verified with a scoped thread:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
std::thread::scope(|s| {
    s.spawn(|| {
        RESULT.store(42, Ordering::Relaxed);
        READY.store(true, Ordering::Release);
    });
    s.spawn(|| {
        while !READY.load(Ordering::Acquire) {
            std::hint::spin_loop();
        }
        assert_eq!(RESULT.load(Ordering::Relaxed), 42);
    });
});

Two things worth knowing. First, the pairing only kicks in when the Acquire load actually sees the Released value — that’s why the reader spins. Second, you won’t catch the Relaxed bug on your x86 laptop: the hardware is strongly ordered and hides it. Your ARM build server is not so forgiving, and the compiler is allowed to reorder either way.

Rule of thumb: Release on the store that publishes, Acquire on the load that consumes, Relaxed for lone counters and flags that guard nothing. This pair is exactly what Mutex unlock/lock and OnceLock do for you under the hood — reach for those first; reach for the raw pair when you can name what’s being published.

Stable since Rust 1.0, on every Atomic* type.

#276 Jul 2026

276. AtomicBool::swap — Let Exactly One Thread Claim the Job

“Check the flag, then set it” has a gap where two threads both see false — and your run-once code runs twice. swap sets and reads the flag in one atomic step.

Say only one thread should print a deprecation warning, spawn the background worker, or run cleanup. The obvious flag check is broken:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::sync::atomic::{AtomicBool, Ordering};

static WARNED: AtomicBool = AtomicBool::new(false);

fn warn_once() {
    // RACE: both threads can see false
    if !WARNED.load(Ordering::Relaxed) {
        WARNED.store(true, Ordering::Relaxed);
        eprintln!("legacy config detected");
    }
}

Same check-then-act race as yesterday’s fetch_max story: two threads load false, both pass the if, both warn. The fix is again a single atomic call:

1
2
3
4
5
6
fn warn_once() {
    // one atomic op: first caller wins
    if !WARNED.swap(true, Ordering::Relaxed) {
        eprintln!("legacy config detected");
    }
}

swap stores the new value and returns the previous one as a single atomic operation. Only the first caller gets false back — everyone else sees true and skips the block. No mutex, no window to slip through:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};

static CLAIMED: AtomicBool = AtomicBool::new(false);
static RUNS: AtomicU32 = AtomicU32::new(0);

std::thread::scope(|s| {
    for _ in 0..8 {
        s.spawn(|| {
            if !CLAIMED.swap(true, Ordering::Relaxed) {
                RUNS.fetch_add(1, Ordering::Relaxed);
            }
        });
    }
});
assert_eq!(RUNS.load(Ordering::Relaxed), 1);

Relaxed is fine while the flag itself is the only shared state. The moment the winning thread writes data that losers will read, you need Acquire/Release ordering — or just reach for OnceLock, which handles that (and blocking until init finishes) for you.

Where swap beats Once/OnceLock: it produces no value, it never blocks the losers, and you can re-arm it — store(false, ...) resets the flag for the next round. Think “claim ticket”, not “lazy init”.

Works on every Atomic* type, stable since Rust 1.0.

#275 Jul 2026

275. fetch_max — Track a High-Water Mark Without the Race

“Check if it’s bigger, then store it” is two operations — and another thread can sneak between them. fetch_max is the whole thing in one atomic call.

Tracking peak latency across worker threads looks harmless:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::sync::atomic::{AtomicU64, Ordering};

static PEAK: AtomicU64 = AtomicU64::new(0);

fn record(latency: u64) {
    // RACE: another thread can store
    // between the load and the store
    if latency > PEAK.load(Ordering::Relaxed) {
        PEAK.store(latency, Ordering::Relaxed);
    }
}

Thread A reads 10, decides its 31 is a new peak. Thread B reads 10, decides its 50 is too. B stores 50, then A stores 31 — your peak just went down. Classic check-then-act race, and it’ll pass every test on your laptop.

The fix is one line:

1
2
3
fn record(latency: u64) {
    PEAK.fetch_max(latency, Ordering::Relaxed);
}

fetch_max compares and stores as a single atomic operation — no window for another thread to slip through. Like every fetch_* method it returns the previous value, which gives you new-record detection for free:

1
2
3
4
let prev = PEAK.fetch_max(latency, Ordering::Relaxed);
if prev < latency {
    println!("new record: {latency}ms");
}

fetch_min is the mirror image for low-water marks. One gotcha: initialize it to the identity for min — u64::MAX, not 0 — or nothing will ever be smaller than your starting value:

1
2
3
4
5
let floor = AtomicU64::new(u64::MAX);
for l in [12, 5, 9] {
    floor.fetch_min(l, Ordering::Relaxed);
}
assert_eq!(floor.load(Ordering::Relaxed), 5);

This morning’s bite 274 covered fetch_update for arbitrary update rules — but check the shelf first: if your rule is just “keep the bigger one”, fetch_max is a single hardware-friendly call with no closure and no retry loop.

Available on all integer Atomic* types, stable since Rust 1.45.

#274 Jul 2026

274. fetch_update — The CAS Loop You Keep Writing by Hand

There’s no fetch_add variant that stops at a cap, so you write a compare_exchange_weak loop by hand. fetch_update is that loop, done right, in one call.

The Atomic* types ship fetch_add, fetch_or, fetch_min — but the moment your update rule isn’t one of those, you’re hand-rolling a compare-and-swap loop. A rate limiter that counts hits but saturates at a cap:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::sync::atomic::{AtomicU32, Ordering};

const CAP: u32 = 3;
let hits = AtomicU32::new(0);

let mut cur = hits.load(Ordering::Relaxed);
while cur < CAP {
    match hits.compare_exchange_weak(
        cur,
        cur + 1,
        Ordering::Relaxed,
        Ordering::Relaxed,
    ) {
        Ok(_) => break,
        Err(actual) => cur = actual,
    }
}

Easy to get subtly wrong: forget to reload on failure, spin forever, or check the cap on the stale value. fetch_update owns the loop — you supply only the transform, returning Some(new) to store or None to leave it alone:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let hits = AtomicU32::new(0);

for _ in 0..5 {
    let _ = hits.fetch_update(
        Ordering::Relaxed,
        Ordering::Relaxed,
        |n| (n < CAP).then(|| n + 1),
    );
}

assert_eq!(hits.load(Ordering::Relaxed), CAP);

The return value tells you what happened: Ok(prev) if your closure returned Some and the store went through, Err(prev) if it returned None — either way you get the previous value, so “did we hit the cap?” is just .is_err().

Two things to keep in mind. The closure can run more than once under contention (another thread changed the value between your load and the swap), so keep it pure — no side effects. And it takes two Orderings: the first for the successful store, the second for the loads; (Relaxed, Relaxed) is fine for counters.

Works on every Atomic* type, stable since Rust 1.45.

#273 Jul 2026

273. remove_file — Deleting Something That Might Already Be Gone

create_dir_all (bite 272) is idempotent by design. Its deletion twins are not: remove_file and remove_dir_all fail with NotFound — and the exists() guard is the same race all over again.

Cleanup code usually doesn’t care whether the thing was there:

1
2
// Error: NotFound — first run, nothing to clean.
fs::remove_file("cache/state.bin")?;

So people reach for the same guard the whole filesystem run has been warning about:

1
2
3
if path.exists() {
    fs::remove_file(path)?; // another process can win the race
}

Check-then-act again (bite 270): if anything else deletes the file between exists() and remove_file, you’re back to the NotFound error the guard was supposed to prevent.

The fix is to call the operation unconditionally and treat “already gone” as success:

1
2
3
4
5
6
7
8
use std::io::{self, ErrorKind};

fn remove_if_exists(path: &Path) -> io::Result<()> {
    match fs::remove_file(path) {
        Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
        other => other,
    }
}

Now it’s safe to call on every shutdown, every retry, every run — the outcome you wanted (“the file is not there”) is the same whether the call deleted it or found nothing.

Three details worth knowing:

  • Same pattern for directories. fs::remove_dir_all also errors on a missing path; wrap it the same way. (Since Rust 1.62 its internals are TOCTOU-hardened on most platforms, so the recursive walk doesn’t race — but the top-level NotFound is still yours to handle.)
  • Only swallow NotFound. A PermissionDenied or “directory not empty” is a real failure — the match above still propagates everything else, which a bare let _ = fs::remove_file(...) would silently eat.
  • Deletion may not be immediate. On Windows, a file open by another process is marked for deletion and disappears when the last handle closes — so don’t follow a successful remove_file with code that assumes the name is instantly reusable.

The asymmetry is worth remembering: create_dir_all bakes idempotence in, the remove_* family makes you opt in with one ErrorKind match.

#272 Jul 2026

272. fs::create_dir_all — mkdir -p Without the exists() Check

fs::create_dir fails if the parent is missing and fails if the directory already exists. create_dir_all handles both — recursive and idempotent in one call.

The single-level version trips over both ends of the problem:

1
2
3
4
5
// Error: NotFound — `cache/images` doesn't exist yet.
fs::create_dir("cache/images/thumbs")?;

// Error: AlreadyExists — on the second run.
fs::create_dir("cache")?;

So people reach for the guard:

1
2
3
if !dir.exists() {
    fs::create_dir(dir)?; // still one level at a time
}

That’s a check-then-act race (bite 270 again): another process can create the directory between the check and the call, and you still can’t build a nested path in one go.

create_dir_all is the whole fix:

1
2
fs::create_dir_all("cache/images/thumbs")?; // creates all three
fs::create_dir_all("cache/images/thumbs")?; // Ok — already there

It creates every missing component along the path, and it returns Ok when the directory already exists — no exists() probe, no AlreadyExists special-casing, safe to call on every startup.

Three details worth knowing:

  • It’s concurrency-tolerant. If another thread or process creates one of the components mid-walk, create_dir_all shrugs and keeps going. The exists() guard can’t promise that.
  • A file in the way is still an error. If cache/images exists but is a regular file, you get an error instead of silent breakage — exactly what you want.
  • It won’t touch what’s already there. Permissions of existing directories are left alone; only newly created components get the default (or DirBuilder-configured) mode.

Same moral as the rest of this filesystem run: don’t interrogate the filesystem and then act on the answer — call the operation that already handles every case.

#271 Jul 2026

271. fs::rename — Replace a File Atomically, Never Expose a Half-Write

fs::write truncates the file before writing. Crash halfway — or get read halfway — and the world sees a torn file. fs::rename is the atomic swap that fixes it.

The obvious way to save state overwrites in place:

1
2
// Truncates state.json to 0 bytes, then writes.
fs::write("state.json", &json)?;

Between the truncate and the final byte, state.json is incomplete. A concurrent reader gets garbage; a crash or power loss leaves it that way permanently. For a config file, a cache, or anything another process watches, that window is a real bug.

The fix is the classic write-then-rename dance:

1
2
fs::write("state.json.tmp", &json)?;   // build the full file aside
fs::rename("state.json.tmp", "state.json")?; // atomic swap

On every major platform, rename over an existing file is atomic: any observer sees either the old complete file or the new complete file, never a mix and never a missing file. If the process dies before the rename, the old file is untouched and only a .tmp straggler is left behind.

Three details worth knowing:

  • Keep the temp file in the same directory. rename can’t cross filesystems — a temp file in /tmp and a target on another mount fails with an error (ErrorKind::CrossesDevices). Same directory guarantees same filesystem, and keeps the swap atomic.
  • Durability needs one more step. Atomic ≠ flushed. If you need the data to survive power loss, open the temp file yourself and call sync_all() before renaming, instead of using fs::write.
  • Unique temp names matter under concurrency. Two processes writing state.json.tmp will trample each other. Give each writer its own name (PID, random suffix) — and create it with File::create_new (bite 169) so collisions fail loudly.

Same moral as try_exists in bite 270: don’t check-then-act on the filesystem — make the filesystem do the transition in one atomic step.