Skip to content

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

Medium Framework

The drop(tx) hang: a bug with no diagnostics

Square every value across workers threads and collect the results, sorted.

pub fn drain_all(values: Vec<i64>, workers: usize) -> Vec<i64>

You must collect with for x in rx — the iterator form, not a counted recv() loop. Treat workers == 0 as 1. Empty input gives [].

That single requirement is the entire problem. It forces you to get channel shutdown right, and channel shutdown in Rust is expressed through ownership.

The starter compiles clean, passes clippy clean, and hangs forever

Read that again, because it is the reason this item exists.

$ cargo clippy -- -D warnings
    Finished in 0.31s
$ cargo run
    (nothing. ever.)

No error. No warning. No output. Not even on the empty input, which is the case you would swear could not possibly break. This is the most common std-channel bug there is, and the toolchain has nothing whatsoever to say about it.

Why

for x in rx desugars to repeated rx.recv(), stopping when recv() returns Err(RecvError). And recv() returns Err under exactly one condition: every Sender has been dropped.

Count the senders in the starter:

  • one clone moved into each worker — those drop when the workers finish;
  • the original tx, still owned by the parent thread, alive for the whole function.

So the channel is never disconnected. The loop drains every message and then blocks on the next recv() forever, waiting for a sender that will never send — and it is the very thread that is blocked which owns it.

The empty-input case is the cruellest and the most instructive. There are no workers at all, so there are no clones, and the parent’s tx is the only sender. Zero messages, one live sender, infinite wait. The input a reviewer would skip as trivial is the one that hangs first.

Three fixes, all about ownership

drop(tx);                       // explicit, and says exactly what it means

after the spawn loop. Or move the original into the last worker instead of cloning for it. Or create the senders inside a block so the original goes out of scope before you start draining.

The explicit drop is the clearest, and it deserves a comment: drop(tx) on its own looks like a no-op to anyone who has not been bitten. Write down that the receiver’s termination depends on it.

The design point underneath

A channel closes when nobody can write to it any more, and Rust knows that because it knows who owns what. There is no close() method, and the absence is deliberate: an explicit close() invites the questions “what if I send after closing?” and “what if two threads close?”, neither of which can arise here.

The cost is that “who still holds a Sender?” becomes a design question you have to be able to answer. In a worker pool, senders are held by workers and the pool must not keep one. In a pipeline, each stage drops its outbound sender when its inbound receiver ends, and that is what makes shutdown cascade. Draw the ownership, and shutdown falls out.

::: question You add a let keep_alive = tx.clone(); for debugging and forget to remove it. What happens? The program hangs, and every test that passed yesterday now times out with no explanation.

It is worth internalising how fragile this is: one extra live Sender anywhere — in a struct field, in a closure that has not been dropped, in a Vec you kept “just in case” — is enough. And because the failure is a hang rather than a panic, there is no stack trace, no message, and nothing to grep for.

If a channel-based program hangs, the first question is always: who still owns a Sender? :::

If the grader times out

It is not slow. It is blocked in recv(). Go and count the senders.

Loading visualization…