Concurrency

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.