Concurrency · about 40 min

Channels: Passing Messages Between Threads

Why this matters

Threads that never communicate are of limited use. One of the cleanest ways for threads to cooperate is message passing: instead of sharing memory, threads send each other values through a channel, like a pipe. A popular slogan (from Go) puts it: "do not communicate by sharing memory; share memory by communicating". In Rust, sending a value moves its ownership to the receiver, so the type system guarantees that no two threads use the same data at once.

A channel

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();
    thread::spawn(move || {
        for i in 1..=3 {
            tx.send(format!("message {i}")).unwrap();
        }
    });
    for received in rx {
        println!("got {received}");
    }
    println!("channel closed");
}
got message 1
got message 2
got message 3
channel closed

When the spawned thread finishes, its tx is dropped, the channel closes, and the for loop in main ends. Closing by dropping senders is the normal way to say "no more messages".

Sending moves ownership

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();
    thread::spawn(move || {
        let report = String::from("all good");
        tx.send(report).unwrap();
        println!("sent {report}");
    });
    println!("{}", rx.recv().unwrap());
}
error[E0382]: borrow of moved value: `report`
 --> main.rs:9:24
  |
7 |         let report = String::from("all good");
  |             ------ move occurs because `report` has type `String`, which does not implement the `Copy` trait
8 |         tx.send(report).unwrap();
  |                 ------ value moved here
9 |         println!("sent {report}");
  |                        ^^^^^^^^ value borrowed here after move
  |

After send, the String belongs to the channel and then to the receiver. The sending thread cannot read or modify it any more, so the receiver can be certain nobody else is touching it. No locks are needed for the data itself.

Multiple producers

Clone the sender to give each thread its own:

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();
    for id in 0..3 {
        let tx = tx.clone();
        thread::spawn(move || {
            let result = (id, (1..=10 * (id + 1)).sum::<u32>());
            tx.send(result).unwrap();
        });
    }
    drop(tx);
    let mut results: Vec<(u32, u32)> = rx.iter().collect();
    results.sort();
    for (id, sum) in results {
        println!("worker {id}: {sum}");
    }
}
worker 0: 55
worker 1: 210
worker 2: 465

Two details matter:

Pipelines

Channels connect stages, each running in its own thread, like a shell pipeline (cat | grep | sort):

 main --lines--> [parse thread] --numbers--> [sum thread] --total--> main

Each stage owns its receiver, processes values, and sends results onward; when its input closes, it finishes and drops its output sender, closing the next channel in turn. Shutdown propagates down the pipeline automatically. The second exercise builds exactly this.

Bounded channels and backpressure

mpsc::sync_channel(n) creates a channel that holds at most n messages; send blocks when it is full. This keeps a fast producer from piling up unlimited data in memory while a slow consumer catches up ("backpressure"). sync_channel(0) is a rendezvous: each send waits until the receiver takes the value.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::sync_channel(2);
    let producer = thread::spawn(move || {
        for i in 0..5 {
            tx.send(i).unwrap();
        }
    });
    let got: Vec<i32> = rx.iter().collect();
    producer.join().unwrap();
    println!("{:?}", got);
}
[0, 1, 2, 3, 4]

Common mistakes

Recap

Going further: read the Rust Book chapter 16.2. Try rx.recv_timeout(Duration::from_millis(100)) and try_recv(), the non-blocking variants.

Exercises

Exercise 1 · write code

Workers report through a channel

Read words from stdin (one per line). Spawn one thread per word (there are only a few); each thread sends a tuple (index, word.len(), uppercased word) through a channel. In main, drop the original sender, collect all messages by iterating over the receiver, sort them by index, and print:

0 apple 5 APPLE
1 fig 3 FIG
2 kiwi 4 KIWI

(the original word, its length, and the uppercased word).

File: main.rs

Starting point

use std::io;
use std::sync::mpsc;
use std::thread;

