Guide contents

Lesson 66 of 94

Channels: Message Passing

The previous lesson shared memory between threads and protected it with locks. The alternative is to not share at all: each piece of data is owned by one thread at a time, and threads communicate by sending messages. Go summarized it as "don't communicate by sharing memory; share memory by communicating", and it fits Rust's ownership model perfectly: sending a value moves it to the receiving thread.

Rust's standard channel is std::sync::mpsc: multi-producer, single-consumer. You get a Sender<T> (which you can clone) and a Receiver<T> (which you can't).

Need Rust
Unbounded queue mpsc::channel()
Bounded queue of size n mpsc::sync_channel(n)
Rendezvous (hand-off) mpsc::sync_channel(0)
Put / take a message tx.send(x) / rx.recv()
Poll without blocking / with a timeout rx.try_recv() / rx.recv_timeout(d)
Tell consumers to stop drop every Sender; the receiver's loop ends
Thread pool a pool of threads reading jobs from a channel
Get a reply to a request send a reply Sender along with the request

The external crate crossbeam-channel adds multi-consumer channels and select!; the async world has its own channels (Async Channels & Locks). The ideas are the same everywhere.

1. A first channel

mpsc::channel() returns a (Sender, Receiver) pair. send puts a value into the channel; recv blocks until a value arrives.

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

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        tx.send(String::from("hello from the worker")).unwrap();
    });

    let msg = rx.recv().unwrap(); // blocks until the message arrives
    println!("main got: {msg}");
}
Output
main got: hello from the worker

The channel's element type is inferred from what you send: here Sender<String> and Receiver<String>.

2. Receiving until the channel closes

A Receiver is an iterator. for msg in rx receives until every Sender has been dropped, then the loop ends. No poison pill needed: the channel closing is the "done" signal.

Rust
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        for step in ["download", "unzip", "index"] {
            tx.send(step).unwrap();
            thread::sleep(Duration::from_millis(10));
        }
        // `tx` is dropped here, which closes the channel.
    });

    for step in rx {
        println!("finished: {step}");
    }
    println!("channel closed, worker is done");
}
Output
finished: download
finished: unzip
finished: index
channel closed, worker is done

3. Sending moves the value

send takes ownership of the value. After sending, the sending thread can't use it, so two threads can never touch the same data at once.

Rust✗ does not compile · E0382
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();
    thread::spawn(move || {
        let report = vec![1, 2, 3];
        tx.send(report).unwrap();
        println!("sent {} rows", report.len());
    });
    println!("{:?}", rx.recv().unwrap());
}
Output
error[E0382]: borrow of moved value: `report`
 --> src/main.rs:9:34
  |
7 |         let report = vec![1, 2, 3];
  |             ------ move occurs because `report` has type `Vec<i32>`, which does not implement the `Copy` trait
8 |         tx.send(report).unwrap();
  |                 ------ value moved here
9 |         println!("sent {} rows", report.len());
  |                                  ^^^^^^ value borrowed here after move

Sending a value and then modifying it would be a classic race, but in Rust it doesn't compile. Read what you need before sending, or send a clone.

4. Multiple producers

Clone the Sender to give each producer its own. Messages from different producers interleave in whatever order they're sent, so this example sorts before printing.

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

fn main() {
    let (tx, rx) = mpsc::channel();

    for sensor in ["kitchen", "garage", "attic"] {
        let tx = tx.clone();
        thread::spawn(move || {
            for reading in 0..2 {
                tx.send(format!("{sensor}: reading {reading}")).unwrap();
            }
        });
    }
    drop(tx); // drop the original, or the loop below never ends

    let mut all: Vec<String> = rx.iter().collect();
    all.sort();
    for line in &all {
        println!("{line}");
    }
}
Output
attic: reading 0
attic: reading 1
garage: reading 0
garage: reading 1
kitchen: reading 0
kitchen: reading 1

Messages from a single producer always arrive in the order they were sent. Only the interleaving between producers varies.

5. The forgotten Sender

If any Sender is still alive, the receiver can't know that no more messages are coming, so it waits forever. The most common cause is the original tx in main, kept alive after cloning:

Rust
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 || tx.send(id).unwrap());
    }
    // Missing: drop(tx);
    for id in rx {
        println!("got {id}");
    }
    println!("never printed: main's own `tx` keeps the channel open");
}

The fix is the drop(tx) from the previous example, or moving the original tx into the last thread instead of cloning it.

6. send fails when the receiver is gone

