Rust Concurrency with Threads

Modern computers run multiple tasks at the same time. A web server handles thousands of requests simultaneously. A game engine updates physics, plays sound, and renders graphics in parallel. Threads make all of this possible — each thread is an independent line of execution that runs alongside others within the same program.

Rust gives you the tools to write concurrent code with confidence. Its ownership system catches data races at compile time rather than letting them crash your program at runtime. If your code compiles, Rust guarantees it is free of data races. That is a promise no other mainstream systems language makes.

What Is a Thread?

A thread is a lightweight unit of execution managed by the operating system. Every Rust program starts with one thread — the main thread. You spawn additional threads when you want work to happen concurrently.

The Kitchen Crew Diagram

Think of a restaurant kitchen. The head chef (main thread) manages the evening. They assign tasks to sous chefs (spawned threads) — one handles appetizers, another preps the main course, a third manages desserts. All three cook at the same time. The head chef waits for each dish to arrive before plating the full meal.

┌─────────────────────────────────────────┐
│              MAIN THREAD                │
│  - starts program                       │
│  - spawns worker threads                │
│  - calls .join() to wait for results    │
└──────────┬─────────────┬────────────────┘
           │             │
    ┌──────▼──────┐  ┌───▼──────────┐
    │  Thread 1   │  │   Thread 2   │
    │  "Pasta"    │  │  "Dessert"   │
    │  cooking…   │  │  baking…     │
    └─────────────┘  └──────────────┘

Spawning a Thread with thread::spawn

You create a new thread by calling std::thread::spawn and passing it a closure. The closure contains the code that thread will run.

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

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

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

    handle.join().unwrap();
}

The handle returned by thread::spawn lets you control that thread. Calling handle.join() tells the main thread to pause and wait until the spawned thread finishes. Without join, the main thread might finish first and shut down the entire program before the spawned thread completes its work.

What join() Does — The Checkpoint Diagram

Main Thread:   ──────────────────────────●─── wait ───► resumes ──► ends
                                         │                ▲
Spawned Thread:          ──────────────────────────────────►
                                         ↑ join() placed here

The dot marks where the main thread calls join. It pauses there until the spawned thread crosses the finish line. Both threads run in parallel up to that point.

Moving Data into Threads with move Closures

Threads often need access to data from the outer scope. By default, closures borrow data. But a spawned thread might outlive the scope where the data was defined. Rust refuses to compile code that would leave a thread holding a dangling reference.

The solution is the move keyword. It forces the closure to take ownership of any captured variables.

use std::thread;

fn main() {
    let message = String::from("Hello from the main thread!");

    let handle = thread::spawn(move || {
        println!("{}", message);
    });

    // message is no longer accessible here — the thread owns it
    handle.join().unwrap();
}

The Packed Lunchbox Diagram

Without move, the thread tries to borrow food from the kitchen (main scope). The kitchen might close before the thread finishes eating — dangling reference. With move, the thread takes its own packed lunchbox. It owns the food. The kitchen can close any time.

Without move:                    With move:
┌──────────────┐                ┌──────────────┐
│ Main scope   │                │ Main scope   │
│ message ─────┼──borrow──►     │              │ (message gone)
│              │     Thread     │              │
└──────────────┘    (dangerous) └──────────────┘
                                        │
                                ┌───────▼──────┐
                                │   Thread     │
                                │ owns message │
                                └──────────────┘

Message Passing with Channels

Rust promotes a philosophy borrowed from Go: "Do not communicate by sharing memory; share memory by communicating." Channels implement this idea. A channel has two ends — a sender and a receiver. One thread sends values; another receives them.

The Mail System Diagram

┌───────────────┐      channel      ┌───────────────┐
│   Thread A    │                   │   Thread B    │
│               │──── tx.send() ───►│               │
│   (sender)    │                   │  (receiver)   │
│               │◄─── rx.recv() ────│               │
└───────────────┘                   └───────────────┘

