State Bersama

Mutex, RwLock, dan poisoning.

Daftar isi

Topik ini membahas state bersama pada Rust, yaitu data yang dipegang banyak thread sekaligus melalui kunci dari std::sync, mulai dari Mutex yang mengantrekan seluruh akses, RwLock yang memisahkan mode baca dan tulis, sampai keadaan teracuni (poisoned) yang ditinggalkan thread yang panic saat memegang kunci. Pembahasan mencakup pemilihan di antara kedua jenis kunci serta pemulihan data dari kunci yang teracuni. Setelah membaca topik ini, data yang memang perlu dibagi lintas thread dapat dilindungi dengan jenis kunci yang tepat, dan kegagalannya dapat ditangani secara sadar.

Mutex sebagai gerbang satu pemegang#

Mutex<T> melindungi data dengan satu kunci: hanya satu thread yang boleh memegangnya pada satu waktu, dan thread lain menunggu hingga kunci terbuka. Pola Arc<Mutex<T>> telah diperkenalkan pada topik Arc dan Mutex sebagai pratinjau, sehingga pembahasan di sini langsung menuju pola pemakaian yang lebih luas. Untuk data yang lebih besar daripada satu bilangan sekalipun, bagian kritis biasanya hanya satu baris ekspresi, misalnya push atau insert, sehingga antrean kunci bergerak cepat dan thread jarang saling menunggu lama.

Metode lock mengembalikan penjaga MutexGuard<T> yang membuka akses ke data selama kunci terpasang. Penjaga itu dijatuhkan otomatis pada akhir lingkupnya, dan saat itulah kunci terbuka kembali, sehingga kunci tidak pernah tertinggal terpasang karena lupa membuka. Nilai kembalian lock berupa Result karena ada keadaan teracuni yang dibahas pada bagian khusus di bawah. Program berikut mengumpulkan bacaan sensor dari empat thread ke satu Vec di dalam Mutex, lalu mencetak isinya setelah semua thread selesai.

rust kumpul-baca.rs
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let catatan = Arc::new(Mutex::new(Vec::new()));
    let mut pegangan = Vec::new();

    for id in 1..=4 {
        let catatan = Arc::clone(&catatan);
        pegangan.push(thread::spawn(move || {
            let nilai = 20 + id * 3;
            catatan.lock().unwrap().push(nilai);
        }));
    }

    for utas in pegangan {
        utas.join().unwrap();
    }

    let mut data = catatan.lock().unwrap();
    data.sort();
    println!("catatan sensor: {:?}", *data);
}
text Keluaran
catatan sensor: [23, 26, 29, 32]

Urutan kedatangan nilai 23, 26, 29, dan 32 tidak ditentukan karena keempat thread berebut kunci, sehingga pengurutan dilakukan sebelum pencetakan agar keluaran tetap sama pada setiap jalankan. Bagian kritis pada setiap thread hanya satu ekspresi catatan.lock().unwrap().push(nilai): penjaga hidup sepanjang ekspresi itu saja, lalu kunci langsung terbuka begitu pernyataan selesai. Pencetakan memakai *data karena penjaga perlu di-dereferensi agar yang diformat adalah isinya, bukan penjaganya.

RwLock untuk data yang sering dibaca#

RwLock<T> membagi kunci menjadi dua mode. Mode baca lewat read() mengembalikan RwLockReadGuard<T>, dan beberapa penjaga baca boleh hidup bersamaan sehingga banyak thread dapat membaca data pada saat yang sama. Mode tulis lewat write() mengembalikan RwLockWriteGuard<T> yang bersifat eksklusif, dan permintaan baca maupun tulis lain menunggu hingga penulis selesai. Pemakaian yang tepat adalah akses yang timpang, misalnya konfigurasi yang dibaca setiap saat tetapi diubah jarang, karena pada data dengan rasio baca dan tulis yang seimbang, pencatatan pembaca justru menambah biaya tanpa keuntungan.

Program berikut menjalankan tiga thread pembaca yang masing-masing menjumlahkan isi data, kemudian satu thread penulis yang menambahkan satu nilai baru. Seluruh pencetakan dilakukan dari thread utama setelah tiap tahap ditunggu, sehingga keluarannya pasti.

rust baca-tulis.rs
use std::sync::{Arc, RwLock};
use std::thread;

