Concurrency

#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.

#160 May 2026

160. Arc<T> — Atomic Reference Counting for Threads, Plus Mutex and RwLock

Rc<T> gave us shared ownership inside one thread. The moment you thread::spawn it, the compiler refuses. Arc<T> is the same shape with an atomic counter: same clone-the-pointer ergonomics, but Send + Sync and safe to share between threads — and the building block under almost every concurrent pattern in Rust.

The pain: Rc stops at the thread boundary

Rc’s counter is a plain usize. Two threads incrementing it at the same time could read, write, and lose updates — and a lost increment would free a still-referenced allocation. So Rc<T> is deliberately !Send, and the compiler stops you before you can try:

1
2
3
4
5
6
use std::rc::Rc;
use std::thread;

let cfg = Rc::new(String::from("app"));
thread::spawn(move || println!("{cfg}"));
// error[E0277]: `Rc<String>` cannot be sent between threads safely

Arc<T>: same API, atomic counter

Arc is Rc’s sibling with an atomic strong-count. Same new, same clone, same Deref<Target = T>, same “read-only through the pointer.” The cost is a fetch_add on every clone and a fetch_sub on every drop — measurable in tight benchmarks, free everywhere else:

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

let shared = Arc::new(vec![1, 2, 3, 4, 5]);

let mut handles = vec![];
for i in 0..3 {
    let view = Arc::clone(&shared);
    handles.push(thread::spawn(move || {
        let sum: i32 = view.iter().sum();
        (i, sum)
    }));
}

let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert_eq!(results.len(), 3);
assert!(results.iter().all(|(_, s)| *s == 15));

Every spawned thread gets its own Arc clone, bumps the strong-count on the way in, and decrements on the way out. The Vec is dropped exactly once — when the last thread (or the main one) lets go of the final clone.

The Arc::clone(&x) convention is even more important here than for Rc: at a glance you want to know that the closure captured a counter bump, not a 200MB deep copy of the value inside.

Read-only is still the default — wrap for mutation

Just like Rc, you only get &T through an Arc<T>:

1
2
3
4
5
use std::sync::Arc;
let a = Arc::new(String::from("app"));
let b = Arc::clone(&a);
a.push_str("-prod");
// error: cannot borrow data in an `Arc` as mutable

That’s the right default — two threads with &mut to the same value would be an instant data race. To mutate, you compose Arc with a lock. The wrapper choice mirrors the morning’s tradeoff: RefCell would have given us runtime borrow checking on one thread; across threads we need synchronization the OS understands.

Arc<Mutex<T>>: the workhorse of shared mutable state

Pair an Arc with a Mutex and you get the most common concurrent pattern in Rust: many threads, one allocation, exclusive write access at a time.

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

let counter = Arc::new(Mutex::new(0_u64));

let mut handles = vec![];
for _ in 0..8 {
    let c = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        for _ in 0..1_000 {
            *c.lock().unwrap() += 1;
        }
    }));
}
for h in handles { h.join().unwrap(); }

assert_eq!(*counter.lock().unwrap(), 8_000);

The Arc is what each thread owns; the Mutex is what makes the increment safe. Drop either half and the code doesn’t compile: without the Arc, the Mutex can’t be moved into eight closures at once; without the Mutex, you can’t get &mut u64 from &Mutex<u64> at all.

Arc<RwLock<T>>: when reads dominate

When the value is read far more often than it’s written — a config, a cache, a routing table — swap the Mutex for an RwLock. Many readers can hold a shared lock at the same time; writers still take it exclusively.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::sync::{Arc, RwLock};
use std::thread;

let config = Arc::new(RwLock::new(String::from("v1")));

let mut readers = vec![];
for _ in 0..4 {
    let c = Arc::clone(&config);
    readers.push(thread::spawn(move || {
        let g = c.read().unwrap();
        g.len()
    }));
}

{
    let mut w = config.write().unwrap();
    *w = String::from("v2-prod");
}

let lens: Vec<_> = readers.into_iter().map(|h| h.join().unwrap()).collect();
assert!(lens.iter().all(|&n| n == 2 || n == 7));
assert_eq!(*config.read().unwrap(), "v2-prod");