If the Receiver has been dropped, send returns Err(SendError(value)), giving you the value back. Producers use this to notice that nobody is listening anymore and stop.

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

fn main() {
    let (tx, rx) = mpsc::channel::<u32>();

    let producer = thread::spawn(move || {
        let mut sent = 0;
        for n in 1.. {
            if tx.send(n).is_err() {
                return sent; // consumer went away: stop producing
            }
            sent += 1;
        }
        sent
    });

    let first_three: Vec<u32> = rx.iter().take(3).collect();
    drop(rx); // hang up

    let sent = producer.join().unwrap();
    println!("consumer took {first_three:?}");
    println!("producer stopped after sending at least 3: {}", sent >= 3);
}
Output
consumer took [1, 2, 3]
producer stopped after sending at least 3: true

7. recv fails when all senders are gone

recv() returns Err(RecvError) once the channel is empty and closed. That's what ends the for loop; you can also handle it directly.

Rust
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
    tx.send("last message").unwrap();
    drop(tx);

    println!("{:?}", rx.recv()); // buffered messages are still delivered
    println!("{:?}", rx.recv()); // then: closed
}
Output
Ok("last message")
Err(RecvError)

8. try_recv: check without blocking

try_recv() returns immediately: Ok(value), Err(TryRecvError::Empty) if nothing is waiting, or Err(TryRecvError::Disconnected) if the channel is closed. It's the equivalent of queue.poll().

Rust
use std::sync::mpsc::{self, TryRecvError};

fn main() {
    let (tx, rx) = mpsc::channel();

    println!("{:?}", rx.try_recv());
    tx.send(42).unwrap();
    println!("{:?}", rx.try_recv());
    drop(tx);
    match rx.try_recv() {
        Ok(v) => println!("value {v}"),
        Err(TryRecvError::Empty) => println!("nothing yet"),
        Err(TryRecvError::Disconnected) => println!("channel closed"),
    }
}
Output
Err(Empty)
Ok(42)
channel closed

try_iter() drains everything currently waiting without blocking, which is handy in a loop that also does other work (like a game loop or UI thread).

9. recv_timeout: wait, but not forever

recv_timeout(duration) waits up to a limit and returns Err(RecvTimeoutError::Timeout) if nothing arrives. A typical use is a heartbeat monitor.

Rust
use std::sync::mpsc::{self, RecvTimeoutError};
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        for beat in 1..=3 {
            thread::sleep(Duration::from_millis(20));
            tx.send(beat).unwrap();
        }
        thread::sleep(Duration::from_millis(300)); // the service hangs...
        drop(tx);
    });

    loop {
        match rx.recv_timeout(Duration::from_millis(150)) {
            Ok(beat) => println!("heartbeat {beat}"),
            Err(RecvTimeoutError::Timeout) => {
                println!("no heartbeat for 150 ms: service looks dead");
                break;
            }
            Err(RecvTimeoutError::Disconnected) => {
                println!("service shut down");
                break;
            }
        }
    }
}
Output
heartbeat 1
heartbeat 2
heartbeat 3
no heartbeat for 150 ms: service looks dead

10. Messages as an enum

Real channels carry more than one kind of message. An enum makes the protocol explicit, and match makes sure the receiver handles every kind.

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

enum Event {
    Login { user: String },
    Purchase { user: String, cents: u64 },
    Logout { user: String },
}

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        tx.send(Event::Login { user: String::from("ana") }).unwrap();
        tx.send(Event::Purchase { user: String::from("ana"), cents: 2599 }).unwrap();
        tx.send(Event::Purchase { user: String::from("ana"), cents: 1000 }).unwrap();
        tx.send(Event::Logout { user: String::from("ana") }).unwrap();
    });

    let mut revenue = 0;
    for event in rx {
        match event {
            Event::Login { user } => println!("{user} logged in"),
            Event::Purchase { user, cents } => {
                revenue += cents;
                println!("{user} spent {cents}");
            }
            Event::Logout { user } => println!("{user} logged out"),
        }
    }
    println!("revenue: {revenue}");
}
Output
ana logged in
ana spent 2599
ana spent 1000
ana logged out
revenue: 3599

11. Bounded channels and backpressure

mpsc::channel() is unbounded: a fast producer can fill memory. mpsc::sync_channel(n) holds at most n messages; send blocks when it's full, slowing the producer down to the consumer's pace. That's backpressure, like ArrayBlockingQueue.put.

Rust
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};

