#291 Jul 2026

291. catch_unwind — One Panicking Job Shouldn't Kill the Whole Worker

Job 2 panics and your worker thread dies with it — jobs 3 through 400 never run. std::panic::catch_unwind stops the panic at a boundary you choose.

A panic unwinds until something catches it or the thread dies. In a job loop, “the thread dies” means every queued job after the bad one is silently dropped:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn run_job(id: usize) -> usize {
    if id == 2 {
        panic!("job {id} choked");
    }
    id * 10
}

for id in 0..4 {
    run_job(id); // job 2 kills the loop
}

catch_unwind runs a closure and converts any panic inside it into an Err, so the loop survives:

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

for id in 0..4 {
    match panic::catch_unwind(|| run_job(id)) {
        Ok(v) => println!("job {id} -> {v}"),
        Err(payload) => {
            let msg = payload
                .downcast_ref::<String>()
                .map(|s| s.as_str())
                .unwrap_or("unknown panic");
            eprintln!("job {id} failed: {msg}");
        }
    }
}
// job 0 -> 0
// job 1 -> 10
// job 2 failed: job 2 choked
// job 3 -> 30

The Err carries the panic payload as Box<dyn Any + Send>. A panic! with a format string stores a String; a bare string literal stores a &'static str — downcast to whichever you expect (or try both) to recover the message.

Three things to know before you reach for it. It’s a boundary tool — thread pools, FFI edges, plugin callbacks — not try/catch for control flow; fallible code should return Result. It can’t catch anything if the build uses panic = "abort". And it doesn’t undo side effects: a panic while holding a lock still poisons itcatch_unwind decides where unwinding stops, and this morning’s clear_poison handles what it left behind.

If you only wanted to observe the panic and pass it on — log and rethrow — use panic::resume_unwind(payload): it continues unwinding with the original payload and skips printing a second panic message.

#290 Jul 2026

290. Mutex::clear_poison — One Panicked Thread Shouldn't Poison the Lock Forever

A worker panics while holding the lock, and from then on every lock() in your program returns Err. If the data is still consistent, you can undo the poisoning instead of apologizing at every call site.

Poisoning is a Mutex feature, not a bug: a panic mid-update might leave the data half-written, so the lock starts refusing service to make you look. But plenty of panics can’t corrupt anything — the classic case is a worker that panicked after its update, or one that never got to write at all:

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

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

thread::scope(|s| {
    let _ = s.spawn(|| {
        let _guard = m.lock().unwrap();
        panic!("worker died");   // guard dropped mid-panic
    }).join();
});

assert!(m.is_poisoned());
assert!(m.lock().is_err());      // ...and now everyone pays

The workaround you usually see is shrugging at every single call site:

1
let data = m.lock().unwrap_or_else(|e| e.into_inner());

That works, but it’s a per-call-site decision repeated forever — and it silently accepts future poisonings too, including the ones that do mean corruption.

Mutex::clear_poison (stable since 1.77) is the one-time version: inspect the data, decide it’s fine, flip the flag back:

1
2
3
4
5
6
7
8
9
{
    // recover once, look at the data, decide
    let data = m.lock().unwrap_or_else(|e| e.into_inner());
    assert_eq!(*data, vec![1, 2, 3]);  // still consistent
}
m.clear_poison();

assert!(!m.is_poisoned());
assert_eq!(m.lock().unwrap().len(), 3);  // Ok again, everywhere

After clear_poison, every lock() in the program returns Ok again — the recovery decision is made once, by the code that actually checked the invariants, not smeared across every caller as a reflexive unwrap_or_else.

The same method exists on RwLock. And if your critical sections genuinely can’t be interrupted mid-invariant — you only ever replace the value wholesale, say — the per-site into_inner() shrug is defensible. But the moment “is the data OK?” has a real answer, clear_poison puts that answer in exactly one place.

#289 Jul 2026

289. JoinHandle::is_finished — Check on a Worker Without Blocking on join()

join() tells you when a thread is done — by blocking until it is. Sometimes you just want to peek: still running, or can I collect the result now?

The DIY version is an Arc<AtomicBool> that the worker sets as its last statement. It has a hole: if the thread panics before that store, the flag stays false forever and your supervisor waits on a corpse.

JoinHandle::is_finished (stable since 1.61) asks the handle directly. It returns immediately, and it reports termination — a worker that panicked counts as finished, because it is:

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

let workers: Vec<_> = (0..4)
    .map(|id| thread::spawn(move || work(id)))
    .collect();

// progress report while they run — no blocking
while workers.iter().any(|w| !w.is_finished()) {
    let done = workers
        .iter()
        .filter(|w| w.is_finished())
        .count();
    println!("{done}/4 workers finished");
    thread::sleep(Duration::from_millis(100));
}

is_finished() doesn’t consume the handle, so you can poll in a loop and still join() afterwards. And you should: is_finished() == true is exactly the guarantee that join() won’t block, and join() is where the result — or the panic — comes out:

1
2
3
4
5
6
for w in workers {
    match w.join() {
        Ok(n) => println!("produced {n}"),
        Err(_) => eprintln!("a worker panicked"),
    }
}

That second half matters. is_finished tells you that a thread ended, never how — a clean return and a panic look identical until you join(). Name your threads with thread::Builder and the panic message at least tells you who died.