Arc<Mutex<T>> vs Arc<RwLock<T>> is one of those daily judgment calls: if write contention is high, Mutex is often faster because RwLock has more bookkeeping; if reads are ≥10× writes, RwLock lets the readers actually run in parallel.

The catch: Arc<T> doesn’t make T thread-safe

Arc::new(x) only requires T: Sync to be Send. If you wrap a Cell<u32> (which is !Sync) in an Arc, the type system catches you trying to share it across threads — there’s no magic; the wrappers compose because their Send/Sync bounds compose.

1
2
3
4
5
6
7
8
use std::sync::Arc;
use std::cell::Cell;
use std::thread;

let a = Arc::new(Cell::new(0));
let b = Arc::clone(&a);
thread::spawn(move || b.set(1));
// error: `Cell<i32>` cannot be shared between threads safely

That’s why Arc<Mutex<T>> and Arc<RwLock<T>> are the workhorses: the inner type provides the Sync, the Arc provides the Send.

When not to reach for Arc

Arc is the right tool for genuine shared ownership across threads, but it isn’t the only way to move data across one. If a thread just needs to borrow something for a bounded scope, scoped threads let you hand out plain &T without an Arc clone at all. If a value only needs to move from producer to consumer, an mpsc channel transfers ownership directly. Use Arc when more than one thread genuinely owns the same allocation at the same time — not as the default smart pointer for “I have a value and threads.”

157. Atomic* — The Thread-Safe Cell for Scalars

A Cell<T> lets a single thread mutate through &selfget/set instead of &mut. The atomic types in std::sync::atomic are the same shape, just Sync: a counter, flag, or pointer many threads can poke at without a Mutex, no lock acquisition, no guard, no panic on contention.

The pain: Mutex<u64> for a single counter

A request counter shared across worker threads is the textbook reach-for-Arc<Mutex<_>> case — and the textbook overkill:

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

let hits = Arc::new(Mutex::new(0u64));
let mut handles = Vec::new();

for _ in 0..8 {
    let h = Arc::clone(&hits);
    handles.push(thread::spawn(move || {
        for _ in 0..1000 {
            let mut g = h.lock().unwrap();   // lock, increment, unlock — 1000 times
            *g += 1;
        }
    }));
}
for h in handles { h.join().unwrap(); }
assert_eq!(*hits.lock().unwrap(), 8_000);

Eight threads contending on a lock for an n += 1 is a lot of ceremony to add one to an integer. The CPU has a single instruction for this. Rust exposes it.

The fix: AtomicU64 (or AtomicUsize, AtomicBool, …)

 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::{AtomicU64, Ordering};
use std::thread;

let hits = Arc::new(AtomicU64::new(0));
let mut handles = Vec::new();

for _ in 0..8 {
    let h = Arc::clone(&hits);
    handles.push(thread::spawn(move || {
        for _ in 0..1000 {
            h.fetch_add(1, Ordering::Relaxed);   // one instruction, no lock
        }
    }));
}
for h in handles { h.join().unwrap(); }
assert_eq!(hits.load(Ordering::Relaxed), 8_000);

No lock(), no guard, no unwrap. fetch_add is a single read-modify-write — on x86 it’s literally lock xadd. The Arc is still there because the threads need shared ownership, but the interior is lock-free.

The API is just Cell’s API, with orderings

Every atomic has the same small surface:

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

let n = AtomicUsize::new(7);

// like Cell::get / Cell::set
let v = n.load(Ordering::Relaxed);     assert_eq!(v, 7);
n.store(42, Ordering::Relaxed);
assert_eq!(n.load(Ordering::Relaxed), 42);

// like Cell::replace
let old = n.swap(100, Ordering::Relaxed);
assert_eq!(old, 42);
assert_eq!(n.load(Ordering::Relaxed), 100);

Notice what’s missing: there is no &mut T anywhere. You never borrow the inside. You read out a copy or write one in. That’s why this works across threads at all — there’s nothing to alias.

Read-modify-write: the real reason atomics exist

The fetch_* family is where atomics earn their keep. Each is a single uninterruptible round-trip:

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

let n = AtomicI32::new(10);

assert_eq!(n.fetch_add(5, Ordering::Relaxed), 10);  // returns old
assert_eq!(n.load(Ordering::Relaxed), 15);

