Lesson 20 / 25

Threads, Arc, Mutex and Channels

Share safely or pass messages.

Three patterns

thread::spawn with a move closure runs code on a new OS thread. To share mutable state, wrap it in Arc<Mutex<T>>: Arc gives shared ownership across threads, Mutex ensures exclusive access, and the lock guard unlocks automatically when dropped. To pass messages, use channels (mpsc::channel): senders can be cloned, and the receiver iteration ends when all senders are dropped. Scoped threads (thread::scope) can borrow local data because they are guaranteed to finish before the scope ends.

Threads the compiler checks

Rust's type system rules out data races; threads, Arc, Mutex and channels share work safely.

Three ideas: threads and sharing, Send and Sync, async.
Figure 7.1 — Threads, Send/Sync and async.

A shared counter, a channel and scoped threads, run

I ran this with Rust 1.99.0 (cargo run, edition 2024, standard library only). Eight threads add 1,000 each to a mutex-protected counter for exactly 8,000; three threads send squares over a channel; a scoped thread borrows data that remains usable afterwards.

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

fn main() {
    let counter = Arc::new(Mutex::new(0));      // shared ownership + exclusive access
    let mut handles = vec![];
    for _ in 0..8 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            for _ in 0..1000 {
                *counter.lock().unwrap() += 1;
            }
        }));
    }
    for h in handles { h.join().unwrap(); }
    println!("counter = {}", *counter.lock().unwrap());

    let (tx, rx) = mpsc::channel();
    for id in 0..3 {
        let tx = tx.clone();
        thread::spawn(move || tx.send(id * id).unwrap());
    }
    drop(tx);                                    // close the original sender
    let mut results: Vec<i32> = rx.iter().collect();
    results.sort();
    println!("squares from threads: {results:?}");

    let data = vec![1, 2, 3];
    let sum = thread::scope(|s| {                // scoped threads may borrow local data
        let h = s.spawn(|| data.iter().sum::<i32>());
        h.join().unwrap()
    });
    println!("scoped sum {sum}, data still usable: {data:?}");
}

Output:

counter = 8000
squares from threads: [0, 1, 4]
scoped sum 6, data still usable: [1, 2, 3]

Keep lock scopes short

Lock, do the minimal work, and let the guard drop; long-held locks serialise your program.

Quick check: Why wrap a shared counter in Arc<Mutex<i32>>?

  • To make it immutable
  • Arc shares ownership across threads and Mutex ensures exclusive access
  • To avoid using threads
  • Because i32 cannot be copied
Answer

Arc shares ownership across threads and Mutex ensures exclusive access — Shared ownership plus synchronisation.