Lewati ke konten

Shared State di Rust: RwLock, Atomic, dan Scoped Thread

Di halaman Konkurensi, kamu berbagi sebuah counter antar-thread dengan Arc<Mutex<T>>: Arc membuat banyak thread bisa memiliki nilai itu, dan Mutex memastikan hanya satu thread yang menyentuhnya pada satu waktu. Di halaman Channel, kamu malah menghindari berbagi data sama sekali dengan saling mengirim pesan. Keduanya alat yang hebat, dan kamu bisa membangun hampir apa saja dengan keduanya.

Tapi bayangkan Mutex seperti satu-satunya kunci toilet di kantor. Memang berhasil, tapi berlebihan kalau yang kamu mau cuma membaca papan pengumuman di lorong. Standard library Rust punya beberapa alat lain, dan masing-masing lebih cocok untuk pekerjaan tertentu. Di halaman ini kamu akan berkenalan dengan empat di antaranya: scoped thread, RwLock, atomic, dan OnceLock/LazyLock. Kamu juga akan belajar cara menghindari satu bug yang tidak bisa ditangkap compiler untukmu: deadlock.

Shared state (state bersama) artinya data yang dipakai bersama oleh beberapa thread sekaligus. Itulah topik halaman ini.

Scoped thread: meminjam, bukan memindahkan

Section titled “Scoped thread: meminjam, bukan memindahkan”

Dengan thread::spawn, kamu biasanya butuh move dan sering juga Arc. Alasannya, thread yang di-spawn bisa saja masih berjalan setelah main sudah lanjut ke hal lain, jadi Rust tidak mengizinkannya meminjam variabel lokalmu.

std::thread::scope mengatasi ini dengan sebuah janji: setiap thread yang dimulai di dalam scope pasti selesai sebelum scope itu berakhir. Karena thread-thread itu tidak mungkin hidup lebih lama daripada datamu, mereka boleh langsung meminjamnya. Tanpa Arc, tanpa move, tanpa clone.

use std::thread;
fn main() {
let shop = "Toko Pojok";
let sales = vec![12, 7, 30, 5, 18, 22, 9, 14];
let (morning, afternoon) = sales.split_at(4);
let (am, pm) = thread::scope(|s| {
let am = s.spawn(|| morning.iter().sum::<i32>());
let pm = s.spawn(|| afternoon.iter().sum::<i32>());
(am.join().unwrap(), pm.join().unwrap())
});
println!("{shop}: pagi {am}, sore {pm}, sehari penuh {}", am + pm);
println!("masih milikku: {:?}", sales);
}

Output:

Toko Pojok: pagi 54, sore 63, sehari penuh 117
masih milikku: [12, 7, 30, 5, 18, 22, 9, 14]
▶ Coba sendiri

Inilah yang terjadi:

  • thread::scope(|s| { ... }) membuka scope-nya. Kamu memulai thread dengan s.spawn(...), bukan thread::spawn(...).
  • Kedua closure meminjam morning dan afternoon, yang hanyalah slice dari sales. Tidak ada yang disalin.
  • Apa pun yang dikembalikan closure milik scope (di sini sebuah tuple berisi dua jumlah) menjadi nilai dari thread::scope.
  • Setelah scope selesai, sales masih milik main, jadi kamu tetap bisa memakainya.

Bahkan kalau kamu lupa memanggil join(), scope tetap menunggu semua thread-nya di } penutup. Janji itulah yang membuat peminjaman ini aman.

Bayangkan papan menu di sebuah kafe. Banyak pelanggan bisa membacanya bersamaan, dan itu tidak masalah. Tapi saat pemiliknya mengganti harga, semua orang harus menunggu sebentar sampai papan barunya terpasang.

Itulah RwLock (“read-write lock”, kunci baca-tulis). Ada dua cara masuk:

  • read() memberimu read guard yang shared (bersama). Banyak thread bisa memegangnya pada saat yang sama.
  • write() memberimu write guard yang exclusive (eksklusif). Selama ada penulis yang memegangnya, tidak ada orang lain yang bisa membaca atau menulis.