assert_eq!(n.fetch_sub(3, Ordering::Relaxed), 15);
assert_eq!(n.fetch_or(0b1000, Ordering::Relaxed), 12);
assert_eq!(n.fetch_and(0b1100, Ordering::Relaxed), 0b1100);
assert_eq!(n.load(Ordering::Relaxed), 0b1100);

fetch_add, fetch_sub, fetch_or, fetch_and, fetch_xor, fetch_min, fetch_max — each one returns the value before the operation. That “before” is what makes them composable: you know exactly which thread did the increment that took you from 999 to 1000.

For anything more complex than a single op (clamp, toggle a state machine, transform), reach for update instead of hand-rolling a compare_exchange loop.

AtomicBool: the flag that doesn’t need a Mutex

The most common “I just want one bit” case:

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

let stop = AtomicBool::new(false);

// thread A
stop.store(true, Ordering::Release);

// thread B's hot loop
if stop.load(Ordering::Acquire) {
    // shut down
}
# assert!(stop.load(Ordering::Acquire));

Release on the writer + Acquire on the reader pairs everything written before the store with everything read after the load — the standard cancellation-flag pattern. Relaxed would be fine if stop is the only thing the two threads share; use Acquire/Release when the flag is gating other writes.

The full menu

std::sync::atomic ships an atomic for every primitive size:

TypeNotes
AtomicBoolLock-free flags
AtomicU8 / U16 / U32 / U64 / UsizeUnsigned counters, bitmasks
AtomicI8 / I16 / I32 / I64 / IsizeSigned deltas
AtomicPtr<T>Raw *mut T, for hand-rolled lock-free structures

Not every target supports every width lock-free (32-bit ARM lacks 64-bit CAS, for example). cfg(target_has_atomic = "64") lets you gate code that requires it. On modern x86_64 and aarch64, all of the above are lock-free.

What you give up vs Mutex<T>

Atomics work only on values the CPU already knows how to swap in one instruction. The moment you need to atomically update two fields together — a counter and a timestamp, say — you’re back to Mutex<T>. There is no AtomicStruct. You can’t fetch_push a Vec.

The other thing you give up is loud failure. A Mutex poisoned by a panic returns an Err; a deadlock blocks forever and shows up in a stack dump. An atomic happily does the wrong thing forever if you pick the wrong Ordering — the bug manifests as a flaky test under heavy load on a weakly-ordered CPU, and not at all on your laptop. Use SeqCst when in doubt; reach for Relaxed/Acquire/Release only when you can name what’s being synchronized with what.

When to reach for atomics

Counters, flags, generation numbers, fetch_add-style ID allocators, the “is this initialized yet” bit. Anything where the value fits in a register and the only operation is read / write / one-shot RMW.

Anything fatter — a config map, a parsed AST, a connection pool — wants a Mutex<T> or RwLock<T> wrapped in an Arc. And for the “compute once, then read forever” case across threads, there’s a purpose-built tool — that’s this afternoon’s bite.

#156 May 2026

156. RwLock<T> — Many Readers OR One Writer, When Reads Dominate

This morning’s Mutex<T> treats every caller the same: one at a time, no matter what they’re doing. If ninety-nine of them only want to read, that’s ninety-nine threads serialized behind a lock they didn’t need. RwLock<T> splits the door in two — many readers OR one writer — so read-heavy workloads actually fan out.

The pain: Mutex serializes readers too

A Mutex doesn’t know or care whether you’re going to mutate. Two threads that just want to peek at a config still queue up:

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

let config = Arc::new(Mutex::new(vec!["a", "b", "c"]));
let mut handles = Vec::new();

for _ in 0..8 {
    let c = Arc::clone(&config);
    handles.push(thread::spawn(move || {
        let g = c.lock().unwrap();        // all 8 threads serialize here
        g.iter().map(|s| s.len()).sum::<usize>()
    }));
}

Eight threads, eight reads, zero writes — and they still run one at a time. For a config that’s read on every request and updated once an hour, that’s a lot of wasted parallelism.

The fix: read() and write()

RwLock<T> has two acquire methods, and they map directly onto the two halves of the aliasing rule:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
use std::sync::RwLock;

let lock = RwLock::new(vec![1, 2, 3]);