fn main() {
    let data = Arc::new(RwLock::new(vec![21, 24, 19]));
    let mut pembaca = Vec::new();

    for _ in 0..3 {
        let data = Arc::clone(&data);
        pembaca.push(thread::spawn(move || {
            let baca = data.read().unwrap();
            baca.iter().sum::<i32>()
        }));
    }

    let mut jumlah = 0;
    for utas in pembaca {
        jumlah += utas.join().unwrap();
    }
    println!("jumlah tiga pembaca: {}", jumlah);

    let penulis = {
        let data = Arc::clone(&data);
        thread::spawn(move || {
            data.write().unwrap().push(30);
        })
    };
    penulis.join().unwrap();

    let akhir = data.read().unwrap();
    println!("data setelah tulis : {:?}", *akhir);
    println!("jumlah akhir       : {}", akhir.iter().sum::<i32>());
}
text Keluaran
jumlah tiga pembaca: 192
data setelah tulis : [21, 24, 19, 30]
jumlah akhir       : 94

Keluaran 192 adalah tiga kali 64 karena ketiga pembaca membaca data yang sama sebelum penulis bekerja, dan penulisan berlangsung setelah ketiga join pembaca selesai. Data kemudian bertambah satu anggota menjadi [21, 24, 19, 30] sehingga jumlah akhirnya 94. Susunan program yang menjadwal tahap baca lebih dahulu dan tahap tulis kemudian membuat hasil tidak bergantung pada kecepatan thread. Diagram dan tabel berikut merangkum perbedaan kedua jenis kunci.

RwLock mengizinkan banyak pembaca atau satu penulispembaca 1pembaca 2pembaca 3penulisRwLockdua mode kuncidatanilai bersamaread()write()read(): pembaca bersamaanwrite(): penulis sendirian
RwLock mengizinkan banyak pembaca bersamaan atau satu penulis sendirian
AspekMutex<T>RwLock<T>
mode kuncisatu mode untuk baca dan tulisdua mode, read dan write
akses bersamaantidak ada, satu pemegang kuncibanyak pembaca atau satu penulis
biaya relatiflebih ringanpencatatan pembaca lebih berat
cocok untukbagian kritis singkat dan seimbangdata yang jauh lebih sering dibaca

Kunci teracuni oleh panic#

Panic yang terjadi saat kunci terpasang meninggalkan keadaan teracuni pada kunci itu. Alasan desainnya berhubungan dengan makna panic: kegagalan semacam itu menandakan pecahnya invarian internal, yakni kondisi yang seharusnya selalu benar, dan data yang sedang dipegang thread itu patut dicurigai tidak konsisten. Karena itu, setiap lock sesudah kejadian itu mengembalikan Err berisi PoisonError alih-alih penjaga, sehingga kode pemanggil tidak membaca data yang mungkin rusak tanpa menyadarinya. Pemulihan tetap dimungkinkan lewat PoisonError::into_inner, yang mengembalikan penjaga atas data apa adanya, atau lewat pemeriksaan is_poisoned sebelum mengunci.

Program berikut menjalankan thread yang panic di tengah pemegangan kunci, kemudian thread utama mengunci ulang dan memeriksa keadaannya. Nomor thread pada pesan panic bervariasi pada setiap jalankan sehingga disaring dari keluaran.

bash
export PATH="$HOME/.cargo/bin:$PATH"
# thread panic saat memegang kunci, kunci menjadi teracuni
printf 'use std::sync::{Arc, Mutex};\nuse std::thread;\n\nfn main() {\n    let data = Arc::new(Mutex::new(vec![10, 24]));\n\n    let utas = {\n        let data = Arc::clone(&data);\n        thread::spawn(move || {\n            let mut nilai = data.lock().unwrap();\n            nilai.push(100);\n            panic!("sensor rusak saat kunci terpasang");\n        })\n    };\n\n    println!("join menghasilkan: {}", utas.join().is_err());\n\n    let hasil = data.lock();\n    match hasil {\n        Ok(nilai) => println!("kunci sehat   : {:?}", nilai),\n        Err(racun) => println!("kunci teracuni: {:?}", racun.into_inner()),\n    }\n}\n' > kunci-racun.rs
# kompilasi lalu jalankan, nomor thread pada pesan bervariasi sehingga disaring
rustc --edition=2021 kunci-racun.rs -o kunci-racun && ./kunci-racun 2>&1 | sed -E 's/ \([0-9]+\)//' | sed -n '1,7p'
text Keluaran

