Multi Threading #

One of Rust’s biggest claims is fearless concurrency — you can write multi-threaded code without worrying about data races, because the compiler rejects them at compile time, not at runtime. This isn’t just a slogan: Rust’s ownership system makes the most difficult to track categories of bugs in C++ and Java — race conditions, use-after-free in another thread, iterator invalidation — into common compilation errors. This article discusses standard thread primitives (thread::spawn, Arc, Mutex, channel), atomic types for simple states, easy parallelization with rayon, and the Send and Sync traits that are the foundation of Rust’s concurrent security.

Basic Thread #

std::thread::spawn creates a new OS thread. The thread executes the closure passed to it:

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

fn main() {
    // Spawn thread — without join, may not finish before main ends
    let handle = thread::spawn(|| {
        for i in 1..=5 {
            println!("[thread] iterasi {}", i);
            thread::sleep(Duration::from_millis(10));
        }
    });

    // Main thread continues to run concurrently
    for i in 1..=3 {
        println!("[main] iterasi {}", i);
        thread::sleep(Duration::from_millis(15));
    }

    // join() — blocks main until thread completes
    // Without this, the thread could be cut off when playing out
    handle.join().unwrap();
    println!("Semua selesai");
}

move Closure — Move Data to Thread #

A thread requires all the data it uses to be alive while the thread is running. Because threads can outlive their original scope, closures must own the data — not just borrow it. The move keyword moves ownership to closure:

use std::thread;

fn main() {
    let pesan = String::from("Halo dari thread!");

    // ANTI-PATTERN: normal borrow cannot cross thread boundaries
    // let handle = thread::spawn(|| println!("{}", message));
    // error: closure may outlive the current function but it borrows `message`

    // CORRECT: move moves ownership to closure
    let handle = thread::spawn(move || {
        println!("{}", pesan);  // message is owned by this closure now
    });

    // println!("{}", message);  // error: message has been moved

    handle.join().unwrap();
}

Spawn Many Threads and Collect the Results #

use std::thread;

fn main() {
    let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
    let mut handles = Vec::new();

    // Split data across multiple threads for parallel processing
    for chunk in data.chunks(2) {
        let chunk = chunk.to_vec();  // makes a copy to move
        let handle = thread::spawn(move || {
            let jumlah: i32 = chunk.iter().sum();
            println!("Chunk {:?} → jumlah {}", chunk, jumlah);
            jumlah
        });
        handles.push(handle);
    }

    // Collect results from all threads
    let total: i32 = handles.into_iter()
        .map(|h| h.join().unwrap())
        .sum();

    println!("Total keseluruhan: {}", total);
}

Data Sharing: Arc<T> and Mutex<T> #

Arc<T> (Atomic Reference Counted) allows multiple threads to have shared ownership of the same data. Mutex<T> ensures only one thread accesses its data at a time:

flowchart TD
    subgraph Heap
        ARC["Arc<Mutex<Data>>\nref_count = 3"]
        DATA["Data\n(terlindungi Mutex)"]
        ARC --> DATA
    end

    T1["Thread 1\nArc::clone"] --> ARC
    T2["Thread 2\nArc::clone"] --> ARC
    T3["Thread 3\nArc::clone"] --> ARC

    T1 -.->|"lock() → akses eksklusif"| DATA
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    // Arc: shared ownership | Mutex: synchronized access
    let counter = Arc::new(Mutex::new(0i32));
    let mut handles = vec![];

    for id in 0..10 {
        let counter = Arc::clone(&counter);  // adds ref count, does not copy data
        let handle = thread::spawn(move || {
            let mut angka = counter.lock().unwrap();  // mutex lock, wait if in use
            *angka += 1;
            println!("Thread {}: counter sekarang {}", id, *angka);
            // number (MutexGuard) is dropped here → mutex is automatically released
        });
        handles.push(handle);
    }

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

    println!("Nilai akhir: {}", *counter.lock().unwrap());  // 10
}

Deadlock — The Main Pitfall of Mutex #

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

fn main() {
    let a = Arc::new(Mutex::new(1));
    let b = Arc::new(Mutex::new(2));

    let a1 = Arc::clone(&a);
    let b1 = Arc::clone(&b);

    // ANTI-PATTERN: classic deadlock — two threads lock in different order
    // Thread 1: lock a first, then b
    // Thread 2: key b first, then a
    // Both of them waited forever

    // TRUE: always lock mutexes in consistent order across all threads
    // Or use try_lock() with fallback

    // Example of safe lock: always lock a before b
    let handle1 = thread::spawn(move || {
        let _ga = a1.lock().unwrap();
        thread::sleep(std::time::Duration::from_millis(1));
        let _gb = b1.lock().unwrap();
        println!("Thread 1 selesai");
    });

    let _ga = a.lock().unwrap();
    let _gb = b.lock().unwrap();
    println!("Main selesai");
    drop(_ga); drop(_gb);

    handle1.join().unwrap();
}