tx = transmitter (sends mail)
rx = receiver (collects mail)
use std::sync::mpsc;
use std::thread;

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

    thread::spawn(move || {
        let msg = String::from("Hello from the worker!");
        tx.send(msg).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

mpsc stands for multiple producer, single consumer. Many threads can own a clone of tx and send values. Only one thread owns rx and receives them. Once a value passes through a channel, ownership transfers to the receiver — the sender can no longer access it.

Sending Multiple Values

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

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

    thread::spawn(move || {
        let items = vec!["Rust", "is", "fast"];
        for item in items {
            tx.send(item).unwrap();
            thread::sleep(Duration::from_millis(200));
        }
    });

    for received in rx {
        println!("Got: {}", received);
    }
}

Iterating over rx blocks the main thread until the channel closes. The channel closes automatically when all senders go out of scope.

Multiple Producers

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

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

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

    for msg in rx.iter().take(2) {
        println!("{}", msg);
    }
}

Shared State with Mutex

Sometimes you need multiple threads to access the same piece of data. A channel moves ownership. Shared state keeps the data in one place and lets threads take turns accessing it. Mutex<T> (mutual exclusion) makes this safe.

The Single Key Diagram

A shared office has one private filing cabinet (the data). There is exactly one key to that cabinet. A thread must pick up the key (lock the Mutex), do its work, and put the key back (unlock). While one thread holds the key, everyone else waits.

┌──────────────────────────────────────────────┐
│              Mutex<i32>: value = 5           │
│                                              │
│  Thread A: lock → read/write → unlock (key)  │
│  Thread B: waiting for key…                  │
│  Thread C: waiting for key…                  │
└──────────────────────────────────────────────┘
use std::sync::Mutex;

fn main() {
    let counter = Mutex::new(0);

    {
        let mut num = counter.lock().unwrap();
        *num += 1;
    } // lock released here automatically

    println!("counter = {:?}", counter);
}

lock() returns a MutexGuard. When the guard goes out of scope, it releases the lock. This happens automatically — you cannot forget to unlock, because Rust's Drop trait handles it.

Sharing a Mutex Across Threads with Arc

A Mutex on its own only works in a single thread because you cannot share a regular reference across threads. You need Arc<T> — Atomic Reference Counted — which is the thread-safe version of Rc<T>.

The Shared Key Ring Diagram

        Arc<Mutex<i32>>  (shared counter)
               │
    ┌──────────┼──────────┐
    │          │          │
Thread 1   Thread 2   Thread 3
clone       clone       clone
  │           │           │
  └───────────┴───────────┘
  All three share the same Mutex
  Only one locks it at a time
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 c = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = c.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

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

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

Five threads all increment the same counter. Arc lets all five threads share ownership. Mutex ensures only one thread writes at a time. The result is always exactly 5 — no data race, no undefined behavior.

The Send and Sync Traits

Rust encodes thread safety directly into the type system through two marker traits.

Send and Sync — The Safety Passport Diagram

┌──────────────────────────────────────────────────────┐
│                   TYPE SYSTEM                        │
│                                                      │
│  Send  → "I can be moved to another thread"          │
│  Sync  → "I can be referenced from any thread"       │
│                                                      │
│  Examples:                                           │
│  ✔ i32, String, Vec → Send + Sync                    |
│  ✔ Arc<T> where T: Send + Sync → Send + Sync         |
│  ✗ Rc<T>  → neither Send nor Sync                    |  
│  ✗ RefCell<T> → Send but not Sync                    |
└──────────────────────────────────────────────────────┘

If you accidentally try to send a type like Rc<T> to a thread, Rust refuses to compile. You get a clear error message telling you that Rc cannot be sent safely across threads. Switch to Arc and the error disappears. The compiler acts as a thread-safety auditor for every type in your program.

Deadlocks

Rust prevents data races, but it cannot prevent deadlocks. A deadlock happens when two threads each hold a lock that the other one needs. Neither can proceed — they wait forever.