Mutex tidak membedakan keduanya: bahkan dua pembaca pun harus bergantian. Berikut cache pengaturan yang dibaca oleh beberapa request handler, lalu diubah sekali oleh admin:

use std::sync::{Arc, RwLock};
use std::thread;
struct Settings {
theme: String,
max_upload_mb: u32,
}
fn main() {
let settings = Arc::new(RwLock::new(Settings {
theme: String::from("light"),
max_upload_mb: 10,
}));
// Empat request handler membaca pengaturan secara bersamaan.
let mut handles = Vec::new();
for id in 1..=4 {
let settings = Arc::clone(&settings);
handles.push(thread::spawn(move || {
let s = settings.read().unwrap(); // shared: thread lain juga bisa membaca
format!("request {id}: tema={}, batas={}MB", s.theme, s.max_upload_mb)
}));
}
for handle in handles {
println!("{}", handle.join().unwrap());
}
// Admin mengubah pengaturan sekali.
{
let mut s = settings.write().unwrap(); // exclusive: semua yang lain menunggu
s.theme = String::from("dark");
s.max_upload_mb = 25;
} // write guard di-drop di sini, jadi kuncinya bebas lagi
let s = settings.read().unwrap();
println!("setelah diubah: tema={}, batas={}MB", s.theme, s.max_upload_mb);
}

Output:

request 1: tema=light, batas=10MB
request 2: tema=light, batas=10MB
request 3: tema=light, batas=10MB
request 4: tema=light, batas=10MB
setelah diubah: tema=dark, batas=25MB
▶ Coba sendiri

Thread-thread itu bisa selesai dalam urutan apa pun, tapi output-nya selalu sama. Setiap thread mengembalikan barisnya alih-alih mencetaknya, dan main mencetak baris-baris itu sesuai urutan ia melakukan join.

Kapan RwLock lebih unggul daripada Mutex? Saat pembacaan sering terjadi, penulisan jarang, dan setiap pembacaan butuh waktu agak lama (misalnya menelusuri cache yang besar). Dalam kondisi itu, para pembaca benar-benar berjalan berdampingan.

Kapan tidak? Kalau pembacaannya sangat singkat, atau penulisan terjadi terus-menerus, RwLock hanyalah Mutex yang lebih rumit. Ia melakukan pencatatan tambahan, dan pembaca serta penulis kebanyakan tetap saling menunggu. Kalau tidak yakin, mulailah dengan Mutex. Lebih sederhana, dan kamu bisa beralih nanti kalau pengukuran menunjukkan bahwa kunci itu memang jadi bottleneck.

Untuk satu angka atau satu flag true/false, kunci pun sebenarnya berlebihan. Tipe atomic seperti AtomicUsize, AtomicI64, dan AtomicBool bisa diubah dengan aman oleh banyak thread sekaligus, tanpa kunci sama sekali. CPU menjalankan setiap operasinya dalam satu langkah yang tidak bisa dipecah, jadi tidak ada update yang hilang.

Di sini, 8 thread masing-masing mencatat 1000 tayangan halaman. Mereka juga menghitung vote: satu upvote, lalu tiga downvote, begitu terus berulang-ulang. Total vote-nya jadi negatif, karena itulah tipenya AtomicI64, bukan tipe unsigned:

use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
use std::thread;
fn main() {
let page_views = AtomicUsize::new(0);
let net_votes = AtomicI64::new(0);
thread::scope(|s| {
for _ in 0..8 {
s.spawn(|| {
for n in 0..1000 {
page_views.fetch_add(1, Ordering::Relaxed);
if n % 4 == 0 {
net_votes.fetch_add(1, Ordering::Relaxed); // upvote
} else {
net_votes.fetch_sub(1, Ordering::Relaxed); // downvote
}
}
});
}
}); // kedelapan thread sudah selesai di sini
println!("tayangan halaman: {}", page_views.load(Ordering::Relaxed));
println!("total vote: {}", net_votes.load(Ordering::Relaxed));
}

