Level 3 · Power Tools

Fearless Concurrency 🧵

Doing many things at once is where most languages get scary, and where Rust got its swagger. The trick: everything you've learned since Lesson 6 was secretly preparing you for this.

Why concurrency is feared

Your CPU has many cores; using them means running threads: several instruction streams at once. The danger: two threads touching the same data at the same time, a data race. Races are the worst kind of bug: invisible in testing, catastrophic at 3am, unreproducible by daylight. Most languages document "best practices" and wish you luck. Rust made the compiler responsible. If a program has a data race, it does not compile.

Spawning threads

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=3 {
            println!("side quest: step {i}");
            thread::sleep(Duration::from_millis(50));
        }
    });

    println!("main quest continues...");
    handle.join().unwrap();   // wait for the side quest to finish
    println!("all quests done!");
}

thread::spawn takes a closure (Lesson 14!) and runs it in parallel. join() waits for it. Run it twice; the line order can differ. That unpredictability is exactly why the next part matters.

Ownership meets threads: move

use std::thread;

fn main() {
    let treasure = vec![1, 2, 3];

    let handle = thread::spawn(move || {     // move: closure takes ownership
        println!("thread guards: {treasure:?}");
    });

    // println!("{treasure:?}");   // ❌ moved into the thread, hands off!
    handle.join().unwrap();
}

Without move, the closure would borrow treasure, but the compiler can't know how long the thread will live. Maybe longer than main's variables! So it demands ownership transfer. Feel that? The "annoying" ownership rules from Lesson 6 just quietly prevented a use-after-free across threads, a bug class that has haunted C for fifty years.

Talking between threads: channels 📮

The Rust motto: "Do not communicate by sharing memory; share memory by communicating." A channel is a one-way pipe (many senders, one receiver):

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

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

    for worker in 0..3 {
        let tx = tx.clone();                 // each worker gets a sender
        thread::spawn(move || {
            tx.send(format!("worker {worker} reporting in")).unwrap();
        });
    }
    drop(tx);   // close the original so the loop below can end

    for message in rx {                      // receive until all senders gone
        println!("📨 {message}");
    }
}

Bonus elegance: send moves the value down the pipe (ownership again!), so after sending, the worker can't secretly keep modifying the message. The API makes cheating impossible, not just impolite.

Sharing state: Arc<Mutex<T>> 🔒

Sometimes threads genuinely need to update one shared value. Two tools, both of which you basically already know:

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

fn main() {
    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();  // take the lock...
            *n += 1;                              // ...safely mutate...
        }));                                      // ...lock auto-released here
    }

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

In C++ this program compiles happily without the lock and corrupts the count randomly. In Rust, try removing the Mutex and sharing a plain counter: the compiler rejects it. The phrase fearless concurrency isn't marketing: whole categories of concurrency bugs are compile-time errors, so refactoring threaded code stops being scary.

🔭 Beyond threads: async For servers juggling thousands of connections, Rust also has async/await (with runtimes like tokio): same safety guarantees, different machinery. Learn threads first; async is a natural next step once you're comfortable.
⚠️ Common stumbles
  • Forgetting move on the closure; the compiler will tell you, with the fix.
  • Forgetting join(): main ends, program exits, threads die mid-sentence.
  • Using Rc/RefCell across threads: refused at compile time; the thread-safe spellings are Arc/Mutex.
Exercise 1

Race... condition-free

Spawn two threads: one prints "tick 1..5", the other "tock 1..5", each sleeping 30ms between prints. Join both. Run it a few times. Does the interleaving change? Why is this randomness still safe?

Reveal solution
use std::thread;
use std::time::Duration;

fn main() {
    let t1 = thread::spawn(|| for i in 1..=5 {
        println!("tick {i}");
        thread::sleep(Duration::from_millis(30));
    });
    let t2 = thread::spawn(|| for i in 1..=5 {
        println!("tock {i}");
        thread::sleep(Duration::from_millis(30));
    });
    t1.join().unwrap();
    t2.join().unwrap();
}

The order varies (that's inherent to parallelism), but no data is shared, so no race exists. Unpredictable timing is fine; unpredictable data is the disease, and Rust cures it.

Exercise 2

The worker pipeline

Spawn 4 workers that each compute a square (worker n sends n * n) through a channel. Collect and sum the results in main. (Mind the drop(tx) trick.)

Reveal solution
use std::sync::mpsc;
use std::thread;

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

    for n in 1..=4 {
        let tx = tx.clone();
        thread::spawn(move || tx.send(n * n).unwrap());
    }
    drop(tx);

    let total: i32 = rx.iter().sum();   // channels are iterators too!
    println!("1+4+9+16 = {total}");     // 30
}
Exercise 3

Shared crab census

Adapt the Arc<Mutex<...>> counter: make it an Arc<Mutex<Vec<String>>>, have 5 threads each push "crab #n", then print the final census. Predict the length. Can you predict the order?

Reveal solution
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let census = Arc::new(Mutex::new(Vec::new()));
    let mut handles = vec![];

    for n in 1..=5 {
        let census = Arc::clone(&census);
        handles.push(thread::spawn(move || {
            census.lock().unwrap().push(format!("crab #{n}"));
        }));
    }
    for h in handles { h.join().unwrap(); }

    println!("{:?}", census.lock().unwrap());
}

Length: always 5. Order: whoever grabbed the lock first (varies per run). Consistent data, flexible timing. Fearless.