fn main() {
    let (tx, rx) = mpsc::sync_channel(2); // room for 2 messages
    let start = Instant::now();

    let producer = thread::spawn(move || {
        for i in 1..=6 {
            tx.send(i).unwrap(); // blocks while the channel is full
        }
        start.elapsed()
    });

    let mut got = Vec::new();
    for item in rx {
        thread::sleep(Duration::from_millis(20)); // slow consumer
        got.push(item);
    }

    let producer_time = producer.join().unwrap();
    println!("received {got:?}");
    println!("producer was slowed down to the consumer's pace: {}", producer_time >= Duration::from_millis(40));
}
Output
received [1, 2, 3, 4, 5, 6]
producer was slowed down to the consumer's pace: true

12. sync_channel(0): a rendezvous

With capacity zero, send blocks until the receiver actually takes the value. The two threads meet at that point. It's a way to hand off work and know that it was picked up.

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

fn main() {
    let (tx, rx) = mpsc::sync_channel::<&str>(0);
    let (log_tx, log_rx) = mpsc::channel();

    let producer = thread::spawn(move || {
        for task in ["task-a", "task-b"] {
            tx.send(task).unwrap(); // returns only once the consumer has received it
            log_tx.send(format!("handed off {task}")).unwrap();
        }
    });

    for task in rx {
        println!("consumer took {task}");
        // The producer's log line for this task can only exist after this point.
    }
    producer.join().unwrap();
    println!("{:?}", log_rx.iter().collect::<Vec<_>>());
}
Output
consumer took task-a
consumer took task-b
["handed off task-a", "handed off task-b"]

13. Request and reply

To get an answer back, send a reply channel inside the request. The requester waits on its own receiver. This is how you build a CompletableFuture-style call on top of channels.

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

struct PriceRequest {
    sku: String,
    reply: mpsc::Sender<Option<u32>>,
}

fn main() {
    let (tx, rx) = mpsc::channel::<PriceRequest>();

    // A "pricing service" thread.
    thread::spawn(move || {
        for req in rx {
            let price = match req.sku.as_str() {
                "BOOK" => Some(1500),
                "PEN" => Some(200),
                _ => None,
            };
            let _ = req.reply.send(price); // the requester may have given up; ignore
        }
    });

    for sku in ["PEN", "LAPTOP", "BOOK"] {
        let (reply_tx, reply_rx) = mpsc::channel();
        tx.send(PriceRequest { sku: sku.to_string(), reply: reply_tx }).unwrap();
        println!("{sku}: {:?}", reply_rx.recv().unwrap());
    }
}
Output
PEN: Some(200)
LAPTOP: None
BOOK: Some(1500)

14. Keeping results in order

When workers finish in random order but you need results in input order, send the index along with the result and put each result in its slot.

Rust
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let urls = ["/a", "/bb", "/ccc", "/dddd"];
    let (tx, rx) = mpsc::channel();

    for (i, url) in urls.into_iter().enumerate() {
        let tx = tx.clone();
        thread::spawn(move || {
            // Later URLs finish first, to show the reordering.
            thread::sleep(Duration::from_millis(40 - 10 * i as u64));
            tx.send((i, format!("{url} -> {} bytes", url.len() * 100))).unwrap();
        });
    }
    drop(tx);

    let mut results = vec![String::new(); urls.len()];
    for (i, res) in rx {
        results[i] = res;
    }
    println!("{results:#?}");
}
Output
[
    "/a -> 200 bytes",
    "/bb -> 300 bytes",
    "/ccc -> 400 bytes",
    "/dddd -> 500 bytes",
]

15. Channels with scoped threads

Channels work with thread::scope too. Scoped workers can borrow input data and send owned results back.

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

fn main() {
    let words = vec!["tokio", "serde", "rayon", "clap", "regex", "anyhow"];
    let (tx, rx) = mpsc::channel();

    thread::scope(|s| {
        for chunk in words.chunks(2) {
            let tx = tx.clone();
            s.spawn(move || {
                for w in chunk {
                    tx.send((w.len(), w.to_uppercase())).unwrap();
                }
            });
        }
    });
    drop(tx);

    let mut out: Vec<(usize, String)> = rx.into_iter().collect();
    out.sort();
    println!("{out:?}");
}
Output
[(4, "CLAP"), (5, "RAYON"), (5, "REGEX"), (5, "SERDE"), (5, "TOKIO"), (6, "ANYHOW")]

Note that thread::scope returns only after all workers finish, so every message is already in the channel when we read it.