// Many readers can hold a read guard at the same time.
{
    let r1 = lock.read().unwrap();
    let r2 = lock.read().unwrap();
    assert_eq!(r1.len(), 3);
    assert_eq!(r2.len(), 3);
}

// Exactly one writer at a time, with no readers alive.
{
    let mut w = lock.write().unwrap();
    w.push(4);
}

assert_eq!(*lock.read().unwrap(), vec![1, 2, 3, 4]);

read() hands back a RwLockReadGuard that derefs to &T. write() hands back a RwLockWriteGuard that derefs to &mut T. Both release on drop. The whole point: any number of read() guards can be alive at once, as long as no write() guard is.

The classic shape: Arc<RwLock<T>> with many readers

The pattern is the same Arc<_>-wraps-the-shared-thing shape as Arc<Mutex<T>>, but the parallelism story changes. Readers actually run at the same time:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use std::sync::{Arc, RwLock};
use std::thread;

let cache: Arc<RwLock<Vec<u32>>> = Arc::new(RwLock::new(vec![10, 20, 30]));
let mut handles = Vec::new();

// 8 readers, all running concurrently.
for _ in 0..8 {
    let c = Arc::clone(&cache);
    handles.push(thread::spawn(move || {
        let g = c.read().unwrap();
        g.iter().sum::<u32>()
    }));
}

// One writer, runs alone.
{
    let c = Arc::clone(&cache);
    handles.push(thread::spawn(move || {
        let mut w = c.write().unwrap();
        w.push(40);
        0
    }));
}

let sums: Vec<u32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// Every reader saw either the pre-write or post-write state, never a torn one.
assert!(sums.iter().all(|&s| s == 60 || s == 100 || s == 0));

The point isn’t the assertion — it’s that the eight readers can interleave freely on real hardware. Swap in a Mutex and they’d be a stairstep.

The footgun: writer starvation

A reader-heavy workload can keep the lock in “shared” mode forever. A writer waiting on write() blocks every new reader from joining (on most platforms), but the current readers keep working — and as soon as one of them is done, if a new reader sneaks in before the writer is scheduled, the writer stays parked. The standard library’s RwLock does not promise any particular fairness policy, and historically the behavior varied per OS.

Two practical takeaways:

  1. Keep the write path short. Compute what you want to write outside the lock; take write() only to swap the result in.
  2. If you find yourself reaching for “give readers priority” or “give writers priority” knobs, you’ve outgrown std::sync::RwLock. Either restructure to publish snapshots through Arc::new swaps, or pull in parking_lot::RwLock which exposes fairness controls.

The other footgun: holding the read guard across a write

Same shape as the Mutex “hold the guard too long” bug, but uglier — because nested re-entry on the same thread will deadlock, not panic:

1
2
3
4
5
6
let lock = RwLock::new(0u32);

let r = lock.read().unwrap();
// Some library calls back into us here and tries:
let mut w = lock.write().unwrap();  // deadlock: we still hold `r`
*w += 1;

RefCell would panic loudly with already borrowed. RwLock will silently park the thread, and you’ll see it only in a stack dump. When in doubt, drop the guard explicitly before any call you don’t control.

Going the other way: downgrade to keep reading what you just wrote

Once you’ve finished a write and want to keep reading the same value without a release/reacquire window, RwLockWriteGuard::downgrade is the atomic way across. Worth knowing about for the cache-refresh shape, where the writer thread immediately turns into a long-lived reader.

When to pick RwLock vs Mutex

Mutex<T> for short critical sections, mixed read/write workloads, or anywhere the per-operation cost of a lock matters. Cheaper per acquire, simpler mental model, no fairness surprises.

RwLock<T> when reads vastly outnumber writes and the read critical section is non-trivial — long enough that running them in parallel actually pays for the higher per-acquire cost. Read-only config lookups served on every request, periodically refreshed snapshots, anything shaped like “1000 readers, 1 writer per minute.”

If reads are short (a single field load) and contention is low, plain Mutex is often faster in practice — the extra bookkeeping in RwLock isn’t free. Measure before assuming the read-write split is a win.

#155 May 2026

155. Mutex<T> — Cross-Thread Exclusive Access, With a Guard Instead of a Panic

Yesterday’s RefCell<T> gives you &mut through &self on a single thread — and panics the moment a second borrow shows up. Mutex<T> is the same idea wearing a hard hat: it’s Sync, so it works across threads, and instead of panicking on contention it just blocks until the other holder is done.