Output:

tayangan halaman: 8000
total vote: -4000
▶ Coba sendiri

Perhatikan bahwa page_views tidak mut. Atomic diubah lewat referensi bersama, dan justru karena itulah banyak thread bisa memakainya sekaligus. Method yang paling sering kamu pakai:

  • fetch_add(n, ordering) / fetch_sub(n, ordering) menambah atau mengurangi, lalu mengembalikan nilai yang lama.
  • load(ordering) membaca nilai saat ini.
  • store(value, ordering) menimpanya.

Setiap operasi atomic meminta sebuah Ordering. Ini memberi tahu CPU dan compiler seberapa ketat operasi ini harus tetap berurutan terhadap operasi memori di sekitarnya. Penjelasan lengkapnya cukup dalam, tapi sebagai pemula kamu hanya butuh dua aturan:

  • Kalau tidak yakin, pakai Ordering::SeqCst. Ini pilihan yang paling ketat dan paling aman.
  • Ordering::Relaxed sudah cukup untuk counter sederhana yang hanya kamu baca di akhir, seperti contoh di atas. Totalnya tetap tepat.

Kalau suatu hari kamu ingin mendalaminya, buku gratis Rust Atomics and Locks (tautannya ada di bagian bawah) menjelaskan ordering dengan jelas.

AtomicBool sangat cocok untuk sinyal “tolong berhenti”. Di sini sebuah worker di latar belakang terus melakukan autosave sampai main membalik flag-nya:

use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
fn main() {
let stop = AtomicBool::new(false);
let report = thread::scope(|s| {
let worker = s.spawn(|| {
while !stop.load(Ordering::SeqCst) {
// anggap saja dokumen di-autosave di sini
thread::sleep(Duration::from_millis(5));
}
"worker autosave berhenti dengan rapi"
});
thread::sleep(Duration::from_millis(30)); // main mengerjakan hal lain
stop.store(true, Ordering::SeqCst); // minta worker berhenti
worker.join().unwrap()
});
println!("{report}");
}

Output:

worker autosave berhenti dengan rapi
▶ Coba sendiri

Worker memeriksa flag itu setiap kali perulangannya berputar. Ia menyelesaikan langkah yang sedang dikerjakan lalu keluar sendiri, jauh lebih baik daripada mematikan thread di tengah-tengah proses menyimpan file.

OnceLock dan LazyLock: siapkan sekali saja

Section titled “OnceLock dan LazyLock: siapkan sekali saja”

Kadang kamu ingin satu nilai global yang bisa dibaca semua thread: tabel pencarian, nama aplikasi, atau konfigurasi yang dimuat saat program mulai. Bagian sulitnya adalah membangun nilai itu. Bagaimana kalau dua thread mencoba membangunnya di saat yang sama?

std::sync::LazyLock dan std::sync::OnceLock mengatasi masalah ini. Nilainya dibuat tepat satu kali, yaitu saat pertama kali ada yang membutuhkannya. Kalau ada thread lain yang meminta selagi nilainya masih dibangun, mereka cukup menunggu, lalu semuanya memakai nilai yang sama.

use std::collections::HashMap;
use std::sync::{LazyLock, OnceLock};
use std::thread;
// LazyLock: resepnya ditulis langsung di sini, dan dijalankan saat pertama dipakai.
static CURRENCIES: LazyLock<HashMap<&str, &str>> = LazyLock::new(|| {
println!("membangun tabel mata uang...");
HashMap::from([("US", "USD"), ("JP", "JPY"), ("DE", "EUR"), ("BR", "BRL")])
});
// OnceLock: awalnya kosong, dan kamu menentukan cara mengisinya di tempat ia dipakai.
static STORE_NAME: OnceLock<String> = OnceLock::new();
fn store_name() -> &'static str {
STORE_NAME.get_or_init(|| {
std::env::var("STORE_NAME").unwrap_or_else(|_| String::from("Toko Rusty"))
})
}
fn main() {
let mut handles = Vec::new();
for country in ["US", "JP", "DE", "BR"] {
handles.push(thread::spawn(move || {
format!("{} di {country} memakai mata uang {}", store_name(), CURRENCIES[country])
}));
}
for handle in handles {
println!("{}", handle.join().unwrap());
}
}