16. A pipeline of stages

Channels connect stages that run at the same time: while stage 2 processes item 1, stage 1 is already parsing item 2. Each stage owns its receiver and the sender for the next stage; when a stage finishes, dropping its sender closes the next channel, and shutdown ripples down the pipeline.

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

fn main() {
    let lines = vec!["3,apple", "x,broken", "5,pear", "12,melon"];

    let (parsed_tx, parsed_rx) = mpsc::channel();
    let (priced_tx, priced_rx) = mpsc::channel();

    // Stage 1: parse
    let parser = thread::spawn(move || {
        for line in lines {
            if let Some((qty, fruit)) = line.split_once(',') {
                if let Ok(qty) = qty.parse::<u32>() {
                    parsed_tx.send((qty, fruit.to_string())).unwrap();
                    continue;
                }
            }
            eprintln!("skipping bad line {line:?}");
        }
    });

    // Stage 2: price
    let pricer = thread::spawn(move || {
        for (qty, fruit) in parsed_rx {
            let unit = if fruit == "melon" { 300 } else { 50 };
            priced_tx.send(format!("{qty} x {fruit} = {}", qty * unit)).unwrap();
        }
    });

    // Stage 3 (main): print
    for line in priced_rx {
        println!("{line}");
    }
    parser.join().unwrap();
    pricer.join().unwrap();
}
Output
3 x apple = 150
5 x pear = 250
12 x melon = 3600

17. Several consumers: share the receiver

mpsc has a single consumer, but a group of workers can share one Receiver behind a Mutex. Each worker locks, takes one job, unlocks, and processes it. This is exactly how a fixed thread pool pulls from its work queue.

Rust
use std::sync::{Arc, Mutex, mpsc};
use std::thread;

fn main() {
    let (job_tx, job_rx) = mpsc::channel::<u32>();
    let job_rx = Arc::new(Mutex::new(job_rx));
    let (result_tx, result_rx) = mpsc::channel();

    for worker in 0..3 {
        let job_rx = Arc::clone(&job_rx);
        let result_tx = result_tx.clone();
        thread::spawn(move || loop {
            let job = job_rx.lock().unwrap().recv(); // lock held only while receiving
            match job {
                Ok(n) => result_tx.send((n, n * n, worker)).unwrap(),
                Err(_) => break, // channel closed: no more jobs
            }
        });
    }
    drop(result_tx);

    for n in 1..=8 {
        job_tx.send(n).unwrap();
    }
    drop(job_tx);

    let mut results: Vec<(u32, u32)> = result_rx.iter().map(|(n, sq, _worker)| (n, sq)).collect();
    results.sort();
    println!("{results:?}");
}
Output
[(1, 1), (2, 4), (3, 9), (4, 16), (5, 25), (6, 36), (7, 49), (8, 64)]

18. A thread pool with graceful shutdown

Putting it together: a small ThreadPool. Jobs are boxed closures (Box<dyn FnOnce() + Send>). When the pool is dropped, it closes the job channel and joins every worker, so all submitted jobs finish first.

Rust
use std::sync::{Arc, Mutex, mpsc};
use std::thread;

type Job = Box<dyn FnOnce() + Send + 'static>;

struct ThreadPool {
    sender: Option<mpsc::Sender<Job>>,
    workers: Vec<thread::JoinHandle<()>>,
}

impl ThreadPool {
    fn new(size: usize) -> ThreadPool {
        let (sender, receiver) = mpsc::channel::<Job>();
        let receiver = Arc::new(Mutex::new(receiver));
        let workers = (0..size)
            .map(|_| {
                let receiver = Arc::clone(&receiver);
                thread::spawn(move || loop {
                    let job = receiver.lock().unwrap().recv();
                    match job {
                        Ok(job) => job(),
                        Err(_) => break,
                    }
                })
            })
            .collect();
        ThreadPool { sender: Some(sender), workers }
    }

    fn execute(&self, f: impl FnOnce() + Send + 'static) {
        self.sender.as_ref().unwrap().send(Box::new(f)).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take()); // close the channel: workers exit after draining it
        for w in self.workers.drain(..) {
            w.join().unwrap();
        }
    }
}