The pain: RefCell is single-threaded, and panics on contention

RefCell<T> is !Sync. The moment you try to share one across threads, the compiler stops you:

1
2
3
4
5
6
7
8
9
use std::cell::RefCell;
use std::sync::Arc;
use std::thread;

let shared = Arc::new(RefCell::new(0u32));
let s2 = Arc::clone(&shared);

thread::spawn(move || { *s2.borrow_mut() += 1; });
// error[E0277]: `RefCell<u32>` cannot be shared between threads safely

Even on one thread, RefCell will panic if a borrow_mut() clashes with a live borrow(). That’s fine for logic bugs you want to find loudly — useless for two real threads that genuinely both want to write.

The fix: lock() returns a guard

Mutex<T> is Sync (when T: Send), and its .lock() method takes &self, blocks until the mutex is free, and hands back a MutexGuard<'_, T>. The guard derefs to &mut T, and releases the lock when it drops:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::sync::Mutex;

let m = Mutex::new(0u32);

{
    let mut g = m.lock().unwrap();   // blocks here if held; we're alone, so it's instant
    *g += 1;
    *g += 1;
}                                     // lock released as `g` drops

assert_eq!(*m.lock().unwrap(), 2);

The .unwrap() is there because lock() returns Result — see “Poisoning” below.

The classic: Arc<Mutex<T>> across threads

Mutex is the inside half. To share ownership across threads you wrap it in Arc — the thread-safe sibling of Rc (covered in Sunday’s morning bite):

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

let counter = Arc::new(Mutex::new(0u32));
let mut handles = Vec::new();

for _ in 0..8 {
    let c = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        for _ in 0..100 {
            *c.lock().unwrap() += 1;
        }
    }));
}

for h in handles { h.join().unwrap(); }

assert_eq!(*counter.lock().unwrap(), 800);

Eight threads, a hundred increments each, eight hundred total — no torn writes, no races, because every increment runs while exactly one thread holds the lock.

The footgun: holding the guard too long

The single most common Mutex bug is leaving the guard alive across slow work. Anything between lock() and the guard going out of scope is serialized:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// BAD — lock held for the whole block, including the slow call
let g = state.lock().unwrap();
if let Some(v) = g.cache.get(&key) {
    slow_network_thing(v);            // every other thread is blocked on us
}

// BETTER — get out what you need, drop the guard, then do the slow work
let v = state.lock().unwrap().cache.get(&key).cloned();
if let Some(v) = v {
    slow_network_thing(&v);
}

drop(g) works too if you can’t easily restructure. The mental model: the guard is a lease, keep it as short as you can.

Poisoning, and why .lock() returns Result

If a thread panics while holding the lock, the Mutex is poisoned. Future lock() calls return Err(PoisonError) so you can decide whether the data is still consistent. You can always recover the inner guard with .into_inner():

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

let m = Arc::new(Mutex::new(vec![1, 2, 3]));
let m2 = Arc::clone(&m);

let _ = thread::spawn(move || {
    let _g = m2.lock().unwrap();
    panic!("oops");
}).join();                            // panic propagates, then mutex is poisoned

let mut g = match m.lock() {
    Ok(g) => g,
    Err(p) => p.into_inner(),         // we know our data is still fine
};
g.push(4);
assert_eq!(*g, vec![1, 2, 3, 4]);

For data where one panic mid-update really would leave things half-written, the Err is the signal to crash the whole component instead of papering over it.

When to pick Mutex vs RefCell vs RwLock

RefCell<T> for single-threaded interior mutability where contention is a bug. Panics loudly. Zero locking cost.

Mutex<T> when more than one thread needs to write — or might. Blocks instead of panicking. One holder at a time, readers and writers treated identically.

RwLock<T> (covered in this afternoon’s bite) when reads vastly outnumber writes and you want many readers to proceed in parallel. Pricier per-op than Mutex, but the parallelism wins for read-heavy workloads.

149. OnceLock::wait — Block a Thread Until Another One Initializes the Value

You have one thread loading a config and a handful of workers that can’t start until it’s ready. OnceLock::wait blocks until the value lands — no Condvar, no Mutex, no spin loop.

