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

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