RwLock — Many Readers, One Writer #

RwLock<T> is more efficient than Mutex when read operations are much more frequent than writes:

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

fn main() {
    let data = Arc::new(RwLock::new(vec![1, 2, 3, 4, 5]));
    let mut handles = vec![];

    // Multiple reader threads can run simultaneously
    for i in 0..5 {
        let data = Arc::clone(&data);
        handles.push(thread::spawn(move || {
            let baca = data.read().unwrap();  // read lock — can be many at once
            println!("Reader {}: {:?}", i, *baca);
        }));
    }

    // One author thread — wait for all readers to finish
    {
        let data = Arc::clone(&data);
        handles.push(thread::spawn(move || {
            let mut tulis = data.write().unwrap();  // write lock — exclusive
            tulis.push(6);
            println!("Writer menambah 6");
        }));
    }

    for h in handles { h.join().unwrap(); }
    println!("Hasil akhir: {:?}", *data.read().unwrap());
}

Atomic Type — Simple State Without Mutex #

For primitive values that only need to be incremented, decremented, or swapped, the atomic type of std::sync::atomic is much lighter than Mutex:

use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, AtomicBool, Ordering};
use std::thread;

fn main() {
    let counter = Arc::new(AtomicUsize::new(0));
    let berhenti = Arc::new(AtomicBool::new(false));
    let mut handles = vec![];

    // 10 thread increment counter atomically — no Mutex
    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            // fetch_add: atomic add and return old value
            counter.fetch_add(1, Ordering::SeqCst);
        }));
    }

    for h in handles { h.join().unwrap(); }
    println!("Counter: {}", counter.load(Ordering::SeqCst));  // 10

    // AtomicBool as a safe stop flag between threads
    let berhenti2 = Arc::clone(&berhenti);
    let worker = thread::spawn(move || {
        let mut i = 0;
        while !berhenti2.load(Ordering::Relaxed) {
            i += 1;
            thread::sleep(std::time::Duration::from_millis(1));
        }
        println!("Worker berhenti setelah {} iterasi", i);
    });

    thread::sleep(std::time::Duration::from_millis(50));
    berhenti.store(true, Ordering::Relaxed);
    worker.join().unwrap();
}
TypeOperationWhen to use
AtomicBoolload, store, swapStop/start flag, switch mode
AtomicI32 / AtomicU32fetch_add, fetch_sub, compare_exchangeSimple counter
AtomicUsizefetch_add, fetch_subCounter, index
AtomicPtr<T>load, store, swapPointer to data (low level)

Channel — Inter-Thread Communication #

Channel is Rust’s idiomatic way of communicating between threads: “don’t communicate by sharing memory, share memory by communicating”. The Rust standard library provides MPSC (Multiple Producer, Single Consumer):

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

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

    // Producer thread — send some messages
    let tx1 = tx.clone();
    thread::spawn(move || {
        let pesan = ["halo", "dunia", "dari", "thread"];
        for p in pesan {
            tx1.send(p.to_string()).unwrap();
            thread::sleep(Duration::from_millis(10));
        }
        // tx1 dropped when thread finished → channel knows this producer is closed
    });

    // Second producer — MPSC: multiple producers
    thread::spawn(move || {
        for i in 1..=3 {
            tx.send(format!("angka-{}", i)).unwrap();
            thread::sleep(Duration::from_millis(15));
        }
    });

    // Receiver — iterates until all producers close
    for pesan in rx {  // rx.recv() in loop, stops when channel closed
        println!("Diterima: {}", pesan);
    }

    println!("Semua producer selesai");
}

Channel with Bounded Buffer #

Standard library only provides unbounded channels. For bounded channels (backpressure), use sync_channel:

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

fn main() {
    // sync_channel with buffer 3 — sender will block if buffer is full
    let (tx, rx) = mpsc::sync_channel::<u32>(3);

    thread::spawn(move || {
        for i in 0..10 {
            println!("Kirim {}", i);
            tx.send(i).unwrap();  // blocks if buffer is full (capacity 3)
        }
    });

    for val in rx {
        thread::sleep(std::time::Duration::from_millis(50));  // slow process simulation
        println!("Proses: {}", val);
    }
}

Data Parallelization with rayon #

