Skip to content

← Fearless Concurrency: Threads, Channels, Shared State step 16 of 24

Easy Primitives

mpsc channels: message passing basics

One thread per value. Each doubles its value and sends it down a channel. The main thread collects exactly values.len() results, sorts them, and returns.

pub fn fan_in(values: Vec<i64>) -> Vec<i64>

Use a counted recv() loopfor _ in 0..n { out.push(rx.recv()?) }. Not for x in rx. That version is the next problem, and it hangs here; the hang is worth meeting on its own terms rather than as an accident.

Why channels, when you already have Mutex

“Do not communicate by sharing memory; share memory by communicating.”

A channel moves ownership. You send(value) and the value is gone from your thread — not copied, not shared, gone — and it arrives owned by the receiver. There is no moment when two threads can both reach it, so there is no lock discipline to maintain, no critical section to keep short, no lock order to get wrong.

That matters more than it first appears. The problem with Arc<Mutex<T>> is not correctness in the small; it is that the invariant “hold this lock before touching that data” lives in every function that touches it, forever, in everyone’s head. Channels replace that with the ownership rules you already have. Channels scale better cognitively than shared state, and for a great many workloads they are the right first answer.

The starter’s error

error[E0382]: use of moved value: `tx`
   = note: move occurs because `tx` has type `Sender<i64>`, which does not
           implement the `Copy` trait

mpsc stands for multi-producer, single-consumer. The multi-producer half is delivered by Sender: Clone — clone one per thread, and they all feed the same queue. Receiver is deliberately not Clone; there is exactly one consumer, which is what makes “who receives this message” unambiguous.

let tx = tx.clone();
thread::spawn(move || { tx.send(v * 2).unwrap(); });

Sorting, and why the count is exact

Send order is the scheduler’s. Sorting collapses the possible interleavings onto a single expected value — the same determinism trick as earlier in this track.

The count is the other half. recv() blocks until a message arrives, so a loop that runs exactly n times terminates exactly when all n messages have been received, whatever order they came in. You are not polling and you are not sleeping: the thread is parked by the OS until there is something to take.

The Sender and Receiver API, briefly

  • send(t) returns Result<(), SendError<T>>, and SendError gives you the value back. If the receiver is gone, nobody can ever take this message, so std hands it to you rather than dropping it silently. That is a small design decision worth admiring.
  • recv() returns Err(RecvError) when every Sender has been dropped. That is the only way it ends — see the next problem.
  • try_recv() never blocks and distinguishes Empty (nothing yet, senders still alive) from Disconnected (nothing ever again).
  • recv_timeout(d) adds a Timeout variant.
  • Sender is Send + Sync and Clone; Receiver is Send but not Sync and not Clone.

::: question The main thread also still owns the original tx it cloned from. Does that break the counted loop? No — and that is precisely why this problem uses a counted loop.

A counted loop stops after n messages regardless of who still owns a Sender. It never asks “is the channel finished?”, so an extra live Sender is harmless.

A for x in rx loop asks exactly that question, and it only ends when every Sender has dropped — including the one the parent is still holding. With the parent’s tx alive, that loop waits forever for a message that will never come. Same program, same channel, one hangs and one does not.

That is the whole of the next problem, and the reason it is separate: the hang produces no error, no warning, and no clue. :::

Loading visualization…