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"| DATAuse 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();
}
| Type | Operation | When to use |
|---|---|---|
AtomicBool | load, store, swap | Stop/start flag, switch mode |
AtomicI32 / AtomicU32 | fetch_add, fetch_sub, compare_exchange | Simple counter |
AtomicUsize | fetch_add, fetch_sub | Counter, index |
AtomicPtr<T> | load, store, swap | Pointer 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+moveclosure — basic way to create a thread.movemoves ownership to threads because threads can survive longer than their original scope.- Always
join()important thread — withoutjoin(), the thread can be truncated when play ends. CollectJoinHandleand calljoin()at the end.Arc<Mutex<T>>for shared mutable state —Arcfor cross-thread shared ownership,Mutexfor exclusive access. Don’t forget thatMutexGuardis released when dropped.RwLockif many reads, few writes — more efficient thanMutexbecause many readers can be active simultaneously.- Atomic types for simple primitives —
AtomicUsize,AtomicBoolare much lighter thanMutexfor counters and flags.- Channel for communication, not shared memory —
mpsc::channel()is Rust’s idiomatic way: send data over a channel rather than sharing pointers.rayonfor data parallelism — replace.iter()with.par_iter()to process collections in parallel with automatic thread pooling.SendandSyncare compiler guarded — the compiler rejects code that tries to sendRcorRefCellto another thread. UseArcandMutex/RwLockinstead.- Avoid deadlocks — lock mutexes in a consistent order across all threads, or use
try_lock()with fallback instead oflock()which blocks forever.