For the task of data parallelism — processing collection elements in parallel — crate rayon is much easier than managing threads manually:

[dependencies]
rayon = "1"
use rayon::prelude::*;

fn hitung_prima(n: u64) -> bool {
    if n < 2 { return false; }
    (2..=(n as f64).sqrt() as u64).all(|i| n % i != 0)
}

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

    // Sequential — one by one
    let t = std::time::Instant::now();
    let prima_seq: Vec<u64> = angka.iter()
        .copied()
        .filter(|&n| hitung_prima(n))
        .collect();
    println!("Sequential: {} prima dalam {:?}", prima_seq.len(), t.elapsed());

    // Parallel — just replace .iter() with .par_iter()
    let t = std::time::Instant::now();
    let prima_par: Vec<u64> = angka.par_iter()  // ← one keyword!
        .copied()
        .filter(|&n| hitung_prima(n))
        .collect();
    println!("Parallel:   {} prima dalam {:?}", prima_par.len(), t.elapsed());

    // par_iter also supports map, filter, sum, etc.
    let total: u64 = (1u64..=1_000_000).into_par_iter()
        .filter(|&n| hitung_prima(n))
        .sum();
    println!("Jumlah semua prima: {}", total);
}

rayon uses a thread pool that is automatically configured based on the number of CPUs available. No need to manage threads manually.


Trait Send and Sync — Foundations of Thread Safety #

These two trait markers are the mechanisms that allow the Rust compiler to verify thread safety:

flowchart TD
    Send["Send\nTipe bisa dipindah ke thread lain\nMost types: ✓\nRc<T>: ✗ (gunakan Arc<T>)\n*mut T: ✗ (raw pointer)"]
    Sync["Sync\nTipe bisa diakses dari banyak thread\nT Sync jika &T Send\nMutex<T>: ✓\nCell<T>, RefCell<T>: ✗"]
use std::sync::{Arc, Mutex};
use std::rc::Rc;  // Rc does not Send — not thread-safe

fn butuh_send<T: Send>(_: T) {}
fn butuh_sync<T: Sync>(_: &T) {}

fn main() {
    let owned = String::from("aman");
    butuh_send(owned);  // ✓ String: Send

    // Rc does not Send — cannot be sent to another thread
    let rc = Rc::new(42);
    // need_send(rc);  // error: Rc<i32> cannot be sent between threads safely

    // Arc is Send — use Arc instead of Rc in multi-threaded code
    let arc = Arc::new(42);
    butuh_send(arc);  // ✓

    // Mutex<T> is Sync if T: Send
    let mutex = Mutex::new(String::new());
    butuh_sync(&mutex);  // ✓

    // ANTI-PATTERN: attempted to send RefCell to another thread
    use std::cell::RefCell;
    let rc_ref = RefCell::new(0);
    // thread::spawn(move || { rc_ref.borrow_mut(); });
    // error: RefCell<i32> cannot be sent between threads safely
}

Summary: Choose the Right Primitives #

Situasi                          → Solusi

Shared immutable data            → Arc<T>
Shared mutable data              → Arc<Mutex<T>>
Banyak baca, jarang tulis        → Arc<RwLock<T>>
Counter / flag sederhana         → AtomicUsize / AtomicBool
Kirim data antar thread          → mpsc::channel
Kirim data dengan backpressure   → mpsc::sync_channel
Data parallelism mudah           → rayon::par_iter()
Koordinasi banyak thread         → Barrier, Condvar

Summary #

  • thread::spawn + move closure — basic way to create a thread. move moves ownership to threads because threads can survive longer than their original scope.
  • Always join() important thread — without join(), the thread can be truncated when play ends. Collect JoinHandle and call join() at the end.
  • Arc<Mutex<T>> for shared mutable stateArc for cross-thread shared ownership, Mutex for exclusive access. Don’t forget that MutexGuard is released when dropped.
  • RwLock if many reads, few writes — more efficient than Mutex because many readers can be active simultaneously.
  • Atomic types for simple primitivesAtomicUsize, AtomicBool are much lighter than Mutex for counters and flags.
  • Channel for communication, not shared memorympsc::channel() is Rust’s idiomatic way: send data over a channel rather than sharing pointers.
  • rayon for data parallelism — replace .iter() with .par_iter() to process collections in parallel with automatic thread pooling.
  • Send and Sync are compiler guarded — the compiler rejects code that tries to send Rc or RefCell to another thread. Use Arc and Mutex/RwLock instead.
  • Avoid deadlocks — lock mutexes in a consistent order across all threads, or use try_lock() with fallback instead of lock() which blocks forever.


← Previous: Crates   Next: I/O →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact