12. Concurrency

📋 Jump to Takeaways

🎁 In most languages, concurrency bugs hide until production. In Rust, data races are impossible — the type system catches them at compile time. No runtime cost, no sanitizer flags, no prayer. The compiler simply refuses to build code that could corrupt shared memory. This is "fearless concurrency."

Spawning Threads

Use std::thread::spawn to create a new OS thread. It takes a closure that becomes the thread's entry point.

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

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=3 {
            println!("spawned thread: {}", i);
            thread::sleep(Duration::from_millis(100));
        }
    });

    for i in 1..=3 {
        println!("main thread: {}", i);
        thread::sleep(Duration::from_millis(100));
    }

    handle.join().unwrap(); // wait for spawned thread to finish
    // Output interleaves — both threads run concurrently
}

thread::spawn returns a JoinHandle. Calling .join() blocks until that thread completes and returns its result (or propagates a panic).

Move Closures for Threads

Spawned threads might outlive the scope that created them. Rust forces you to move data into the thread closure so ownership is clear.

use std::thread;

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

    let handle = thread::spawn(move || {
        // data is now owned by this thread
        let sum: i32 = data.iter().sum();
        println!("sum: {}", sum); // sum: 6
    });

    // println!("{:?}", data); // ERROR: data was moved
    handle.join().unwrap();
}

Without move, the compiler rejects the code because it can't guarantee data lives long enough. This is the ownership system preventing a use-after-free at compile time.

Channels: Message Passing

Channels let threads communicate by sending messages. Rust's standard library provides mpsc — multiple producer, single consumer.

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

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

    thread::spawn(move || {
        let messages = vec!["hello", "from", "thread"];
        for msg in messages {
            tx.send(msg).unwrap();
        }
    });

    // rx.recv() blocks until a message arrives
    for received in rx {
        println!("got: {}", received);
    }
    // got: hello
    // got: from
    // got: thread
}

The rx iterator ends when all senders are dropped. You can clone tx to have multiple producers sending to the same receiver.

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

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

    thread::spawn(move || { tx.send("from thread 1").unwrap(); });
    thread::spawn(move || { tx2.send("from thread 2").unwrap(); });

    for msg in rx {
        println!("{}", msg);
    }
    // Order depends on scheduling — both messages arrive
}

Shared State: Arc and Mutex

When multiple threads need to read and write the same data, use Arc<Mutex<T>>. Mutex provides mutual exclusion (only one thread locks at a time). Arc is an atomically reference-counted pointer that allows shared ownership across threads.

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

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

    for _ in 0..5 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("final count: {}", *counter.lock().unwrap()); // final count: 5
}

You can't use a plain Rc across threads — the compiler rejects it because Rc doesn't implement Send. You can't use Mutex without Arc across threads because the mutex would be dropped when the spawning scope ends. The type system guides you to the correct combination.

Send and Sync Traits

Rust's thread safety guarantees come from two marker traits:

  • Send — a type can be transferred to another thread. Almost everything is Send.
  • Sync — a type can be referenced from multiple threads. T is Sync if &T is Send.

These are auto-implemented by the compiler. Types like Rc<T> are not Send, and Cell<T> is not Sync. You never implement them manually in normal code — the compiler checks them for you.

use std::rc::Rc;
use std::thread;

fn main() {
    let data = Rc::new(5);

    // This won't compile:
    // thread::spawn(move || {
    //     println!("{}", data);
    // });
    // ERROR: `Rc<i32>` cannot be sent between threads safely

    // Fix: use Arc instead
    use std::sync::Arc;
    let data = Arc::new(5);
    thread::spawn(move || {
        println!("{}", data); // 5
    }).join().unwrap();
}

The compiler enforces Send and Sync at every thread boundary. You cannot accidentally share non-thread-safe data.

Channels vs Mutex: When to Use Which

Use Case Prefer
One thread produces, another consumes Channel
Multiple writers updating shared state Arc<Mutex<T>>
Pipeline / streaming data Channel
Shared config or cache Arc<Mutex<T>> or Arc<RwLock<T>>
Simple coordination (done signal) Channel

Channels enforce a "share by communicating" philosophy. Mutexes enforce a "communicate by sharing" model. When in doubt, start with channels — they're harder to deadlock.

Rayon: Easy Parallelism

The rayon crate turns sequential iterators into parallel ones with a single method change. It handles thread pool management and work-stealing automatically.

// Add to Cargo.toml: rayon = "1"
use rayon::prelude::*;

fn main() {
    let numbers: Vec<i64> = (1..=1_000_000).collect();

    // Just change .iter() to .par_iter()
    let sum: i64 = numbers.par_iter().sum();
    println!("sum: {}", sum); // sum: 500000500000

    // Parallel map + filter
    let results: Vec<i64> = numbers
        .par_iter()
        .filter(|&&x| x % 2 == 0)
        .map(|x| x * x)
        .collect();

    println!("even squares count: {}", results.len());
}

Rayon is the go-to choice for data parallelism. It uses all available CPU cores and requires no manual thread management.

Async/Await and Tokio

For I/O-bound work (network requests, file operations, databases), Rust offers async/await. Unlike threads, async tasks don't each require an OS thread — thousands can run on a small thread pool.

// Add to Cargo.toml: tokio = { version = "1", features = ["full"] }

#[tokio::main]
async fn main() {
    let task1 = tokio::spawn(async {
        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
        "task 1 done"
    });

    let task2 = tokio::spawn(async {
        tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
        "task 2 done"
    });

    let result1 = task1.await.unwrap();
    let result2 = task2.await.unwrap();
    println!("{}, {}", result1, result2); // task 1 done, task 2 done
}

Async Rust is a large topic on its own. The key points: async fn returns a Future, .await yields control until the future completes, and you need a runtime like Tokio or async-std to execute futures. Use threads for CPU-bound parallelism and async for I/O-bound concurrency.

Practical Example: Parallel Web Scraper Pattern

Here's a pattern combining channels and threads for concurrent work:

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

fn process_item(id: u32) -> String {
    thread::sleep(Duration::from_millis(50)); // simulate work
    format!("result-{}", id)
}

fn main() {
    let (tx, rx) = mpsc::channel();
    let items: Vec<u32> = (1..=4).collect();

    for item in items {
        let tx = tx.clone();
        thread::spawn(move || {
            let result = process_item(item);
            tx.send(result).unwrap();
        });
    }

    drop(tx); // drop original sender so rx iterator ends

    let results: Vec<String> = rx.into_iter().collect();
    println!("{:?}", results); // ["result-1", "result-3", "result-2", "result-4"] (order varies)
}

Notice we drop(tx) — the original sender — so the receiver knows when all senders are gone and stops iterating.

Key Takeaways

  • thread::spawn creates OS threads; use move closures to transfer ownership
  • JoinHandle::join() waits for a thread to finish and propagates panics
  • Channels (mpsc) enable safe message passing between threads
  • Arc<Mutex<T>> enables safe shared mutable state across threads
  • Send and Sync are compiler-enforced — you can't accidentally share unsafe types
  • Channels suit producer/consumer patterns; mutexes suit shared caches and counters
  • Rayon makes data parallelism trivial with .par_iter()
  • Async/await handles I/O concurrency without one thread per connection
  • Rust guarantees no data races at compile time — "fearless concurrency" is real

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue

© 2026 ByteLearn.dev. Free courses for developers. · Privacy