OnceLock<T> is a write-once cell: any number of threads can race to set it, but only the first wins. The usual reader API is get, which returns None until something has been stored:

1
2
3
4
5
6
use std::sync::OnceLock;

let cell: OnceLock<u32> = OnceLock::new();
assert_eq!(cell.get(), None);
cell.set(42).unwrap();
assert_eq!(cell.get(), Some(&42));

That’s fine when the reader can keep working without the value. But what if the reader genuinely needs it now? Before Rust 1.86 you’d reach for a Mutex<Option<T>> plus a Condvar, or spin in a loop calling get — both more code and more bugs than the problem deserves.

OnceLock::wait parks the calling thread until the cell is initialized, then hands back &T:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
use std::sync::OnceLock;
use std::thread;
use std::time::Duration;

static CONFIG: OnceLock<String> = OnceLock::new();

thread::scope(|s| {
    // Producer: pretend this is loading config from disk.
    s.spawn(|| {
        thread::sleep(Duration::from_millis(20));
        CONFIG.set("rustbites=on".into()).unwrap();
    });

    // Consumers: block until the producer is done, then read.
    for _ in 0..3 {
        s.spawn(|| {
            let cfg: &String = CONFIG.wait();
            assert_eq!(cfg, "rustbites=on");
        });
    }
});

Every consumer gets back the same &StringOnceLock only ever holds one value, so the borrow is shared and lives as long as the cell does. No cloning, no Arc wrapping.

wait plays nicely with the existing init helpers. If you have a fallible initializer that some threads might run and others just want to await, mix get_or_init with wait:

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

static GREETING: OnceLock<String> = OnceLock::new();

thread::scope(|s| {
    s.spawn(|| {
        // First thread here pays the cost; others get the cached value.
        GREETING.get_or_init(|| "hello, bites".into());
    });
    s.spawn(|| {
        // This thread doesn't care who initialized — just wants the value.
        assert_eq!(GREETING.wait(), "hello, bites");
    });
});

A few things worth knowing:

  • wait blocks forever if nobody ever calls set (or get_or_init succeeds). It’s a synchronization primitive, not a timeout — pair it with thread::spawn for a producer you actually control.
  • It’s &self, so any number of threads can wait on the same cell at once.
  • OnceLock<T> requires T: Send + Sync to be shared across threads, same as Arc.

For lazy-init that runs on first read, LazyLock is still the right tool. But when initialization happens elsewhere and other threads need to pause until it’s done, wait turns a Condvar dance into one method call.

#125 May 2026

125. RwLockWriteGuard::downgrade — Hand a Write Lock Off as a Read, Atomically

You took a write lock, updated the data, and now you only want to read. Dropping the write guard and re-acquiring as a reader leaves a window where another writer can slip in. downgrade closes that window.

The gap between releasing and re-acquiring

A common shape in read-heavy systems: a worker takes a write lock to refresh a cache, then wants to keep reading the value it just wrote. The straightforward version drops the writer and grabs a reader:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::sync::RwLock;

let cache = RwLock::new(0);

let mut w = cache.write().unwrap();
*w = 42;
drop(w); // <-- another writer can grab the lock here

let r = cache.read().unwrap();
assert_eq!(*r, 42);

Between drop(w) and cache.read() the lock is released. On a busy system, another writer can land in that hole and replace your 42 with something else before your reader sees it.

downgrade is atomic

Stabilized in Rust 1.92, RwLockWriteGuard::downgrade consumes the write guard and returns a read guard — no release, no reacquire. The transition is atomic, so no other writer can sneak in:

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

let cache = RwLock::new(0);

let mut w = cache.write().unwrap();
*w = 42;

// Atomically: write lock -> read lock. No window.
let r = RwLockWriteGuard::downgrade(w);
assert_eq!(*r, 42);

Other readers waiting on the lock can wake up immediately, while the value you just published is guaranteed to still be 42 when you read it back.

A real shape: refresh-then-publish

The pattern shows up wherever one thread mutates state and then turns into a long-lived reader of the same state — config reloads, cache refreshes, snapshot publishers:

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

let snapshot: Arc<RwLock<Vec<u32>>> = Arc::new(RwLock::new(vec![]));