Output:

membangun tabel mata uang...
Toko Rusty di US memakai mata uang USD
Toko Rusty di JP memakai mata uang JPY
Toko Rusty di DE memakai mata uang EUR
Toko Rusty di BR memakai mata uang BRL
▶ Coba sendiri

Empat thread menginginkan tabel itu di waktu yang hampir bersamaan, tapi “membangun tabel mata uang…” hanya tercetak sekali. Dan karena ini adalah nilai static yang hidup selama program berjalan, thread-thread itu sama sekali tidak butuh Arc.

Mana yang sebaiknya kamu pilih?

  • LazyLock saat kamu tahu cara membangun nilainya di tempat kamu mendeklarasikannya. Rasanya seperti variabel global biasa.
  • OnceLock saat nilainya baru ditentukan belakangan, misalnya dari flag command-line. Kamu mengisinya dengan get_or_init(...) atau set(...) (dan set gagal dengan sopan kalau sudah terisi).

Keduanya bagian dari standard library. LazyLock baru hadir di Rust 1.80, jadi kode yang lebih lama sering memakai crate once_cell atau lazy_static untuk pekerjaan yang sama.

Compiler mencegah data race, tapi ada satu bug konkurensi yang tidak bisa ia tangkap: deadlock (kebuntuan). Ini terjadi saat thread-thread saling menunggu selamanya dan programmu langsung membeku. Tanpa error, tanpa crash, tanpa apa-apa.

Resep klasiknya adalah dua kunci yang diambil dalam urutan berbeda. Bayangkan dua orang yang sama-sama butuh pulpen dan buku catatan. Yang satu mengambil pulpen, yang lain mengambil buku catatan, dan masing-masing dengan sopan menunggu yang lain melepaskannya. Selamanya.

// Thread A // Thread B
let from = alice.lock().unwrap(); let from = bob.lock().unwrap();
let to = bob.lock().unwrap(); let to = alice.lock().unwrap();
// A pegang alice, tunggu bob. // B pegang bob, tunggu alice. Macet!

Tiga kebiasaan ini menjagamu tetap aman:

  1. Selalu ambil kunci dalam urutan yang sama. Kalau setiap thread mengunci alice sebelum bob (misalnya diurutkan berdasarkan ID akun), lingkaran saling tunggu itu tidak mungkin terjadi.
  2. Buat cakupan kunci sesingkat mungkin. Pegang kunci hanya selama yang dibutuhkan untuk membaca atau mengubah data. Jangan pernah memegangnya selama pekerjaan yang lambat seperti panggilan jaringan, menulis file, atau perhitungan yang panjang. Selama itu, semua thread lain terjebak menunggu.
  3. Drop guard lebih awal. Guard membuka kunci saat ia di-drop. Kamu bisa mengakhirinya dengan blok dalam { ... } (seperti penulis RwLock di atas) atau memanggil drop(guard) sendiri.

Berikut kebiasaan ke-3 dalam praktik. Setiap worker mengambil satu pekerjaan dari antrean bersama, membuka kunci antrean, dan baru setelah itu mengerjakan bagian yang lambat:

use std::sync::Mutex;
use std::thread;
fn main() {
let queue = Mutex::new(vec!["ubah ukuran foto", "kirim email", "buat laporan"]);
let finished = Mutex::new(Vec::new());
thread::scope(|s| {
for _ in 0..3 {
s.spawn(|| {
let mut jobs = queue.lock().unwrap();
let job = jobs.pop();
drop(jobs); // buka kunci antrean SEBELUM bagian yang lambat
if let Some(job) = job {
let result = format!("selesai: {job}"); // anggap ini butuh beberapa detik
finished.lock().unwrap().push(result);
}
});
}
});
// Scope sudah berakhir, jadi tidak ada thread yang memakai `finished` lagi.
let mut done = finished.into_inner().unwrap();
done.sort(); // thread selesai dalam urutan acak
for line in done {
println!("{line}");
}
}

