Mutex menjamin tidak ada dua thread yang menyentuh data pada saat yang sama — tetapi jaminan itu lahir dengan tagihan. Kunci yang harus diambil dan ditunggu membuka satu mode kegagalan yang tidak bisa dicegah ownership: deadlock, keadaan ketika sekumpulan thread saling menunggu kunci yang dipegang satu sama lain sehingga tidak ada satu pun yang bisa maju. Kompilator tidak memberi peringatan untuk pola ini karena semua pihak memegang kunci lewat lock() yang sah — hanya dengan urutan yang salah. Kita harus mengenalinya dari bentuknya, mereproduksinya dengan aman, lalu membangun kebiasaan yang memutus lingkarannya.

Anatomi Deadlock #

Bentuk paling kecil dari deadlock membutuhkan dua thread dan dua kunci: Admin mengunci brankas gudang, Auditor mengunci brankas kas, lalu masing-masing menunggu kunci yang dipegang lawannya — penantian itu abadi:

text
Thread Admin   : kunci GUDANG ... tunggu KAS     ]
Thread Auditor : kunci KAS     ... tunggu GUDANG ]  lingkaran menunggu

Struktur inilah yang disebut circular wait: panah “menunggu kunci yang dipegang siapa” membentuk lingkaran tertutup. Justru karena semua langkahnya sah, program tidak panic dan tidak mencetak apa pun — program hanya diam. Kasus satu thread pun ada: thread yang memanggil lock() dua kali atas Mutex yang sama sedang menunggu dirinya sendiri.

Reproduksi Deadlock Secara Aman #

Mendemokan deadlock itu mudah; mendemokannya tanpa menggantung program selamanya butuh trik. Thread utama memeriksa kedua worker dari luar lewat is_finished(), mencetak diagnosis, lalu keluar — begitu proses keluar, OS menutup seluruh proses beserta thread yang terjebak:

rust
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

fn main() {
    let gudang = Arc::new(Mutex::new(40));
    let kas = Arc::new(Mutex::new(1_250_000));

    let gudang_admin = Arc::clone(&gudang);
    let kas_admin = Arc::clone(&kas);
    let admin = thread::spawn(move || {
        let stok = gudang_admin.lock().unwrap();
        thread::sleep(Duration::from_millis(300));
        let mut saldo = kas_admin.lock().unwrap();
        *saldo -= *stok * 15_000;
    });

    let gudang_auditor = Arc::clone(&gudang);
    let kas_auditor = Arc::clone(&kas);
    let auditor = thread::spawn(move || {
        let saldo = kas_auditor.lock().unwrap();
        thread::sleep(Duration::from_millis(300));
        let mut stok = gudang_auditor.lock().unwrap();
        *stok += *saldo / 1_000_000;
    });

    thread::sleep(Duration::from_secs(2));
    println!("Status setelah 2 detik:");
    println!("Admin   selesai? {}", if admin.is_finished() { "ya" } else { "tidak" });
    println!("Auditor selesai? {}", if auditor.is_finished() { "ya" } else { "tidak" });
    println!("Kedua thread saling menahan kunci - pola deadlock terjadi");
}

Output yang Dihasilkan:

text
Status setelah 2 detik:
Admin   selesai? tidak
Auditor selesai? tidak
Kedua thread saling menahan kunci - pola deadlock terjadi

Mari kita bedah kode di atas:

  1. Baris 6-7 (let gudang = ...; let kas = ...;): dua brankas — stok gudang dan saldo kas — dibungkus Arc<Mutex<...>> agar bisa dipegang dua thread.
  2. Baris 11-16 (thread Admin): Admin mengunci gudang (Baris 12), lalu sengaja tidur 300 milidetik (Baris 13) agar Auditor sempat mengunci kas. Setelah bangun, lock() atas kas pada Baris 14 tidak pernah kembali.
  3. Baris 20-25 (thread Auditor): urutannya kebalikan — kas dulu (Baris 21), tidur (Baris 22), baru gudang (Baris 23). Saat kedua thread melewati tidurnya, masing-masing memegang satu kunci dan menginginkan kunci lawannya — lingkaran terbentuk.
  4. Baris 27 (thread::sleep(Duration::from_secs(2));): main menunggu dari luar, bukan lewat join() — join atas thread yang deadlock ikut menggantung selamanya.
  5. Baris 29-30 (admin.is_finished() / auditor.is_finished()): is_finished() memeriksa apakah sebuah thread sudah rampung tanpa menunggu. Pekerjaan kedua worker hanya butuh 300 milidetik, jadi jawaban tidak dua kali setelah 2 detik adalah bukti keduanya terjebak.
  6. Baris 32 (akhir main): main kembali, proses keluar dengan status sukses, dan OS mematikan kedua thread yang terjebak — program selesai rapi dalam ±2 detik pada setiap eksekusi.

Solusi Utama: Urutan Kunci yang Konsisten #

Lingkaran hanya bisa terbentuk bila dua thread mengambil kumpulan kunci yang sama dengan urutan berbeda. Pencegahannya: tetapkan satu urutan pengambilan kunci di seluruh program — selalu gudang sebelum kas — dan patuhi di mana pun kedua kunci dibutuhkan. Panah “menunggu kunci siapa” kini hanya mengalir satu arah, sehingga lingkaran mustahil terjadi:

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