let writer = {
    let snapshot = Arc::clone(&snapshot);
    thread::spawn(move || {
        let mut w = snapshot.write().unwrap();
        w.extend([10, 20, 30]); // expensive build

        // Downgrade so readers can fan in immediately,
        // and so we keep reading the value we just wrote.
        let r = RwLockWriteGuard::downgrade(w);
        r.iter().sum::<u32>()
    })
};

assert_eq!(writer.join().unwrap(), 60);

Without downgrade, you’d either hold the write lock longer than necessary (blocking every reader) or release it and risk reading stale-or-clobbered data.

When to reach for it

Use downgrade whenever a thread finishes writing and immediately wants to read the same RwLock — especially in read-heavy workloads where you want other readers to fan in as soon as possible without losing the consistency of “I’m reading what I just wrote.” If you don’t need the read afterwards, plain drop is fine; if you do, downgrade is the only way to get there without a race.

#087 Apr 2026

87. Atomic update — Kill the Compare-and-Swap Loop

Every Rust developer who’s written lock-free code has written the same compare_exchange loop. Rust 1.95 finally gives atomics an update method that does it for you.

The old way

Atomically doubling a counter used to mean writing a retry loop yourself:

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

let counter = AtomicUsize::new(10);

loop {
    let current = counter.load(Ordering::Relaxed);
    let new_val = current * 2;
    match counter.compare_exchange(
        current, new_val,
        Ordering::SeqCst, Ordering::Relaxed,
    ) {
        Ok(_) => break,
        Err(_) => continue,
    }
}
// counter is now 20

It works, but it’s boilerplate — and easy to get wrong (use the wrong ordering, forget to retry, etc.).

The new way: update

1
2
3
4
5
6
use std::sync::atomic::{AtomicUsize, Ordering};

let counter = AtomicUsize::new(10);

counter.update(Ordering::SeqCst, Ordering::SeqCst, |x| x * 2);
// counter is now 20

One line. No loop. No chance of forgetting to retry on contention.

The method takes two orderings (one for the store on success, one for the load on failure) and a closure that transforms the current value. It handles the compare-and-swap retry loop internally.

It returns the previous value

Just like fetch_add and friends, update returns the value before the update:

1
2
3
4
5
6
7
use std::sync::atomic::{AtomicUsize, Ordering};

let counter = AtomicUsize::new(5);

let prev = counter.update(Ordering::SeqCst, Ordering::SeqCst, |x| x + 3);
assert_eq!(prev, 5);  // was 5
assert_eq!(counter.load(Ordering::SeqCst), 8);  // now 8

This makes it perfect for “fetch-and-modify” patterns where you need the old value.

Works on all atomic types

update isn’t just for AtomicUsize — it’s available on AtomicBool, AtomicIsize, AtomicUsize, and AtomicPtr too:

1
2
3
4
5
use std::sync::atomic::{AtomicBool, Ordering};

let flag = AtomicBool::new(false);
flag.update(Ordering::SeqCst, Ordering::SeqCst, |x| !x);
assert_eq!(flag.load(Ordering::SeqCst), true);

When to use update vs fetch_add

If your operation is a simple add, sub, or bitwise op, the specialized fetch_* methods are still better — they compile down to a single atomic instruction on most architectures.

Use update when your transformation is more complex: clamping, toggling state machines, applying arbitrary functions. Anywhere you’d previously hand-roll a CAS loop.

Summary

MethodUse when
fetch_add, fetch_or, etc.Simple arithmetic/bitwise ops
updateArbitrary transformations (Rust 1.95+)
Manual CAS loopNever again (mostly)

Available on stable since Rust 1.95.0 for AtomicBool, AtomicIsize, AtomicUsize, and AtomicPtr.

51. File::lock — File Locking in the Standard Library

Multiple processes writing to the same file? That’s a recipe for corruption. Since Rust 1.89, File::lock gives you OS-backed file locking without external crates.

The problem

You have a CLI tool that appends to a shared log file. Two instances run at the same time, and suddenly your log entries are garbled — half a line from one process interleaved with another. Before 1.89, you’d reach for the fslock or file-lock crate. Now it’s built in.

Exclusive locking

File::lock() acquires an exclusive (write) lock. Only one handle can hold an exclusive lock at a time — all other attempts block until the lock is released:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::fs::File;
use std::io::{self, Write};