fn main() {
    let words: Vec<String> = io::stdin().lines().map(|l| l.unwrap().trim().to_string()).collect();
    let (tx, rx) = mpsc::channel();
}

Example

input

apple
fig
kiwi

expected output

0 apple 5 APPLE
1 fig 3 FIG
2 kiwi 4 KIWI
Hint 1

Each thread needs its own sender: let tx = tx.clone(); before thread::spawn(move || ...).

Hint 2

drop(tx) in main closes the original sender; rx.iter() then ends when every thread's sender has been dropped.

Hint 3

Messages arrive in any order: collect into a Vec and sort.

Reference solution
use std::io;
use std::sync::mpsc;
use std::thread;

fn main() {
    let words: Vec<String> = io::stdin().lines().map(|l| l.unwrap().trim().to_string()).collect();
    let (tx, rx) = mpsc::channel();
    for (i, w) in words.iter().enumerate() {
        let tx = tx.clone();
        let w = w.clone();
        thread::spawn(move || {
            tx.send((i, w.len(), w.to_uppercase())).unwrap();
        });
    }
    drop(tx);
    let mut results: Vec<(usize, usize, String)> = rx.iter().collect();
    results.sort();
    for (i, len, up) in results {
        println!("{i} {} {len} {up}", words[i]);
    }
}

Exercise 2 · write code

A two-stage pipeline

Build a pipeline of two threads connected by channels: stage 1 receives lines (Strings) from main, parses each as an i64 and sends only the valid numbers onward; stage 2 receives numbers and keeps a running total, sending the final total back to main when its input channel closes. main sends every stdin line into stage 1, drops its sender, and prints the total received from stage 2:

total: 42

for the lines 40, x, 2, an empty line. (Order of processing does not matter for a sum.)

File: main.rs

Starting point

use std::io;
use std::sync::mpsc;
use std::thread;

fn main() {
    let (line_tx, line_rx) = mpsc::channel::<String>();
    let (num_tx, num_rx) = mpsc::channel::<i64>();
    let (total_tx, total_rx) = mpsc::channel::<i64>();

    for line in io::stdin().lines() {
        line_tx.send(line.unwrap()).unwrap();
    }
    drop(line_tx);
    println!("total: {}", total_rx.recv().unwrap());
}

Example

input

40
x
2

expected output

total: 42

Example

expected output

total: 0
Hint 1

Spawn the stage threads before the sending loop. Stage 1 moves in line_rx and num_tx; stage 2 moves in num_rx and total_tx.

Hint 2

for line in line_rx { ... } ends when all senders are dropped; when stage 1 ends it drops num_tx, which ends stage 2's loop.

Reference solution
use std::io;
use std::sync::mpsc;
use std::thread;

fn main() {
    let (line_tx, line_rx) = mpsc::channel::<String>();
    let (num_tx, num_rx) = mpsc::channel::<i64>();
    let (total_tx, total_rx) = mpsc::channel::<i64>();

    thread::spawn(move || {
        for line in line_rx {
            if let Ok(n) = line.trim().parse::<i64>() {
                num_tx.send(n).unwrap();
            }
        }
    });
    thread::spawn(move || {
        let mut total = 0;
        for n in num_rx {
            total += n;
        }
        total_tx.send(total).unwrap();
    });

    for line in io::stdin().lines() {
        line_tx.send(line.unwrap()).unwrap();
    }
    drop(line_tx);
    println!("total: {}", total_rx.recv().unwrap());
}

Exercise 3 · quiz

What happens to a sent value?

A thread runs let msg = String::from("hi"); tx.send(msg).unwrap(); println!("{msg}");. What happens?

Answer
  • Compile error E0382: send takes ownership of msg, so it cannot be used afterwards

send moves the value into the channel: ownership passes to whichever thread receives it. That is why message passing is safe: after sending, the sender cannot touch the data, so two threads never access it at the same time.