Channels

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