fn main() -> io::Result<()> {
    let mut file = File::options()
        .write(true)
        .create(true)
        .open("/tmp/rustbites_lock_demo.txt")?;

    // Blocks until the lock is acquired
    file.lock()?;

    writeln!(file, "safe write from process {}", std::process::id())?;

    // Lock is released when the file is closed (dropped)
    Ok(())
}

When the File is dropped, the lock is automatically released. No manual unlock() needed — though you can call file.unlock() explicitly if you want to release it early.

Shared (read) locking

Sometimes you want to allow multiple readers but block writers. That’s what lock_shared() is for:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
use std::fs::File;
use std::io::{self, Read};

fn main() -> io::Result<()> {
    let mut file = File::open("/tmp/rustbites_lock_demo.txt")?;

    // Multiple processes can hold a shared lock simultaneously
    file.lock_shared()?;

    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    println!("Read: {contents}");

    file.unlock()?; // explicit release
    Ok(())
}

Shared locks coexist with other shared locks, but block exclusive lock attempts. Classic reader-writer pattern, enforced at the OS level.

Non-blocking with try_lock

Don’t want to wait? try_lock() and try_lock_shared() return immediately instead of blocking:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
use std::fs::{self, File, TryLockError};

fn main() -> std::io::Result<()> {
    let file = File::options()
        .write(true)
        .create(true)
        .open("/tmp/rustbites_trylock.txt")?;

    match file.try_lock() {
        Ok(()) => println!("Lock acquired!"),
        Err(TryLockError::WouldBlock) => println!("File is busy, try later"),
        Err(TryLockError::Error(e)) => return Err(e),
    }

    Ok(())
}

If another process holds the lock, you get TryLockError::WouldBlock instead of hanging. Perfect for tools that should fail fast rather than block when another instance is already running.

Key details

  • Advisory locks: these locks are advisory on most platforms — they don’t prevent other processes from reading/writing the file unless those processes also use locking
  • Automatic release: locks are released when the File handle is dropped
  • Cross-platform: works on Linux, macOS, and Windows (uses flock on Unix, LockFileEx on Windows)
  • Stable since Rust 1.89

40. Scoped Threads — Borrow Across Threads Without Arc

Need to share stack data with spawned threads? std::thread::scope lets you borrow local variables across threads — no Arc, no .clone().

The problem

With std::thread::spawn, you can’t borrow local data because the thread might outlive the data:

1
2
3
4
5
6
7
let data = vec![1, 2, 3];

// This won't compile — `data` might be dropped
// while the thread is still running
// std::thread::spawn(|| {
//     println!("{:?}", data);
// });

The classic workaround is wrapping everything in Arc:

1
2
3
4
5
6
7
8
9
use std::sync::Arc;

let data = Arc::new(vec![1, 2, 3]);
let data_clone = Arc::clone(&data);

let handle = std::thread::spawn(move || {
    println!("{:?}", data_clone);
});
handle.join().unwrap();

It works, but it’s noisy — especially when you just want to read some data in parallel.

The fix: std::thread::scope

Scoped threads guarantee that all spawned threads finish before the scope exits, so borrowing is safe:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
let data = vec![1, 2, 3];
let mut results = vec![];

std::thread::scope(|s| {
    s.spawn(|| {
        // Borrowing `data` directly — no Arc needed
        println!("Thread sees: {:?}", data);
    });

    s.spawn(|| {
        let sum: i32 = data.iter().sum();
        println!("Sum: {sum}");
    });
});

// All threads have joined here — guaranteed
println!("Done! data is still ours: {:?}", data);

Mutable access works too

Since the scope enforces proper lifetimes, you can even have one thread mutably borrow something:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let mut counts = [0u32; 3];

std::thread::scope(|s| {
    for (i, count) in counts.iter_mut().enumerate() {
        s.spawn(move || {
            *count = (i as u32 + 1) * 10;
        });
    }
});

assert_eq!(counts, [10, 20, 30]);

Each thread gets exclusive access to its own element — the borrow checker is happy, no Mutex required.

When to reach for scoped threads

Use std::thread::scope when you need parallel work on local data and don’t want the overhead or ceremony of Arc/Mutex. It’s perfect for fork-join parallelism: spin up threads, borrow what you need, collect results when they’re done.