Sync #
Concurrency is one of the most difficult domains in programming — data races, deadlocks, and race conditions are bugs that are hard to reproduce and even harder to debug. In other languages, developers rely on conventions and manual discipline to avoid these problems. Rust takes a fundamentally different approach: the compiler guarantees that no data races can pass to runtime. Not by prohibiting concurrency, but by making unsafe state sharing impossible to compile. This article discusses the concurrency primitives in the Rust standard library — Arc, Mutex, RwLock, channel mpsc, and type Atomic — as well as design patterns that ensure your concurrent code is definitively correct.
Why Rust Concurrency is Different #
In Rust, two traits determine whether a type can be used concurrently:
Send— type that can be moved to another thread. Almost all types ofSend, except those containing raw pointers or non-thread-safe references such asRc<T>.Sync— type that can be referenced from another thread (i.e.&TisSend). The typeSyncis safe to access from multiple threads simultaneously.
These two traits are implemented automatically by the compiler based on type composition. If you try to send a type that is not Send to another thread, the code will not compile.
use std::thread;
use std::rc::Rc;
use std::sync::Arc;
fn main() {
// ANTI-PATTERN: Rc does not Send — cannot be sent to another thread
let rc = Rc::new(42);
// thread::spawn(move || println!("{}", rc));
// ERROR: `Rc<i32>` cannot be sent between threads safely
// CORRECT: Arc is the thread-safe version of Rc
let arc = Arc::new(42);
let arc_clone = Arc::clone(&arc);
thread::spawn(move || {
println!("Di thread lain: {}", arc_clone);
}).join().unwrap();
println!("Di main thread: {}", arc);
}
flowchart TD
A[Tipe T] --> B{Implements Send?}
B -- Yes --> C{Implements Sync?}
B -- No --> D["Tidak bisa dipindah ke thread lain\nContoh: Rc<T>, *mut T"]
C -- Yes --> E["Aman untuk shared reference antar thread\nContoh: Arc<T>, Mutex<T>"]
C -- No --> F["Bisa dipindah tapi tidak di-share\nContoh: Cell<T>, RefCell<T>"]
style D fill:#ffebee
style E fill:#e8f5e9
style F fill:#fff3e0Threads — Creating and Managing #
Standard library provides std::thread for creating OS threads.
use std::thread;
use std::time::Duration;
fn main() {
// Create thread — closure is moved to a new thread
let handle = thread::spawn(|| {
for i in 0..5 {
println!("Thread anak: {}", i);
thread::sleep(Duration::from_millis(10));
}
});
// Main thread continues to run
for i in 0..3 {
println!("Main thread: {}", i);
thread::sleep(Duration::from_millis(15));
}
// join() — waits for thread to complete, returns Result
handle.join().unwrap();
// Send data to thread with move closure
let data = vec![1, 2, 3, 4, 5];
let handle = thread::spawn(move || {
// data is moved into the thread
let jumlah: i32 = data.iter().sum();
jumlah // return value of the thread
});
let hasil = handle.join().unwrap(); // takes the return value
println!("Jumlah: {}", hasil);
// Thread with name — useful for debugging
let handle = thread::Builder::new()
.name("worker-thread".to_string())
.stack_size(4 * 1024 * 1024) // 4MB stack
.spawn(|| {
println!("Thread: {:?}", thread::current().name());
})
.unwrap();
handle.join().unwrap();
// Runs multiple threads at once
let handles: Vec<_> = (0..5).map(|i| {
thread::spawn(move || {
println!("Worker {} selesai", i);
i * i // return value
})
}).collect();
let hasil: Vec<i32> = handles.into_iter()
.map(|h| h.join().unwrap())
.collect();
println!("{:?}", hasil); // [0, 1, 4, 9, 16]
}
Arc<T> — Shared Ownership Between Threads #
Arc<T> (Atomically Reference Counted) is a thread-safe version of Rc<T>. It allows multiple threads to have references to the same data, with reference counts managed atomically.
use std::sync::Arc;
use std::thread;
fn main() {
// Arc allows shared ownership between threads
let data = Arc::new(vec![1, 2, 3, 4, 5]);
let mut handles = vec![];
for i in 0..3 {
let data_clone = Arc::clone(&data); // clone Arc, not data
let handle = thread::spawn(move || {
println!("Thread {}: jumlah = {}", i, data_clone.iter().sum::<i32>());
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
// data is still valid here
println!("Main: {:?}", data);
// Arc is only for immutable shared data
// For mutable shared data, combine with Mutex
let counter = Arc::new(std::sync::Mutex::new(0i32));
let handles: Vec<_> = (0..5).map(|_| {
let counter = Arc::clone(&counter);
thread::spawn(move || {
let mut nilai = counter.lock().unwrap();
*nilai += 1;
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
println!("Counter akhir: {}", *counter.lock().unwrap()); // 5
}
Arc::clone()is more efficient than regular.clone()— it only increments the reference count atomically, does not copy data. UseArc::clone(&arc)instead ofarc.clone()so that the intent is clear in the code.
Mutex<T> — Mutual Exclusion for Mutable State #
Mutex<T> (Mutual Exclusion) ensures that only one thread can access the data at a time. In Rust, data is guarded inside the Mutex — you can’t access the data without locking the Mutex first.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Mutex wraps data — cannot be accessed without lock
let mutex = Mutex::new(0i32);
// lock() returns MutexGuard — released automatically when exiting the scope
{
let mut nilai = mutex.lock().unwrap(); // blocks until lock is available
*nilai += 1;
} // MutexGuard is dropped here — lock removed
println!("{}", mutex.lock().unwrap()); // 1
// General pattern: Arc<Mutex<T>> for shared mutable state
let shared = Arc::new(Mutex::new(Vec::<i32>::new()));
let handles: Vec<_> = (0..5).map(|i| {
let shared = Arc::clone(&shared);
thread::spawn(move || {
let mut data = shared.lock().unwrap();
data.push(i);
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
let data = shared.lock().unwrap();
let mut hasil = data.clone();
drop(data); // release lock before further operation
hasil.sort();
println!("{:?}", hasil); // [0, 1, 2, 3, 4] — order is not guaranteed without sort
}
Avoid Deadlock #
Deadlock occurs when two or more threads are waiting for a lock held by another thread. Rust can’t prevent deadlocks at compile time, but there are patterns that help.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let mutex_a = Arc::new(Mutex::new(0i32));
let mutex_b = Arc::new(Mutex::new(0i32));
// ANTI-PATTERN: different lock sequences between threads — potential deadlock
let a = Arc::clone(&mutex_a);
let b = Arc::clone(&mutex_b);
let t1 = thread::spawn(move || {
let _lock_a = a.lock().unwrap(); // take A
thread::sleep(std::time::Duration::from_millis(1));
let _lock_b = b.lock().unwrap(); // wait for B — can deadlock if t2 holds B
});
let a = Arc::clone(&mutex_a);
let b = Arc::clone(&mutex_b);
let t2 = thread::spawn(move || {
let _lock_b = b.lock().unwrap(); // take B
thread::sleep(std::time::Duration::from_millis(1));
let _lock_a = a.lock().unwrap(); // wait for A — can deadlock if t1 holds A
});
// TRUE: always locks in the same order across all threads
let a = Arc::clone(&mutex_a);
let b = Arc::clone(&mutex_b);
let t3 = thread::spawn(move || {
let _lock_a = a.lock().unwrap(); // is always A first
let _lock_b = b.lock().unwrap(); // new B
});
// CORRECT: release the lock as soon as possible — minimize time in the critical section
let mutex = Arc::new(Mutex::new(0i32));
let m = Arc::clone(&mutex);
thread::spawn(move || {
// ANTI-PATTERN: long computation in lock
let mut nilai = m.lock().unwrap();
let hasil = komputasi_berat(); // blocks other threads during this time
*nilai = hasil;
// CORRECT: computing out of lock
let hasil = komputasi_berat();
let mut nilai = m.lock().unwrap();
*nilai = hasil; // lock is for assignment only
});
// try_lock — try lock without blocking
let mutex = Mutex::new(42);
match mutex.try_lock() {
Ok(nilai) => println!("Berhasil lock: {}", *nilai),
Err(_) => println!("Lock sedang dipakai, coba lagi nanti"),
}
t1.join().unwrap();
t2.join().unwrap();
t3.join().unwrap();
}
fn komputasi_berat() -> i32 { 42 }
RwLock<T> — Multiple Readers, One Writer #
RwLock<T> (Read-Write Lock) allows multiple readers or one writer simultaneously. It is more efficient than Mutex for scenarios where read operations are much more frequent than write operations.
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let data = Arc::new(RwLock::new(vec![1, 2, 3, 4, 5]));
// Multiple readers can run simultaneously
let handles: Vec<_> = (0..4).map(|i| {
let data = Arc::clone(&data);
thread::spawn(move || {
let baca = data.read().unwrap(); // read lock — can be used simultaneously with other readers
println!("Reader {}: jumlah = {}", i, baca.iter().sum::<i32>());
// read lock is released when reading out of the scope
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
// Only one writer at a time
{
let mut tulis = data.write().unwrap(); // write lock — exclusive
tulis.push(6);
println!("Setelah tulis: {:?}", *tulis);
} // write lock removed
// Read again after writing
println!("Hasil akhir: {:?}", *data.read().unwrap());
// When is RwLock better than Mutex?
// RwLock is good for: caches, configurations, registries that are often read but rarely changed
// Mutex is simpler for: counters, queues, frequently modified states
}
sequenceDiagram
participant T1 as Thread 1 (Reader)
participant T2 as Thread 2 (Reader)
participant T3 as Thread 3 (Writer)
participant RW as RwLock
T1->>RW: read()
RW-->>T1: ReadGuard ✓
T2->>RW: read()
RW-->>T2: ReadGuard ✓ (bersamaan dengan T1)
T3->>RW: write()
Note over T3,RW: Menunggu T1 dan T2 selesai
T1->>RW: drop ReadGuard
T2->>RW: drop ReadGuard
RW-->>T3: WriteGuard ✓ (sekarang eksklusif)
T3->>RW: drop WriteGuardChannel mpsc — Inter-Thread Communication #
Channel is a message passing-based communication mechanism — threads communicate by sending data, not sharing state. This is often easier to reason about than shared state.
mpsc is an abbreviation for multiple producer, single consumer — many senders, one receiver.
use std::sync::mpsc;
use std::thread;
fn main() {
// Create channels — tx to send, rx to receive
let (tx, rx) = mpsc::channel();
// Posted from another thread
let tx_clone = tx.clone(); // clone sender for multiple producers
thread::spawn(move || {
tx_clone.send(String::from("pesan dari thread 1")).unwrap();
tx_clone.send(String::from("pesan kedua dari thread 1")).unwrap();
});
thread::spawn(move || {
tx.send(String::from("pesan dari thread 2")).unwrap();
});
// Receive all messages — recv() blocks until there is a message or all senders are dropped
for pesan in rx {
println!("Diterima: {}", pesan);
}
// The loop ends when all senders are dropped
}
Producer-Consumer Pattern #
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
#[derive(Debug)]
enum Tugas {
Proses(String),
Selesai,
}
fn main() {
let (tx, rx) = mpsc::channel::<Tugas>();
// Worker thread — consumer
let worker = thread::spawn(move || {
let mut diproses = 0;
loop {
match rx.recv().unwrap() {
Tugas::Proses(data) => {
println!("Memproses: {}", data);
thread::sleep(Duration::from_millis(10)); // work simulation
diproses += 1;
}
Tugas::Selesai => {
println!("Worker selesai, total diproses: {}", diproses);
break;
}
}
}
});
// Producer — submit tasks
for i in 0..5 {
tx.send(Tugas::Proses(format!("data-{}", i))).unwrap();
}
tx.send(Tugas::Selesai).unwrap(); // stop signal
worker.join().unwrap();
// Bounded channel — sync_channel
let (tx, rx) = mpsc::sync_channel::<i32>(3); // buffers a maximum of 3 messages
thread::spawn(move || {
for i in 0..10 {
println!("Mengirim {}", i);
tx.send(i).unwrap(); // blocks if buffer is full
}
});
for pesan in rx {
thread::sleep(Duration::from_millis(50)); // consumer is slower
println!("Diterima: {}", pesan);
}
}
try_recv and recv_timeout #
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel::<i32>();
thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
tx.send(42).unwrap();
});
// try_recv — does not block, returns immediately
match rx.try_recv() {
Ok(nilai) => println!("Diterima: {}", nilai),
Err(mpsc::TryRecvError::Empty) => println!("Belum ada pesan"),
Err(mpsc::TryRecvError::Disconnected) => println!("Sender sudah ter-drop"),
}
// recv_timeout — block with timeout
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(nilai) => println!("Diterima dalam timeout: {}", nilai),
Err(mpsc::RecvTimeoutError::Timeout) => println!("Timeout!"),
Err(mpsc::RecvTimeoutError::Disconnected) => println!("Disconnected"),
}
}
Atomic — Lock-Free Operation #
Type Atomic allows thread-safe read-write operations without locks. They are implemented using special CPU instructions (compare-and-swap, fetch-and-add) that are guaranteed to be atomic.
use std::sync::atomic::{AtomicI32, AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
fn main() {
// AtomicI32 — counter without Mutex
let counter = Arc::new(AtomicI32::new(0));
let handles: Vec<_> = (0..10).map(|_| {
let counter = Arc::clone(&counter);
thread::spawn(move || {
// fetch_add — add and return the old value, atomic
counter.fetch_add(1, Ordering::SeqCst);
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
println!("Counter: {}", counter.load(Ordering::SeqCst)); // 10
// AtomicBool — thread-safe flag
let berjalan = Arc::new(AtomicBool::new(true));
let berjalan_clone = Arc::clone(&berjalan);
let worker = thread::spawn(move || {
let mut iterasi = 0;
while berjalan_clone.load(Ordering::Relaxed) {
iterasi += 1;
thread::sleep(std::time::Duration::from_millis(1));
}
println!("Worker berhenti setelah {} iterasi", iterasi);
});
thread::sleep(std::time::Duration::from_millis(10));
berjalan.store(false, Ordering::Relaxed); // stop signal
worker.join().unwrap();
// AtomicUsize — Secure generator ID
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn generate_id() -> usize {
COUNTER.fetch_add(1, Ordering::SeqCst)
}
let handles: Vec<_> = (0..5).map(|_| {
thread::spawn(|| generate_id())
}).collect();
let ids: Vec<usize> = handles.into_iter().map(|h| h.join().unwrap()).collect();
println!("{:?}", ids); // Each ID is unique, but the order is uncertain
}
Memory Ordering #
Memory ordering determines how atomic operations interact with other memory operations around them.
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
fn main() {
// Ordering::Relaxed — atomicity only, no ordering guarantee
// Suitable for: simple counters that do not depend on other values
let counter = AtomicI32::new(0);
counter.fetch_add(1, Ordering::Relaxed);
// Ordering::Release + Acquire — synchronization between producer and consumer
// Release: all previous writes are visible to the thread that performed the Acquire
// Suitable for: flag indicating data is ready
static DATA_SIAP: AtomicBool = AtomicBool::new(false);
static mut DATA: i32 = 0;
// Producer thread
// unsafe { DATA = 42; } // write data
// DATA_SIAP.store(true, Ordering::Release); // mark ready
// Consumer thread
// while !DATA_SIAP.load(Ordering::Acquire) { } // wait until ready
// unsafe { println!("{}", DATA); } // safe to read now
// Ordering::SeqCst — total, strongest and most secure ordering
// Suitable for: when unsure, or when correctness is more important than performance
counter.store(0, Ordering::SeqCst);
counter.fetch_add(1, Ordering::SeqCst);
println!("{}", counter.load(Ordering::SeqCst));
}
| Ordering | Strength | Performance | When to Use |
|---|---|---|---|
Relaxed | Lowest | Best | Simple counter without dependencies |
Acquire | Medium | Good | Read the flag indicating data is ready |
Release | Medium | Good | Write flag once data is ready |
AcqRel | Medium | Good | Read-modify-write(fetch_add, compare_exchange) |
SeqCst | Highest | Slowest | Safe default, when unsure |
For most cases, use Ordering::SeqCst — it’s easiest to reason about and the performance difference is only significant on hot paths with millions of operations per second. Optimize to weaker ordering only after profiling has proven necessary.Concurrency Design Patterns #
Simple Thread Pool with Channels #
use std::sync::{Arc, Mutex};
use std::sync::mpsc;
use std::thread;
type Job = Box<dyn FnOnce() + Send + 'static>;
struct ThreadPool {
workers: Vec<thread::JoinHandle<()>>,
sender: mpsc::Sender<Option<Job>>,
}
impl ThreadPool {
fn new(ukuran: usize) -> Self {
let (sender, receiver) = mpsc::channel::<Option<Job>>();
let receiver = Arc::new(Mutex::new(receiver));
let workers = (0..ukuran).map(|id| {
let receiver = Arc::clone(&receiver);
thread::spawn(move || loop {
let pesan = receiver.lock().unwrap().recv().unwrap();
match pesan {
Some(job) => {
println!("Worker {} mengerjakan tugas", id);
job();
}
None => {
println!("Worker {} berhenti", id);
break;
}
}
})
}).collect();
ThreadPool { workers, sender }
}
fn execute<F: FnOnce() + Send + 'static>(&self, f: F) {
self.sender.send(Some(Box::new(f))).unwrap();
}
fn shutdown(self) {
for _ in &self.workers {
self.sender.send(None).unwrap(); // sends a stop signal to each worker
}
for worker in self.workers {
worker.join().unwrap();
}
}
}
fn main() {
let pool = ThreadPool::new(4);
for i in 0..8 {
pool.execute(move || {
println!("Tugas {} selesai di thread {:?}", i, thread::current().id());
});
}
pool.shutdown();
}
Shared Cache with RwLock #
use std::sync::{Arc, RwLock};
use std::collections::HashMap;
use std::thread;
struct Cache {
data: RwLock<HashMap<String, String>>,
}
impl Cache {
fn new() -> Arc<Self> {
Arc::new(Cache {
data: RwLock::new(HashMap::new()),
})
}
fn get(&self, kunci: &str) -> Option<String> {
self.data.read().unwrap().get(kunci).cloned()
}
fn set(&self, kunci: String, nilai: String) {
self.data.write().unwrap().insert(kunci, nilai);
}
fn get_or_compute(&self, kunci: &str, hitung: impl FnOnce() -> String) -> String {
// Check with read lock first (fast)
if let Some(nilai) = self.get(kunci) {
return nilai;
}
// If none, calculate and save
let nilai = hitung();
self.set(kunci.to_string(), nilai.clone());
nilai
}
}
fn main() {
let cache = Cache::new();
// Many readers simultaneously
let handles: Vec<_> = (0..5).map(|i| {
let cache = Arc::clone(&cache);
thread::spawn(move || {
let kunci = format!("kunci-{}", i % 3);
let nilai = cache.get_or_compute(&kunci, || {
println!("Menghitung untuk {}", kunci);
format!("nilai-{}", i)
});
println!("Thread {}: {} = {}", i, kunci, nilai);
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
}
Once — Initialize Once #
use std::sync::Once;
use std::thread;
static INIT: Once = Once::new();
static mut KONFIGURASI: Option<String> = None;
fn dapatkan_konfigurasi() -> &'static str {
INIT.call_once(|| {
// Executes only once, even if called from multiple threads
println!("Menginisialisasi konfigurasi...");
unsafe {
KONFIGURASI = Some(String::from("konfigurasi-global"));
}
});
unsafe { KONFIGURASI.as_ref().unwrap() }
}
fn main() {
let handles: Vec<_> = (0..5).map(|_| {
thread::spawn(|| {
println!("Konfigurasi: {}", dapatkan_konfigurasi());
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
// "Initializing configuration..." appears only once
}
Selecting Appropriate Concurrency Primitives #
Gunakan Arc<Mutex<T>> jika:
✓ Data perlu dimodifikasi dari banyak thread
✓ Operasi baca dan tulis sama seringnya
✓ Seksi kritis singkat (kurang dari beberapa mikrodetik)
Gunakan Arc<RwLock<T>> jika:
✓ Data jauh lebih sering dibaca dari dimodifikasi
✓ Operasi baca bisa berjalan lama (tidak apa-apa reader lain ikut)
✓ Contoh: cache, konfigurasi, registry
Gunakan mpsc channel jika:
✓ Thread berkomunikasi dengan mengirim pesan
✓ Ownership data perlu berpindah antar thread (bukan shared)
✓ Pola producer-consumer yang jelas
✓ Ingin menghindari shared state sepenuhnya
Gunakan Atomic jika:
✓ Operasinya sederhana: increment, flag, ID generator
✓ Perlu performa maksimum tanpa overhead lock
✓ Tidak ada dependensi antar field yang berbeda
Hindari shared mutable state jika:
✗ Komunikasi antar thread bisa digantikan message passing
✗ Data bisa di-partition — setiap thread punya datanya sendiri
✗ Hasil bisa dikumpulkan setelah thread selesai (join + merge)
flowchart TD
A{Data perlu dibagi antar thread?} -- Yes --> B{Perlu dimodifikasi?}
A -- No --> C[Arc<T> only — shared immutable]
B -- Yes --> D{Frekuensi baca vs tulis?}
B -- No --> C
D -->|Baca >> Tulis| E[Arc<RwLock<T>><br/>Cache, configuration]
D -->|Seimbang| F[Arc<Mutex<T>><br/>Counter, queue]
D -->|Operasi sederhana| G[Atomic<br/>Counter, flag, ID]
A -- No --> H{Thread perlu berkomunikasi?}
H -- Yes --> I["mpsc channel\nProducer-consumer"]
H -- No --> J["Thread independen\nJoin + collect hasil"]
style C fill:#e8f5e9
style E fill:#e3f2fd
style F fill:#fff3e0
style G fill:#e8f5e9
style I fill:#fce4ecSummary #
SendandSyncare compile-time guarantees — the compiler ensures that types that are not thread-safe cannot be sent to another thread. This is the foundation of Rust’s “fearless concurrency.”Arc<T>for shared ownership — thread-safe version ofRc<T>. UseArc::clone(&arc)instead ofarc.clone()so that the intention is clear.Arcitself is only for immutable data.Arc<Mutex<T>>for shared mutable state —Mutexguarantees that data can only be accessed by one thread.MutexGuardreleases automatically when exiting the scope — take advantage of this to minimize lock time.Arc<RwLock<T>>for read-heavy workloads — many readers can run simultaneously, only writers exclusively. More efficient thanMutexif the read to write ratio is much higher.mpscchannel for message passing — send data between threads with ownership transfer, not sharing. This pattern eliminates the need for locks and is easier to reason with.Atomicfor simple lock-free operations —AtomicI32,AtomicBool,AtomicUsizefor counters, flags, and ID generators without Mutex overhead. UseSeqCstas the default ordering.- Avoid deadlocks with consistent lock order — always lock multiple mutexes in the same order across all threads. Minimize code in critical sections.
- Choose message passing first — if data can be communicated over channels rather than shared, it’s usually a simpler design and easier to maintain.