fn main() {
    let (tx, rx) = mpsc::channel();
    {
        let pool = ThreadPool::new(4);
        for i in 1..=10u64 {
            let tx = tx.clone();
            pool.execute(move || tx.send((i, (1..=i).product::<u64>())).unwrap());
        }
    } // pool dropped here: waits for all 10 jobs
    drop(tx);

    let mut factorials: Vec<(u64, u64)> = rx.iter().collect();
    factorials.sort();
    println!("{:?}", &factorials[..5]);
    println!("10! = {}", factorials[9].1);
}
Output
[(1, 1), (2, 2), (3, 6), (4, 24), (5, 120)]
10! = 3628800

Drop shuts the pool down and waits for the workers, so it can't be forgotten.

19. Stopping a worker with a message

To stop a long-running worker, send it a message it checks between units of work. try_recv lets it check without blocking.

Rust
use std::sync::mpsc::{self, TryRecvError};
use std::thread;
use std::time::Duration;

fn main() {
    let (stop_tx, stop_rx) = mpsc::channel::<()>();

    let worker = thread::spawn(move || {
        let mut batches = 0;
        loop {
            match stop_rx.try_recv() {
                Ok(()) | Err(TryRecvError::Disconnected) => break,
                Err(TryRecvError::Empty) => {}
            }
            batches += 1; // process one batch
            thread::sleep(Duration::from_millis(5));
        }
        batches
    });

    thread::sleep(Duration::from_millis(50));
    stop_tx.send(()).unwrap();
    let batches = worker.join().unwrap();
    println!("worker stopped cleanly after some batches: {}", batches > 0);
}
Output
worker stopped cleanly after some batches: true

Treating Disconnected as "stop" too means that simply dropping stop_tx also shuts the worker down. An AtomicBool flag is the other common way (Atomics).

20. Merging several sources into one channel

std has no select! for waiting on several channels at once. The usual workaround is to send everything into one channel, wrapping each source's messages in an enum variant.

Rust
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

enum Input {
    Key(char),
    Tick(u32),
    NetworkMessage(String),
}

fn main() {
    let (tx, rx) = mpsc::channel();

    let keys = tx.clone();
    thread::spawn(move || {
        for c in ['h', 'i'] {
            keys.send(Input::Key(c)).unwrap();
        }
    });
    let ticks = tx.clone();
    thread::spawn(move || {
        for t in 1..=3 {
            ticks.send(Input::Tick(t)).unwrap();
            thread::sleep(Duration::from_millis(5));
        }
    });
    thread::spawn(move || tx.send(Input::NetworkMessage(String::from("new mail"))).unwrap());

    let (mut typed, mut ticks_seen, mut messages) = (String::new(), 0, Vec::new());
    for input in rx {
        match input {
            Input::Key(c) => typed.push(c),
            Input::Tick(_) => ticks_seen += 1,
            Input::NetworkMessage(m) => messages.push(m),
        }
    }
    println!("typed {typed:?}, {ticks_seen} ticks, messages {messages:?}");
}
Output
typed "hi", 3 ticks, messages ["new mail"]

21. Errors travel as values

A worker that can fail sends Results. The receiver decides what to do with each failure instead of the worker panicking.

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

fn parse_record(line: &str) -> Result<(String, u32), String> {
    let (name, age) = line.split_once(':').ok_or(format!("no ':' in {line:?}"))?;
    let age = age.trim().parse().map_err(|_| format!("bad age in {line:?}"))?;
    Ok((name.trim().to_string(), age))
}

fn main() {
    let (tx, rx) = mpsc::channel();
    thread::spawn(move || {
        for line in ["ana: 34", "ben 40", "cy: forty", "dee: 29"] {
            tx.send(parse_record(line)).unwrap();
        }
    });

    let (ok, failed): (Vec<_>, Vec<_>) = rx.iter().partition(|r| r.is_ok());
    let people: Vec<(String, u32)> = ok.into_iter().map(Result::unwrap).collect();
    println!("parsed: {people:?}");
    for e in failed {
        println!("error: {}", e.unwrap_err());
    }
}
Output
parsed: [("ana", 34), ("dee", 29)]
error: no ':' in "ben 40"
error: bad age in "cy: forty"

22. Batching messages

A consumer that writes to a database or network is more efficient with batches. Collect messages until the batch is full or a short timeout passes, then flush.