The Crossed Keys Diagram

Thread A holds Lock 1, needs Lock 2
Thread B holds Lock 2, needs Lock 1

Thread A: ─── holds [🔑 Lock1] ─── waiting for [Lock2] ───►
Thread B: ─── holds [🔑 Lock2] ─── waiting for [Lock1] ───►

Both threads block forever ← DEADLOCK

Avoid deadlocks by always acquiring locks in the same order across all threads, keeping lock durations short, and releasing locks before acquiring new ones. These are design decisions — the compiler cannot enforce them for you.

Thread Scope with thread::scope

Spawned threads normally require move closures and 'static lifetimes. Rust 1.63 introduced thread::scope, which lets you spawn threads that borrow data from the outer scope without moving it.

use std::thread;

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

    thread::scope(|s| {
        s.spawn(|| {
            println!("Sum: {}", data.iter().sum::<i32>());
        });
        s.spawn(|| {
            println!("Length: {}", data.len());
        });
    }); // all threads guaranteed to finish before scope ends

    println!("Original data: {:?}", data);
}

Both threads borrow data immutably. Rust guarantees all scoped threads finish before the scope block exits. You keep access to data in the main thread afterward — no move required.

scope vs spawn — The Bounded vs Unbounded Diagram

thread::spawn (unbounded):
Main: ───────────────────────────────► continues immediately
Spawned: ────────────────────────────────────────────► ends ?

thread::scope (bounded):
Main: ──────────┬─── scope block ───┬──────────► continues
Threads:        ├─ Thread A ──►     │
                └─ Thread B ─────►  │
                                    ▲ all must finish here

Rayon — Data Parallelism Made Simple

For parallel iteration over collections, the rayon crate provides a drop-in replacement for Rust's standard iterators. You swap .iter() for .par_iter() and Rayon distributes the work across all available CPU cores automatically.

// Cargo.toml: rayon = "1.10"
use rayon::prelude::*;

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

    let sum: i32 = numbers.par_iter().sum();
    println!("Sum: {}", sum);
}

Rayon handles thread pools, load balancing, and work stealing internally. You focus on what to compute, not how to parallelize it.

Choosing the Right Concurrency Tool

SituationTool
Run independent work in parallelthread::spawn
Threads that borrow local datathread::scope
Send values between threadsmpsc::channel
Shared mutable state, one at a timeArc<Mutex<T>>
Parallel iteration over datarayon::par_iter
Async I/O (network, files)tokio or async-std

Common Pitfalls and How Rust Stops Them

Data Race — Stopped at Compile Time

A data race happens when two threads access the same memory simultaneously and at least one is writing. In C or C++, this causes silent, unpredictable bugs. In Rust, the ownership and Send/Sync system catches this before your code ever runs.

Use After Move — Stopped at Compile Time

Once you move a value into a thread, you cannot use it in the original scope. Rust's borrow checker enforces this. The thread owns the value exclusively — no one else can touch it during that time.

Forgotten Join — Logical Error, Not Compile Error

If you drop a JoinHandle without calling join, the thread gets detached and runs until it finishes or the program exits. This is valid code but often unintentional. Always store handles and join them explicitly when you care about the thread completing before moving on.

Key Takeaways

  • thread::spawn creates a new thread; JoinHandle::join waits for it to finish
  • move closures transfer ownership of captured variables into a thread
  • Channels (mpsc) let threads communicate by sending values, transferring ownership
  • Mutex<T> protects shared data by allowing only one thread to access it at a time
  • Arc<Mutex<T>> combines shared ownership with mutual exclusion for thread-safe state
  • The Send and Sync traits encode thread safety in the type system — wrong usage fails at compile time
  • Rust prevents data races at compile time but cannot prevent deadlocks — those require careful design
  • thread::scope safely borrows data across scoped threads without requiring move

Leave a Comment