thread '<unnamed>' panicked at kunci-racun.rs:12:13:
sensor rusak saat kunci terpasang
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
join menghasilkan: true
kunci teracuni: [10, 24, 100]

Baris join menghasilkan: true menegaskan bahwa panic pada thread tidak menghentikan thread utama, dan kunci teracuni: [10, 24, 100] menunjukkan bahwa push(100) sempat berjalan sebelum panic. Pemanggilan into_inner pada PoisonError memang memberi akses kembali atas data, tetapi keputusan memakai data itu tetap tanggung jawab pemanggil karena Rust tidak tahu invarian mana yang mungkin sudah pecah. RwLock menerapkan perlakuan yang sama: kegagalan pada mode tulis mengacuni kunci, sedangkan kegagalan pada mode baca tidak.

Pesan atau kunci: memilih gaya#

Channel dan kunci menyelesaikan dua kebutuhan yang berbeda. Channel unggul ketika data berpindah: satu pihak selesai dengan sebuah nilai dan pihak lain mengambil alih, misalnya antrean tugas atau pengiriman hasil hitung. Kunci unggul ketika data bertahan di satu tempat dan hanya dilihat atau diubah oleh banyak thread, misalnya penghitung bersama, kumpulan catatan, atau konfigurasi yang dibaca semua thread. Buku resmi Rust menyarankan memulai dari bentuk yang paling sederhana: message passing membuat kepemilikan jelas, sedangkan kunci wajar dipakai ketika pembagian memori memang tujuannya.

KebutuhanAlat yang sesuai
hasil dikirim sekali kepada satu pemroseschannel mpsc
banyak thread membaca data yang jarang berubahArc<RwLock<T>>
banyak thread mengubah satu data bersamaArc<Mutex<T>>

Kedua gaya sama-sama menuntut kehati-hatian terhadap penantian tanpa batas. Channel yang tidak pernah tertutup membuat penerima menunggu selamanya, sedangkan dua kunci yang diambil dengan urutan berbeda oleh dua thread saling menunggu dalam keadaan deadlock. Kebiasaan memegang kunci sesempat mungkin dan menghindari kunci bersarang mengurangi dua risiko itu pada akarnya.

Kesalahan umum#

Kesalahan pertama adalah mengunci dua Mutex dengan urutan berbeda pada dua thread. Thread pertama memegang kunci A dan menunggu B, sedangkan thread kedua memegang B dan menunggu A, sehingga keduanya berhenti selamanya dalam deadlock. Perbaikannya adalah menetapkan urutan penguncian yang sama di seluruh program, memegang kunci sesempat mungkin, dan memakai try_lock apabila kegagalan mengunci perlu ditangani alih-alih ditunggu.

Kesalahan kedua adalah merangkai unwrap pada lock di program yang thread-nya dapat panic. Kunci teracuni membuat seluruh unwrap berikutnya panic berurutan dan mengaburkan penyebab pertamanya. Tangani Err pada titik yang tepat, dan putuskan secara sadar apakah data dipulihkan atau kegagalan dilaporkan.

Kesalahan ketiga adalah memanggil write pada RwLock yang penjaga bacanya masih dipegang thread yang sama. Permintaan tulis menunggu seluruh pembaca selesai, padahal salah satu pembacanya adalah thread itu sendiri, sehingga thread menggantung menunggu dirinya. Lepaskan penjaga baca dengan keluar dari lingkupnya sebelum meminta mode tulis.

Ringkasan#

  • Mutex<T> mengantrekan seluruh akses lewat satu kunci dengan penjaga yang terbuka otomatis saat dijatuhkan, dan bagian kritisnya sebaiknya hanya satu ekspresi singkat.
  • RwLock<T> memisahkan mode read yang boleh dimiliki banyak thread bersamaan dan mode write yang eksklusif, tepat untuk data yang jauh lebih sering dibaca.
  • Panic saat memegang kunci mengacuni kunci itu, sehingga lock berikutnya mengembalikan Err berisi PoisonError, dan pemulihan data dilakukan secara sadar lewat into_inner.
  • Channel dipilih ketika kepemilikan berpindah, sedangkan kunci dipilih ketika data memang dibagi, dan keduanya menuntut kunci yang dipegang sesempat mungkin.

Referensi#