Output:

selesai: buat laporan
selesai: kirim email
selesai: ubah ukuran foto
▶ Coba sendiri

Tanpa drop(jobs), guard-nya akan hidup sampai akhir closure. Akibatnya pekerjaan yang “lambat” terjadi selagi antrean masih terkunci, dan ketiga worker akan berjalan satu per satu, bukan berdampingan.

Ada satu kasus licik yang perlu kamu tahu: di while let Some(job) = queue.lock().unwrap().pop() { ... }, guard-nya hidup selama seluruh isi perulangan. Jadi antrean tetap terkunci selama kamu mengerjakan job itu. Ambil job-nya dulu di pernyataan let yang terpisah, seperti di contoh.

Kamu ingin… Pakai
Mengirim pekerjaan atau hasil dari satu thread ke thread lain Channel (mpsc)
Membuat banyak thread bisa membaca dan mengubah nilai yang sama Arc<Mutex<T>>
Berbagi data yang sering dibaca dan jarang diubah Arc<RwLock<T>>
Menghitung sesuatu, atau berbagi flag nyala/mati sederhana Atomic (AtomicUsize, AtomicBool, …)
Membangun nilai global sekali, lalu hanya membacanya LazyLock atau OnceLock
Membuat thread bisa meminjam data lokal, tanpa Arc thread::scope

Semua ini bisa dikombinasikan dengan bebas. Contoh-contoh di halaman ini sudah menggabungkan thread::scope dengan atomic dan dengan Mutex. Pilih alat paling sederhana yang cocok, dan jangan khawatir salah pilih: beralih belakangan biasanya hanya perubahan kecil.

Meminjam variabel lokal di thread::spawn. Ini kelihatannya tidak berbahaya:

use std::thread;
fn main() {
let sales = vec![12, 7, 30, 5];
let handle = thread::spawn(|| {
let total: i32 = sales.iter().sum();
total
});
println!("total: {}", handle.join().unwrap());
}
error[E0373]: closure may outlive the current function, but it borrows `sales`, which is owned by the current function
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `sales`
7 | let total: i32 = sales.iter().sum();
| ----- `sales` is borrowed here
|
help: to force the closure to take ownership of `sales` (and any other referenced variables), use the `move` keyword

Compiler bilang closure itu mungkin hidup lebih lama daripada fungsi saat ini, padahal ia meminjam sales yang dimiliki fungsi tersebut. Kamu tahu bahwa kamu langsung memanggil join(), tapi thread::spawn tidak bisa tahu itu. Dari sudut pandangnya, thread itu bisa saja berjalan selamanya. Ada dua cara memperbaikinya: tambahkan move (setelah itu main tidak bisa memakai sales lagi), atau pakai thread::scope, yang boleh meminjam:

let total: i32 = thread::scope(|s| s.spawn(|| sales.iter().sum()).join().unwrap());

Lupa memakai Arc. Tanpa Arc, closure move milik thread pertama mengambil Mutex-nya, dan tidak ada yang tersisa untuk thread berikutnya:

use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..3 {
handles.push(thread::spawn(move || {
*counter.lock().unwrap() += 1;
}));
}
for handle in handles {
handle.join().unwrap();
}
}
error[E0382]: use of moved value: `counter`
--> src/main.rs:9:36
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
8 | for _ in 0..3 {
| ------------- inside of this loop
9 | handles.push(thread::spawn(move || {
| ^^^^^^^ value moved into closure here, in previous iteration of loop

Error ini artinya counter sudah dipindahkan ke closure pada putaran perulangan sebelumnya, jadi tidak bisa dipakai lagi. Bungkus dengan Arc::new(...) dan clone Arc-nya di awal perulangan, seperti di halaman Konkurensi. Atau pakai thread::scope dan hapus move-nya. Kalau kamu memakai Rc alih-alih Arc, kamu akan mendapat error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely (tidak bisa dikirim antar-thread dengan aman). Rc hanya untuk satu thread (lihat Smart pointer).

Buat Vec berisi angka 1 sampai 1000. Pakai thread::scope untuk membaginya menjadi 4 bagian berisi 250 angka, lalu jumlahkan setiap bagian di thread-nya sendiri. Cetak jumlah setiap bagian dan total keseluruhannya. Petunjuk: numbers.chunks(250) memberimu slice-slice-nya. Kumpulkan handle-nya ke dalam Vec, lalu join satu per satu sesuai urutan.

Lihat solusi
use std::thread;
fn main() {
let numbers: Vec<u64> = (1..=1000).collect();
let sums: Vec<u64> = thread::scope(|s| {
let mut handles = Vec::new();
for chunk in numbers.chunks(250) {
handles.push(s.spawn(move || chunk.iter().sum::<u64>()));
}
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
println!("jumlah per bagian: {:?}", sums);
println!("total: {}", sums.iter().sum::<u64>());
}

Output:

jumlah per bagian: [31375, 93875, 156375, 218875]
total: 500500
▶ Coba sendiri

move di sini hanya memindahkan chunk, yaitu slice pinjaman yang kecil, ke setiap thread. Vektornya sendiri tetap dipinjam, bukan dipindahkan, jadi kamu masih bisa memakai numbers lagi setelah scope selesai.

Sebuah toko online memberi setiap pesanan sebuah nomor, mulai dari 1000. Jalankan 4 thread yang masing-masing mengambil 3 nomor pesanan dari satu AtomicU64 bersama. Kumpulkan ke-12 nomor itu, urutkan, lalu cetak. Petunjuk: fetch_add(1, Ordering::SeqCst) mengembalikan nilai sebelum ditambah, jadi setiap nomor dibagikan tepat sekali.

Lihat solusi
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
fn main() {
let next_order = AtomicU64::new(1000);
let mut numbers: Vec<u64> = thread::scope(|s| {
let mut handles = Vec::new();
for _ in 0..4 {
handles.push(s.spawn(|| {
(0..3)
.map(|_| next_order.fetch_add(1, Ordering::SeqCst))
.collect::<Vec<u64>>()
}));
}
handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
});
numbers.sort();
println!("{} pesanan: {:?}", numbers.len(), numbers);
}

Output:

12 pesanan: [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011]
▶ Coba sendiri

Thread mana yang mendapat nomor berapa bisa berubah di setiap run, tapi tidak ada nomor yang dibagikan dua kali atau terlewat. Pengurutan membuat daftar yang dicetak selalu sama.

  • thread::scope menjamin semua thread-nya selesai sebelum scope berakhir, jadi thread-thread itu bisa meminjam data lokal tanpa Arc atau move.
  • RwLock mengizinkan banyak pembaca atau satu penulis. Ia membantu untuk data yang sering dibaca; selain itu, Mutex lebih sederhana.
  • Atomic (AtomicUsize, AtomicI64, AtomicBool) menangani counter dan flag tanpa kunci. Pakai SeqCst kalau ragu, Relaxed untuk counter sederhana.
  • LazyLock dan OnceLock membuat nilai global tepat satu kali, dengan aman, bahkan saat banyak thread memintanya bersamaan.
  • Hindari deadlock: ambil kunci dalam urutan yang sama, buat cakupan kunci singkat, dan drop guard lebih awal.

Kuis singkat

1. Kenapa thread yang dimulai dengan s.spawn di dalam thread::scope boleh meminjam variabel lokal?

2. Pernyataan mana tentang RwLock yang benar?

3. Kamu butuh counter tayangan halaman yang dipakai bersama oleh banyak thread. Apa pilihan bagus yang paling sederhana?

4. Thread A mengunci alice lalu bob. Thread B mengunci bob lalu alice. Apa yang bisa terjadi?

Skor: 0 / 4