fn main() {
    let gudang = Arc::new(Mutex::new(40));
    let kas = Arc::new(Mutex::new(1_250_000));

    let mut handles = Vec::new();

    for nama_tim in ["Admin", "Auditor"] {
        let gudang_tim = Arc::clone(&gudang);
        let kas_tim = Arc::clone(&kas);
        handles.push(thread::spawn(move || {
            let mut stok = gudang_tim.lock().unwrap();
            let mut saldo = kas_tim.lock().unwrap();
            *stok -= 5;
            *saldo -= 75_000;
            nama_tim
        }));
    }

    let mut tim_selesai = Vec::new();
    for handle in handles {
        tim_selesai.push(handle.join().unwrap());
    }

    println!("Tim yang selesai: {:?}", tim_selesai);
    println!("Stok gudang: {}", *gudang.lock().unwrap());
    println!("Saldo kas: Rp{}", *kas.lock().unwrap());
}

Output yang Dihasilkan:

text
Tim yang selesai: ["Admin", "Auditor"]
Stok gudang: 30
Saldo kas: Rp1100000

Mari kita bedah kode di atas:

  1. Baris 10 (for nama_tim in ["Admin", "Auditor"] {): kedua tim menjalankan pekerjaan yang sama; nama_tim bertipe &str yang Copy, sehingga tersalin aman ke tiap thread.
  2. Baris 14-15 (let mut stok = ...; let mut saldo = ...;): inilah seluruh perbaikannya — kedua thread mengunci gudang lebih dulu, baru kas. Tim kedua yang datang belakangan menunggu di Baris 14 sampai tim pertama melepas kedua kuncinya, lalu masuk dengan urutan yang sama.
  3. Baris 16-18 (*stok -= 5; ...): dua kunci hidup bersama dalam satu area kritis bersarang — sah-sah saja karena urutannya seragam.
  4. Baris 24 (tim_selesai.push(handle.join().unwrap());): join() kini aman karena kedua thread dijamin selesai — penantiannya paling lama seukuran satu area kritis, bukan abadi.
  5. Baris 27-29: hasilnya konsisten: kedua tim tercatat selesai, stok berkurang 10, dan saldo berkurang 150000 dari 1250000.

try_lock: Mencoba Tanpa Menunggu #

Kadang urutan global sulit dipertahankan. Untuk situasi seperti ini, try_lock() mengambil kunci tanpa antre: bila bebas, panggilan berhasil; bila sedang dipegang, panggilan langsung kembali dengan Err (varian WouldBlock). Pola penggunaannya: gagal mengambil kunci kedua → lepaskan kunci pertama → mundur → ulangi:

rust
use std::sync::Mutex;

fn main() {
    let laci = Mutex::new("Berita Acara Serah Terima");

    let kunci = laci.lock().unwrap();
    println!("Laci sedang dipakai");

    match laci.try_lock() {
        Ok(_) => println!("Laci berhasil dibuka tanpa antre"),
        Err(_) => println!("Laci masih terkunci - tidak menunggu, tunda dulu"),
    }

    drop(kunci);
    match laci.try_lock() {
        Ok(_) => println!("Laci berhasil dibuka tanpa antre"),
        Err(_) => println!("Laci masih terkunci - tidak menunggu, tunda dulu"),
    }

    println!("Program selesai");
}

Output yang Dihasilkan:

text
Laci sedang dipakai
Laci masih terkunci - tidak menunggu, tunda dulu
Laci berhasil dibuka tanpa antre
Program selesai

Mari kita bedah kode di atas:

  1. Baris 6 (let kunci = laci.lock().unwrap();): thread ini memegang kunci laci — pada versi dua-kunci, pemegangan seperti ini adalah “kunci pertama” yang rawan memulai lingkaran.
  2. Baris 9-12 (match laci.try_lock() { ... }): try_lock menemukan kunci sedang dipegang (oleh thread yang sama) dan langsung memilih cabang Err tanpa menunggu. Momen Err inilah titik keputusan pola mundur: lepaskan kunci pertama, lalu coba lagi.
  3. Baris 14-18 (drop(kunci); match laci.try_lock() ...): setelah kunci pertama dilepas, try_lock kedua berhasil. Di dunia nyata, try_lock hidup di dalam loop berbatas percobaan — lima kali mundur selama 50 milidetik, misalnya — lalu melaporkan kegagalan bila kunci tak kunjung didapat.

Aturan Praktis Menghindari Deadlock #

Empat kebiasaan berikut menutup sebagian besar pintu masuk deadlock:

  • Hindari kunci bersarang bila bisa. Ambil satu kunci, selesaikan, lepas, baru ambil kunci berikutnya.
  • Bila kunci bersarang tak terhindarkan, patuhi satu urutan global — seperti gudang-sebelum-kas di atas.
  • Jangan menunggu apa pun sambil memegang kunci. Tidur, recv(), atau memanggil kode pihak ketiga di dalam area kritis berarti pemegang kunci berhenti tanpa jadwal kembali.
  • Pertimbangkan channel sebagai pengganti berbagi data — kepemilikan yang mengalir dari producer ke consumer tidak butuh kunci, seperti pola fan-in di Chapter Channel Banyak Producer.

Ringkasan #

Deadlock adalah lingkaran tunggu: beberapa thread masing-masing memegang kunci dan menunggu kunci milik yang lain, diam tanpa panic dan tanpa jejak. Reproduksi yang aman memakai is_finished() untuk mendiagnosis dari luar lalu keluar dari proses, bukan join() yang ikut tergantung. Pencegahannya berlapis: utamakan satu kunci dalam satu masa, patuhi urutan global bila harus bersarang, gunakan try_lock dengan pola mundur, dan jangan menunggu apa pun sambil memegang kunci. Untuk puluhan ribu koneksi I/O ringan, babak berikutnya pindah ke model async di Section Pemrograman Async.