Rust
use std::sync::mpsc::{self, RecvTimeoutError};
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        for i in 1..=7 {
            tx.send(format!("event-{i}")).unwrap();
        }
        thread::sleep(Duration::from_millis(100)); // quiet period
        tx.send(String::from("event-8")).unwrap();
    });

    let mut batch = Vec::new();
    loop {
        match rx.recv_timeout(Duration::from_millis(30)) {
            Ok(event) => {
                batch.push(event);
                if batch.len() == 3 {
                    println!("flush (full): {batch:?}");
                    batch.clear();
                }
            }
            Err(RecvTimeoutError::Timeout) => {
                if !batch.is_empty() {
                    println!("flush (timeout): {batch:?}");
                    batch.clear();
                }
            }
            Err(RecvTimeoutError::Disconnected) => {
                if !batch.is_empty() {
                    println!("flush (closing): {batch:?}");
                }
                break;
            }
        }
    }
}
Output
flush (full): ["event-1", "event-2", "event-3"]
flush (full): ["event-4", "event-5", "event-6"]
flush (timeout): ["event-7"]
flush (closing): ["event-8"]

23. The actor pattern

An actor is a thread that owns some state and changes it only in response to messages. Nobody else can touch the state, so no locks are needed. A small handle type with ordinary methods hides the channels from callers.

Rust
use std::collections::HashMap;
use std::sync::mpsc;
use std::thread;

enum Command {
    Deposit { account: String, amount: i64 },
    Withdraw { account: String, amount: i64, reply: mpsc::Sender<Result<i64, String>> },
    Balance { account: String, reply: mpsc::Sender<i64> },
}

#[derive(Clone)]
struct BankHandle {
    tx: mpsc::Sender<Command>,
}

impl BankHandle {
    fn start() -> BankHandle {
        let (tx, rx) = mpsc::channel();
        thread::spawn(move || {
            let mut balances: HashMap<String, i64> = HashMap::new(); // owned by this thread only
            for cmd in rx {
                match cmd {
                    Command::Deposit { account, amount } => *balances.entry(account).or_default() += amount,
                    Command::Withdraw { account, amount, reply } => {
                        let bal = balances.entry(account).or_default();
                        let result = if *bal >= amount {
                            *bal -= amount;
                            Ok(*bal)
                        } else {
                            Err(format!("insufficient funds: balance {bal}"))
                        };
                        let _ = reply.send(result);
                    }
                    Command::Balance { account, reply } => {
                        let _ = reply.send(balances.get(&account).copied().unwrap_or(0));
                    }
                }
            }
        });
        BankHandle { tx }
    }

    fn deposit(&self, account: &str, amount: i64) {
        self.tx.send(Command::Deposit { account: account.to_string(), amount }).unwrap();
    }

    fn withdraw(&self, account: &str, amount: i64) -> Result<i64, String> {
        let (reply, rx) = mpsc::channel();
        self.tx.send(Command::Withdraw { account: account.to_string(), amount, reply }).unwrap();
        rx.recv().unwrap()
    }

    fn balance(&self, account: &str) -> i64 {
        let (reply, rx) = mpsc::channel();
        self.tx.send(Command::Balance { account: account.to_string(), reply }).unwrap();
        rx.recv().unwrap()
    }
}

fn main() {
    let bank = BankHandle::start();

    let tellers: Vec<_> = (0..4)
        .map(|_| {
            let bank = bank.clone();
            thread::spawn(move || {
                for _ in 0..25 {
                    bank.deposit("ana", 10);
                }
            })
        })
        .collect();
    for t in tellers {
        t.join().unwrap();
    }

    println!("balance: {}", bank.balance("ana"));
    println!("withdraw 300: {:?}", bank.withdraw("ana", 300));
    println!("withdraw 900: {:?}", bank.withdraw("ana", 900));
}
Output
balance: 1000
withdraw 300: Ok(700)
withdraw 900: Err("insufficient funds: balance 700")

Messages from one sender are processed in order, so after all four tellers are joined, every deposit is in the actor's queue before the balance request.

24. A dedicated logging thread

Writing logs from many threads to one output needs coordination. Sending log lines to one logger thread keeps request threads fast and the output unmixed.

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

