Reading Rust

Part 4 · Concurrency & async

Threads, channels, and shared state

What the concurrency primitives actually look like, how they compare to goroutines and `synchronized`, and why scoped threads changed the ergonomics.

40 mincontrasting casesPOE
What this lesson is trying to break (3)
  • A thread closure must always take ownership of everything.
  • Channels are the idiomatic answer to everything, as in Go.
  • `Arc<Mutex<T>>` is a code smell.

The primitives, quickly, with attention to the parts that differ from what you already know.

Spawning

use std::thread;

let handle = thread::spawn(move || {
    expensive_work();
    42
});

let result = handle.join().unwrap();   // blocks; Err if the thread panicked

Three things to notice.

move is nearly always required. spawn demands F: Send + 'static, and 'static means the closure cannot borrow from the calling frame (2.4) — because the thread may outlive it.

join returns a Result. Err means the thread panicked. This is one of the few places you can observe a panic without the process ending.

Threads are OS threads. Not green threads, not goroutines. Roughly 8 MB of virtual stack each on Linux by default, and a real cost to create. If you want tens of thousands of concurrent tasks, that is async (4.3), not threads.

Scoped threads

The 'static requirement used to force a lot of ceremony. Since Rust 1.63:

let data = vec![1, 2, 3];

thread::scope(|s| {
    s.spawn(|| println!("{:?}", &data));    // borrows! no Arc, no clone
    s.spawn(|| println!("{}", data.len()));
});
// all scoped threads are guaranteed joined here

scope guarantees every thread it spawned has finished before it returns. That makes borrowing sound, so the 'static bound is not needed.

This is a genuine ergonomic improvement and reasonably recent, so it is worth knowing about: if you read older Rust and see Arc::new purely to hand data to short-lived worker threads, that code predates scoped threads or has not been updated.

Channels

use std::sync::mpsc;

let (tx, rx) = mpsc::channel();

for i in 0..3 {
    let tx = tx.clone();                 // multi-producer
    thread::spawn(move || tx.send(i * 10).unwrap());
}
drop(tx);                                // drop the original, or rx never ends

for received in rx {                     // iterates until all senders are gone
    println!("{received}");
}

Two things bite people here.

mpsc is multi-producer, single-consumer. Clone tx for more senders; there is only ever one rx.

And that drop(tx) matters. The receiver’s iterator ends when all senders have been dropped. Keep the original tx alive in scope and the loop hangs forever, which is a very common first bug.

Varies: the language’s default answer for sharing between concurrent tasks

GoJavaRust
Unit of concurrencyGoroutine (green, ~2 KB)Thread, or virtual threadOS thread, or async task
Cultural default“Share memory by communicating” — channelssynchronized, concurrent collectionsWhichever fits
Shared mutable stateAllowed, unchecked; -race at test timesynchronized, by conventionArc<Mutex<T>>, checked at compile time
Channels areA language featureA library classA library type (std, crossbeam, tokio)
Forgetting to synchroniseA race, found at run time if luckyA race, found in productionDoes not compile

Rust has no cultural preference for channels the way Go does. Shared state with a mutex is entirely idiomatic here, because the compiler makes it safe.

That last point is worth internalising if you are coming from Go, where reaching for a mutex can feel like admitting defeat. In Rust, Arc<Mutex<T>> is not a code smell. It is the standard tool, used throughout the ecosystem.

The questions worth asking in review are not “why a mutex?” but:

  • How long is the lock held? (See 1.4 — the scope is the critical section.)
  • Does the critical section do I/O, or call anything that might block?
  • Would an atomic express this better? A counter does not need a mutex.
  • Is there a lock-ordering rule, and is it written down?

Shared state

use std::sync::{Arc, Mutex};

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
    let counter = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        let mut n = counter.lock().unwrap();
        *n += 1;
    }));                                  // guard dropped here → unlocked
}

for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap());   // always 10

Read the two layers separately, because they do different jobs:

  • Arc — shared ownership. Several threads each hold a handle; the value is freed when the last one goes.
  • Mutex — shared mutability. Serialises access so only one thread has &mut at a time.

Neither substitutes for the other, which is why the combination is so common. And note .lock().unwrap(): the Result is for lock poisoning — if a thread panics while holding the lock, later lockers get an Err, because the data may be in a broken half-updated state. Most code unwraps; being aware of what it means is enough.

Predict firstcommit in writing, then look
fn transfer(from: &Mutex<u64>, to: &Mutex<u64>, amount: u64) {
    let mut a = from.lock().unwrap();
    let mut b = to.lock().unwrap();
    *a -= amount;
    *b += amount;
}

Rayon

Worth knowing about, because it is the strongest practical demonstration of what Part 4 buys:

use rayon::prelude::*;

let total: u64 = items.par_iter()          // was .iter()
    .map(|x| expensive(x))
    .sum();

Change one method name and the loop runs on a work-stealing thread pool. And because par_iter requires Send + Sync on everything involved, you cannot parallelise a loop that would have raced. If the closure captured a RefCell, it would not compile.

That is the Part 4 argument in one line: safety that comes from the ordinary type system is safety you can apply retroactively, cheaply, to code you did not write carefully in the first place.

Explain it to yourselfwrite it — thinking it does not count

You are reviewing a service. A handler does this:

let mut cache = state.cache.lock().unwrap();
let value = fetch_from_database(&key).await;
cache.insert(key, value);

Name the problem. Then name the second problem, which is about which Mutex this is.

What you should now be able to say

  • spawn needs Send + 'static; thread::scope lifts the 'static by guaranteeing joins.
  • std channels are mpsc; the receiver ends only when all senders drop.
  • Rust threads are OS threads — for many thousands of tasks, use async.
  • Arc<Mutex<T>> is idiomatic, not a smell. Arc is ownership, Mutex is mutability.
  • lock() returns a Result because of poisoning.
  • Deadlocks still compile. Lock ordering is yours.
  • rayon shows the payoff: parallelism you can add safely after the fact.

Next: 4.3 — Futures are inert. If you write JavaScript, this is the biggest trap in the language.

In the wildgo read the real thing now, while it is fresh
  • std::thread::scopeStabilised in Rust 1.63 and it quietly removed most of the Arc-cloning ceremony from short-lived parallelism. Read the example and compare it mentally with the spawn version.
  • rayon — par_iterChanging `.iter()` to `.par_iter()` parallelises a loop, and the Send/Sync bounds make it impossible to do so incorrectly. This is the strongest practical demonstration of what Part 4 buys you.
Scheduled for recall3 items added to your review queue

These are not shown to you now on purpose. They will surface in Review tomorrow, mixed in among items from other parts of the course — not grouped by topic. Sorting out which idea applies is the part that transfers.