#285 Jul 2026

285. try_iter — The Drain Loop You Wrote This Morning, as One Line

That match-on-try_recv drain loop from bite 284? The standard library already wrote it for you: try_iter yields every pending message and stops the moment the channel is empty.

Bite 284 drained a channel by hand: loop, try_recv, match, break on Empty. That shape is so common it’s an iterator:

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

let (tx, rx) = mpsc::channel();

for i in 0..3 {
    tx.send(i).unwrap();
}

// drain everything pending — never blocks
let drained: Vec<_> = rx.try_iter().collect();
assert_eq!(drained, [0, 1, 2]);

// queue empty now — iterator just ends
assert_eq!(rx.try_iter().next(), None);

Unlike rx.iter(), which blocks on every next() until a message arrives, try_iter never waits. Empty channel means the iterator simply ends — perfect for the frame loop, which collapses from six lines to two:

1
2
3
4
5
6
loop {
    for msg in rx.try_iter() {
        apply(msg);
    }
    render_frame();
}

The trade-off: try_iter flattens both Empty and Disconnected into plain None. The exit signal bite 284 relied on is invisible here:

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

drop(tx); // last sender gone

// still just None — the disconnect is swallowed
assert_eq!(rx.try_iter().next(), None);

// ask try_recv when you need the real reason
assert_eq!(rx.try_recv(), Err(TryRecvError::Disconnected));

So a loop that must notice dead senders can drain with try_iter, then make one try_recv call to check for Disconnected — or just keep the explicit match from bite 284.

Rule of thumb: try_iter when “no messages” and “no more senders” mean the same thing to you; try_recv when disconnection is your signal to stop.

#284 Jul 2026

284. try_recv — Poll the Channel Without Stalling the Loop

Your render loop can’t afford to block on recv() — but it still needs to pick up messages. try_recv checks the channel and returns immediately, message or not.

Bite 283 put a deadline on the wait. But some loops can’t wait at all: a game tick, a UI frame, a simulation step that must keep running whether or not a worker has reported in. Even recv_timeout(1ms) steals a millisecond you don’t have.

try_recv never blocks. Either a message is ready now, or you get an error telling you why not:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::sync::mpsc::{self, TryRecvError};
use std::thread;

let (tx, rx) = mpsc::channel();

// nothing sent yet — returns instantly, no waiting
assert_eq!(rx.try_recv(), Err(TryRecvError::Empty));

tx.send("loaded: level.dat").unwrap();
assert_eq!(rx.try_recv(), Ok("loaded: level.dat"));

Like recv_timeout, the error type carries the signal. Empty means “nothing right now, sender still alive — check again next tick.” Disconnected means “every sender is gone — stop checking”:

1
2
3
drop(tx); // last sender gone

assert_eq!(rx.try_recv(), Err(TryRecvError::Disconnected));

That’s the shape of the classic frame loop — drain whatever arrived since last tick, then get on with the frame:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
loop {
    // drain all pending messages, never block
    loop {
        match rx.try_recv() {
            Ok(msg) => apply(msg),
            Err(TryRecvError::Empty) => break,
            Err(TryRecvError::Disconnected) => return,
        }
    }
    render_frame();
}

The trap to avoid: treating Empty and Disconnected the same. Match only on Ok and you’ll happily poll a dead channel forever — a busy-loop that can never receive anything. Disconnected is your exit condition; use it.

Rule of thumb: recv() when you have nothing better to do, recv_timeout when you can wait a little, try_recv when the loop must not stop.

#283 Jul 2026

283. recv_timeout — Wait for a Message, But Not Forever

rx.recv() blocks until a message arrives — and if the producer hangs, so do you. recv_timeout puts a deadline on the wait and tells you why it ended.

Bite 282 bounded the queue so a fast producer can’t outrun the consumer. The mirror problem: a consumer stuck on recv() because the producer stalled — a hung network call, a deadlock, a job that just takes long. recv() gives you no way to bail out, log progress, or check a shutdown flag:

1
let msg = rx.recv().unwrap(); // producer hangs → you hang

recv_timeout(d) waits at most d, then returns an error you can act on:

 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::mpsc::{self, RecvTimeoutError};
use std::time::Duration;
use std::thread;

let (tx, rx) = mpsc::channel();

thread::spawn(move || {
    thread::sleep(Duration::from_millis(50)); // slow worker
    tx.send("result").unwrap();
});

// worker isn't done yet — we get control back instead of hanging
assert_eq!(
    rx.recv_timeout(Duration::from_millis(5)),
    Err(RecvTimeoutError::Timeout)
);

// try again with a longer deadline — message arrives
assert_eq!(
    rx.recv_timeout(Duration::from_secs(2)).unwrap(),
    "result"
);

The error type is the useful part. Timeout means “nothing yet, sender still alive — worth retrying.” Disconnected means “every sender is gone — no message will ever come, stop waiting”:

1
2
3
4
5
// the worker thread finished, dropping its Sender
assert_eq!(
    rx.recv_timeout(Duration::from_secs(2)),
    Err(RecvTimeoutError::Disconnected)
);

That distinction is what makes the classic supervision loop work — wake up periodically, check a shutdown flag, and still exit promptly when the workers are done:

1
2
3
4
5
6
7
8
9
loop {
    match rx.recv_timeout(Duration::from_millis(100)) {
        Ok(job) => handle(job),
        Err(RecvTimeoutError::Timeout) => {
            if shutdown_requested() { break; }
        }
        Err(RecvTimeoutError::Disconnected) => break,
    }
}

Rule of thumb: recv() only when you’re certain the sender side can’t stall. Anywhere a producer might hang — or you need to interleave waiting with other checks — recv_timeout keeps the consumer in control.

#282 Jul 2026

282. mpsc::sync_channel — A Channel That Pushes Back When the Consumer Falls Behind

mpsc::channel() never says no: a fast producer and a slow consumer means the queue — and your memory — grows without bound. sync_channel(n) caps the queue and makes the producer wait.

Bite 281 used mpsc::channel() to collect results from workers. Its buffer is unbounded — send always succeeds immediately, no matter how far behind the receiver is:

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

let (tx, rx) = mpsc::channel();
for i in 0..1_000_000 {
    tx.send(i).unwrap(); // never blocks, just buffers
}
// receiver hasn't read a single message yet —
// all million are sitting in the queue

If the consumer parses files, writes to a database, or talks to a network, the producer can outrun it by orders of magnitude. Nothing crashes; the process just eats memory until something else does.

mpsc::sync_channel(n) creates a bounded channel: at most n messages queued. Once full, send blocks until the receiver catches up — that’s backpressure. try_send gives you the non-blocking version that reports the problem instead:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
use std::sync::mpsc::{self, TrySendError};

let (tx, rx) = mpsc::sync_channel(2);

tx.send(1).unwrap();
tx.send(2).unwrap(); // buffer full

// send(3) would block here — try_send tells you why
assert!(matches!(
    tx.try_send(3),
    Err(TrySendError::Full(3))
));

assert_eq!(rx.recv().unwrap(), 1); // one slot frees up
tx.try_send(3).unwrap();           // now it fits

Note the error hands the value back — TrySendError::Full(3) — so nothing is lost; you can retry, drop it, or spill it elsewhere.

The odd-looking special case is sync_channel(0): a rendezvous channel with no buffer at all. Every send blocks until a recv is ready to take the value, so each handoff is also a synchronization point between the two threads:

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

let (tx, rx) = mpsc::sync_channel(0);

let worker = thread::spawn(move || {
    tx.send("done").unwrap(); // blocks until main receives
    // reaching this line proves main got the message
});

assert_eq!(rx.recv().unwrap(), "done");
worker.join().unwrap();

Rule of thumb: channel() when producers must never wait, sync_channel(n) when the consumer sets the pace. If you’d rather drop data than slow the producer, try_send makes that an explicit decision instead of an accidental out-of-memory.

#281 Jul 2026

281. mpsc::channel — Send Results Back Instead of Fighting Over a Mutex

Threads that only produce values don’t need shared state. A channel turns “everyone locks the accumulator” into “everyone mails in their answer.”

This morning’s bite 280 collected thread results through a Mutex, then peeled it off with into_inner. That works — but every worker contends for the same lock, and the value only exists to be fought over:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
use std::sync::Mutex;
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>()
        });
    }
});
let sum = total.into_inner().unwrap();

With std::sync::mpsc, each worker gets its own Sender and the results flow one way:

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

let (tx, rx) = mpsc::channel();

thread::scope(|s| {
    for chunk in data.chunks(2) {
        let tx = tx.clone();
        s.spawn(move || {
            tx.send(chunk.iter().sum::<i32>()).unwrap()
        });
    }
    drop(tx); // keep this — or the next line waits forever

    let sum: i32 = rx.iter().sum();
    assert_eq!(sum, 21);
});

No lock, no poisoning, no unwrap on contention that can’t happen. Receiver implements Iterator, so collecting results is just rx.iter() — it yields each value as it arrives and ends when every Sender is gone.

That last part is the classic trap: the original tx counts too. Each worker got a clone, but the one you cloned from is still alive in the parent — if you don’t drop(tx), the channel never closes and rx.iter() blocks forever after the last worker exits. Symptom: all threads finished, program hangs.

mpsc is multi-producer, single-consumer: clone Sender freely, but there’s exactly one Receiver — which is why receiving needs no lock at all. Sends are non-blocking and buffered; if you want backpressure, mpsc::sync_channel(n) blocks senders once n messages are queued.

Mutexes are for threads that share data. When they only hand data back, give them a channel.

In std since Rust 1.0.

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