Threads

#293 Jul 2026

293. thread::panicking — A Second Panic in Drop Doesn't Unwind, It Aborts

Your Drop impl asserts a cleanup invariant. A test fails, unwinding starts, your destructor runs — and its assert fails too. Two panics at once: the process aborts and the original error message is gone.

Rust can’t unwind twice at the same time. If a destructor panics while the thread is already unwinding from an earlier panic, the runtime gives up and calls abort() — no unwinding, no catch_unwind rescue (bite 291), just a dead process that hides the failure you actually cared about.

The problem:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct Guard {
    committed: bool,
}

impl Drop for Guard {
    fn drop(&mut self) {
        // If a panic is already unwinding past this
        // guard, this second panic aborts the process.
        assert!(self.committed, "guard dropped uncommitted");
    }
}

Any panic between creating the guard and committing it now aborts instead of unwinding — your test harness never even gets to print the real failure.

std::thread::panicking() tells you whether the current thread is mid-unwind, so the destructor can stand down:

1
2
3
4
5
6
7
8
impl Drop for Guard {
    fn drop(&mut self) {
        if std::thread::panicking() {
            return; // already unwinding — don't make it worse
        }
        assert!(self.committed, "guard dropped uncommitted");
    }
}

The original panic unwinds cleanly, catch_unwind and your panic hook (bite 292) see it as usual, and the guard still enforces its invariant on every normal exit path.

Rule of thumb: any Drop that can panic — asserts, unwrap, flushing a writer — should either swallow the error or check thread::panicking() first.

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

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

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.