enum Log {
    Line { level: &'static str, source: String, text: String },
    Flush(mpsc::Sender<usize>),
}

fn main() {
    let (log_tx, log_rx) = mpsc::channel();

    let logger = thread::spawn(move || {
        let mut buffer = Vec::new();
        let mut written = 0;
        for msg in log_rx {
            match msg {
                Log::Line { level, source, text } => buffer.push(format!("[{level}] {source}: {text}")),
                Log::Flush(done) => {
                    buffer.sort(); // make the demo's output stable
                    for line in buffer.drain(..) {
                        println!("{line}");
                        written += 1;
                    }
                    let _ = done.send(written);
                }
            }
        }
    });

    let workers: Vec<_> = ["auth", "orders", "payments"]
        .into_iter()
        .map(|source| {
            let log = log_tx.clone();
            thread::spawn(move || {
                log.send(Log::Line { level: "INFO", source: source.to_string(), text: String::from("started") }).unwrap();
                if source == "payments" {
                    log.send(Log::Line { level: "WARN", source: source.to_string(), text: String::from("gateway slow") }).unwrap();
                }
            })
        })
        .collect();
    for w in workers {
        w.join().unwrap();
    }

    let (done_tx, done_rx) = mpsc::channel();
    log_tx.send(Log::Flush(done_tx)).unwrap();
    println!("-- logger wrote {} lines --", done_rx.recv().unwrap());
    drop(log_tx);
    logger.join().unwrap();
}
Output
[INFO] auth: started
[INFO] orders: started
[INFO] payments: started
[WARN] payments: gateway slow
-- logger wrote 4 lines --

25. Realistic: a file-checksum pipeline with a worker pool

A three-stage pipeline: one thread "reads files", a pool of hasher threads computes checksums, and main collects the results. The bounded channel between reader and hashers limits how many file contents are held in memory at once.

Rust
use std::sync::{Arc, Mutex, mpsc};
use std::thread;

fn fnv1a(data: &[u8]) -> u64 {
    data.iter().fold(0xcbf29ce484222325, |h, &b| (h ^ b as u64).wrapping_mul(0x100000001b3))
}

fn main() {
    let files: Vec<(String, Vec<u8>)> = (1..=6)
        .map(|i| (format!("doc{i}.txt"), format!("contents of document number {i}").into_bytes()))
        .collect();

    let (content_tx, content_rx) = mpsc::sync_channel::<(String, Vec<u8>)>(2); // at most 2 files buffered
    let content_rx = Arc::new(Mutex::new(content_rx));
    let (hash_tx, hash_rx) = mpsc::channel();

    let reader = thread::spawn(move || {
        for file in files {
            content_tx.send(file).unwrap();
        }
    });

    let hashers: Vec<_> = (0..3)
        .map(|_| {
            let rx = Arc::clone(&content_rx);
            let tx = hash_tx.clone();
            thread::spawn(move || loop {
                let next = rx.lock().unwrap().recv();
                let Ok((name, bytes)) = next else { break };
                tx.send((name, bytes.len(), fnv1a(&bytes))).unwrap();
            })
        })
        .collect();
    drop(hash_tx);

    let mut results: Vec<(String, usize, u64)> = hash_rx.iter().collect();
    results.sort();
    reader.join().unwrap();
    for h in hashers {
        h.join().unwrap();
    }

    for (name, len, hash) in results {
        println!("{name:<9} {len:>3} bytes  {hash:016x}");
    }
}
Output
doc1.txt   29 bytes  5bc7a664749e6651
doc2.txt   29 bytes  5bc7a364749e6138
doc3.txt   29 bytes  5bc7a464749e62eb
doc4.txt   29 bytes  5bc7a964749e6b6a
doc5.txt   29 bytes  5bc7aa64749e6d1d
doc6.txt   29 bytes  5bc7a764749e6804

26. Realistic: progress reporting from workers

Workers report progress on a channel; a single "UI" thread (main) aggregates it and prints a summary, so worker threads never print directly.

Rust
use std::collections::BTreeMap;
use std::sync::mpsc;
use std::thread;

enum Progress {
    Started { job: &'static str, total: u32 },
    Advanced { job: &'static str, done: u32 },
    Finished { job: &'static str },
}

fn main() {
    let (tx, rx) = mpsc::channel();

    for (job, total) in [("resize-images", 40), ("send-emails", 25), ("backup-db", 10)] {
        let tx = tx.clone();
        thread::spawn(move || {
            tx.send(Progress::Started { job, total }).unwrap();
            for done in (10..=total).step_by(10) {
                tx.send(Progress::Advanced { job, done }).unwrap();
            }
            tx.send(Progress::Finished { job }).unwrap();
        });
    }
    drop(tx);

    let mut state: BTreeMap<&str, (u32, u32, bool)> = BTreeMap::new(); // job -> (done, total, finished)
    let mut updates = 0;
    for msg in rx {
        updates += 1;
        match msg {
            Progress::Started { job, total } => {
                state.insert(job, (0, total, false));
            }
            Progress::Advanced { job, done } => state.get_mut(job).unwrap().0 = done,
            Progress::Finished { job } => state.get_mut(job).unwrap().2 = true,
        }
    }

    println!("{updates} progress messages");
    for (job, (done, total, finished)) in state {
        println!("{job:<14} {done:>2}/{total:<2} {}", if finished { "done" } else { "running" });
    }
}
Output
13 progress messages
backup-db      10/10 done
resize-images  40/40 done
send-emails    20/25 done

Each job's own messages arrive in order (Started before Advanced), which is why state.get_mut(job).unwrap() is safe here.

27. Realistic: a key-value store actor with snapshots

A slightly bigger actor: a key-value store that supports get, set, and snapshot. Many client threads use it concurrently; the actor processes one command at a time, so each command is atomic.

Rust
use std::collections::BTreeMap;
use std::sync::mpsc::{self, Sender};
use std::thread;

enum Cmd {
    Set(String, i64),
    Incr(String, Sender<i64>),
    Get(String, Sender<Option<i64>>),
    Snapshot(Sender<BTreeMap<String, i64>>),
}

fn start_store() -> (Sender<Cmd>, thread::JoinHandle<usize>) {
    let (tx, rx) = mpsc::channel();
    let handle = thread::spawn(move || {
        let mut data = BTreeMap::new();
        let mut handled = 0;
        for cmd in rx {
            handled += 1;
            match cmd {
                Cmd::Set(k, v) => {
                    data.insert(k, v);
                }
                Cmd::Incr(k, reply) => {
                    let v = data.entry(k).or_insert(0);
                    *v += 1;
                    let _ = reply.send(*v);
                }
                Cmd::Get(k, reply) => {
                    let _ = reply.send(data.get(&k).copied());
                }
                Cmd::Snapshot(reply) => {
                    let _ = reply.send(data.clone());
                }
            }
        }
        handled // returned when every Sender is gone
    });
    (tx, handle)
}

fn main() {
    let (store, actor) = start_store();
    store.send(Cmd::Set(String::from("visits"), 100)).unwrap();

    let clients: Vec<_> = (0..5)
        .map(|_| {
            let store = store.clone();
            thread::spawn(move || {
                for _ in 0..20 {
                    let (reply, rx) = mpsc::channel();
                    store.send(Cmd::Incr(String::from("visits"), reply)).unwrap();
                    rx.recv().unwrap();
                }
            })
        })
        .collect();
    for c in clients {
        c.join().unwrap();
    }

    let (reply, rx) = mpsc::channel();
    store.send(Cmd::Get(String::from("visits"), reply)).unwrap();
    println!("visits: {:?}", rx.recv().unwrap());

    let (reply, rx) = mpsc::channel();
    store.send(Cmd::Snapshot(reply)).unwrap();
    println!("snapshot: {:?}", rx.recv().unwrap());

    drop(store); // last Sender: the actor's loop ends
    println!("actor handled {} commands", actor.join().unwrap());
}
Output
visits: Some(200)
snapshot: {"visits": 200}
actor handled 103 commands

28. Choosing between channels and locks

Both styles are correct in Rust; the compiler checks both. Some rules of thumb:

Use channels when Use shared state when
work flows through stages (pipelines, producers/consumers) many threads read and occasionally update the same data
one thread should own a resource (a connection, a file, a UI) the data is naturally a shared table (cache, registry)
you want backpressure between fast and slow parts you need low overhead for tiny operations (counters: atomics)
you want requests handled one at a time (actors) you need to read a consistent snapshot of several fields

Many programs mix them: an Arc<RwLock<Config>> read by workers that pull jobs from a channel.

Summary

  • mpsc::channel() gives a cloneable Sender<T> and a single Receiver<T>. send moves the value to the receiving thread.
  • A Receiver loop ends when every Sender is dropped. Forgetting to drop the original tx makes it hang.
  • send fails if the receiver is gone; recv fails when the channel is empty and closed. try_recv and recv_timeout avoid blocking forever.
  • sync_channel(n) is bounded and gives backpressure; sync_channel(0) is a rendezvous.
  • Send an enum for several message kinds, a reply Sender for request/response, and an index to restore order.
  • Share a Receiver behind Arc<Mutex<_>> for a worker pool; a thread pool's Drop can close the channel and join workers for graceful shutdown.
  • Actors own their state and handle one message at a time, with no locks.

Next: Atomics, Send & Sync, which looks under the hood at lock-free primitives and the traits that make all of this compile-time safe.