One boundary to respect: this is a check-in tool, not a synchronization primitive. A hot while !w.is_finished() {} loop is the same spin-wait sin as the atomic-counter hack from this morning’s Barrier bite. If you need to wait for completion, join() already does that; if you need results as they arrive, use a channel. Reach for is_finished when the answer “not yet” is useful — progress bars, health checks, deciding whether to steal a straggler’s remaining work.

#288 Jul 2026

288. std::sync::Barrier — Make All Threads Start Phase 2 Together

Your workers finish phase 1 at different speeds, and the fast ones charge into phase 2 while the slow ones are still writing. The hand-rolled fix is an atomic counter and a spin loop — std already ships the real thing.

The hack everyone writes first: bump an AtomicUsize when done, then spin until it hits n. It burns CPU while waiting and resetting it for the next round is a race of its own.

std::sync::Barrier (stable since 1.0) is that counter done right. Barrier::new(n) sets the threshold; every thread that calls .wait() blocks until the n-th one arrives, then all of them are released at once:

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

let n = 4;
let barrier = Barrier::new(n);

thread::scope(|s| {
    for id in 0..n {
        let barrier = &barrier;
        s.spawn(move || {
            prepare(id);    // phase 1, each at its own pace
            barrier.wait(); // block until all 4 arrive
            process(id);    // phase 2 starts in lockstep
        });
    }
});

With scoped threads a shadowed &barrier is all the sharing you need — no Arc. (The move is for id; the reference moves with it.) Under plain thread::spawn, wrap the barrier in Arc as usual.

Two details the DIY counter doesn’t give you. First, .wait() returns a BarrierWaitResult, and exactly one thread per round gets is_leader() == true — a built-in election for “someone swap the buffers before the next phase”:

1
2
3
if barrier.wait().is_leader() {
    // exactly one thread runs this per round
}

Second, the barrier resets itself after releasing n threads. Call .wait() in a loop and you get phase-synchronized rounds for free — the classic shape of iterative simulations: compute a step, wait, leader swaps front/back buffers, wait, repeat.

One trap: the count is fixed at construction. If a thread panics before reaching .wait(), the rest block forever — there’s no timeout variant. Keep the work between barriers panic-free, or use channels when workers can drop out.

#287 Jul 2026

287. thread::Builder — Name Your Workers So Panics Tell You Who Died

thread '<unnamed>' panicked — great, which of the eight workers was that? thread::spawn has a configurable sibling, and the two minutes it costs pay off the first time something crashes.

Bite 286 computed how many workers to spawn. Spawn them with thread::spawn and every panic message, debugger view, and profiler trace calls them <unnamed>:

1
2
3
thread::spawn(|| {
    // panics as: thread '<unnamed>' panicked
});

std::thread::Builder (stable since 1.0) is the same spawn with configuration in front:

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

let h = thread::Builder::new()
    .name("worker-3".into())
    .spawn(|| {
        // panics as: thread 'worker-3' panicked
        thread::current().name().map(str::to_owned)
    })
    .expect("spawn failed");

Now a crash names the culprit, and the name shows up in gdb/lldb thread lists and profilers too. The code inside can read it back via thread::current().name().

There’s a second difference hiding in that .expect: Builder::spawn returns io::Result<JoinHandle>. Plain thread::spawn panics if the OS can’t create the thread — under fd/memory pressure, exactly when you least want it. With the builder you decide: fall back to fewer workers instead of crashing.

One more knob while you’re there — .stack_size(bytes) for that one worker doing deep recursion, instead of raising RUST_MIN_STACK for the whole program:

1
2
3
4
5
let h = thread::Builder::new()
    .name("deep-parser".into())
    .stack_size(8 * 1024 * 1024)
    .spawn(|| { /* recurse away */ })
    .expect("spawn failed");

Loop it with format!("worker-{i}") and the whole pool from bite 286 is debuggable by name.

#286 Jul 2026

286. available_parallelism — Stop Hardcoding Your Worker Count

That let workers = 8; in your thread pool is wrong on almost every machine but yours. std can tell you how many threads the program can actually run at once — no crate needed.

The channel series (bites 281285) kept spawning workers without ever asking the obvious question: how many? Hardcoding a number over-subscribes small machines and wastes big ones. std::thread::available_parallelism (stable since 1.59) gives the real answer:

1
2
3
4
5
6
7
use std::thread;

let workers = thread::available_parallelism()
    .map(|n| n.get())
    .unwrap_or(1);

assert!(workers >= 1);

It returns io::Result<NonZeroUsize> — two deliberate choices in one signature. The Result is because some platforms can’t answer; .unwrap_or(1) degrades gracefully. The NonZeroUsize is a guarantee: on success the count is never zero, so dividing work by it can’t panic:

1
2
3
4
5
6
let data: Vec<u32> = (0..100).collect();

// never a divide-by-zero, workers >= 1
let chunk = data.len().div_ceil(workers);
let batches = data.chunks(chunk).count();
assert!(batches <= workers);

Why not just count cores?

Because the answer isn’t “how many cores does the CPU have” — it’s “how many can this process use,” which can be far smaller:

  • cgroup CPU quotas (Linux): a container capped at 2 CPUs on a 64-core host reports 2, not 64
  • process affinity masks: pinned to 4 cores, you get 4
  • SMT: hyperthreads count, so an 8-core/16-thread CPU typically reports 16

A naive core count in a Kubernetes pod spawns 64 threads to fight over 2 CPUs. available_parallelism reads the quota and sizes the pool right.

Two caveats: the value is a snapshot (quotas can change mid-run), and it’s a hint for CPU-bound work — an I/O-bound pool may justifiably want more threads than cores.

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