Deadlock adalah kondisi ketika dua thread (atau lebih) masing-masing memegang satu kunci dan menunggu kunci milik pihak lain — tidak ada yang bisa maju, tidak ada yang bisa mundur, dan program menjadi beku tanpa pesan error apa pun. Berbeda dari crash, deadlock tidak meninggalkan jejak; programnya “hidup” tapi tidak berbuat apa-apa lagi. Chapter ini merangkum empat kondisi yang harus terpenuhi agar deadlock terjadi, menampilkan pola berbahaya yang memicunya (tanpa dijalankan), lalu membangun versi aman yang benar-benar teruji dengan std::lock dan std::scoped_lock.

Empat Kondisi Deadlock #

Literature sistem operasi merangkum syarat deadlock (kondisi Coffman) menjadi empat, dan keempatnya harus terpenuhi sekaligus:

  • Mutual exclusion: ada resource yang hanya boleh dipakai satu thread — misalnya mutex.
  • Hold and wait: thread memegang satu kunci sambil menunggu kunci lain.
  • No preemption: kunci tidak bisa direbut paksa dari pemegangnya; hanya pemegang yang boleh melepas.
  • Circular wait: membentuk lingkaran tunggu — A menunggu B, B menunggu A.

Kita tidak bisa menghapus kondisi pertama dan ketiga (memang sifat mutex), jadi strategi pencegahan menyerang dua sisanya: jangan pernah hold-and-wait tanpa jaminan, dan pastikan urutan pengambilan kunci konsisten sehingga circular wait mustahil terbentuk.

Pola yang Memicu Deadlock #

Bentuk klasiknya: dua thread mengunci dua mutex yang sama dengan urutan berbeda.

cpp Pola dua mutex dengan urutan berbeda — program ini akan beku
std::mutex rekeningA;
std::mutex rekeningB;

void thread1() {
    std::lock_guard<std::mutex> a(rekeningA);   // pegang A...
    std::lock_guard<std::mutex> b(rekeningB);   // ...lalu tunggu B
}

void thread2() {
    std::lock_guard<std::mutex> b(rekeningB);   // pegang B...
    std::lock_guard<std::mutex> a(rekeningA);   // ...lalu tunggu A
}

Program di atas sengaja TIDAK dijalankan dan tidak ada keluaran yang diklaim. Jalannya bisa seperti ini: thread1 merebut A, thread2 merebut B; kini thread1 menunggu B yang dipegang thread2, dan thread2 menunggu A yang dipegang thread1. Kedua lock_guard itu tidak pernah dibuat sempurna, kedua destructor tidak pernah jalan, dan kedua thread menunggu selamanya. Menariknya, deadlock ini sering tidak muncul di setiap eksekusi — bila thread1 selesai sebelum thread2 sempat mulai, program berjalan normal. Karena itu deadlock termasuk bug yang paling licin: tersembunyi berbulan-bulan, lalu muncul di produksi.

Pencegahan: Kunci Bersama Sekaligus #

Pustaka standar memberi dua alat yang memecahkan pola di atas. std::lock(m1, m2) mengunci beberapa mutex sekaligus dengan algoritma yang menghindari deadlock (mencoba kunci satu per satu, dan bila ada yang gagal, melepas yang sudah dipegang lalu mengulang). std::scoped_lock (C++17) melakukannya sekaligus dalam bentuk RAII — pilihan paling ringkas untuk beberapa mutex.

Program teruji berikut memakai keduanya untuk operasi transfer antar dua rekening — contoh dunia nyata yang klasik, karena memindahkan uang memang harus mengunci dua saldo bersamaan:

cpp
#include <iostream>
#include <mutex>
#include <thread>

std::mutex rekeningA;
std::mutex rekeningB;
int saldoA = 500;
int saldoB = 300;

// Teknik 1: std::scoped_lock mengunci beberapa mutex sekaligus,
// dengan algoritma anti-deadlock di dalamnya (C++17)
void transferAB() {
    std::scoped_lock kunci(rekeningA, rekeningB);
    saldoA -= 100;
    saldoB += 100;
}

// Teknik 2: std::lock mengunci kedua mutex, lalu lock_guard
// "mengadopsi" kunci yang sudah terpasang (adopt_lock)
void transferBA() {
    std::lock(rekeningA, rekeningB);
    std::lock_guard<std::mutex> kunciA(rekeningA, std::adopt_lock);
    std::lock_guard<std::mutex> kunciB(rekeningB, std::adopt_lock);
    saldoB -= 50;
    saldoA += 50;
}

int main() {
    std::thread t1(transferAB);
    std::thread t2(transferBA);

    t1.join();
    t2.join();

    std::cout << "saldoA: " << saldoA << "\n";
    std::cout << "saldoB: " << saldoB << "\n";
    std::cout << "total : " << saldoA + saldoB << "\n";
    return 0;
}

Output yang Dihasilkan:

text
saldoA: 450
saldoB: 350
total : 800

Bedah barisnya:

  1. Baris 13 (std::scoped_lock kunci(rekeningA, rekeningB)): kunci A dan B diperoleh dalam satu langkah yang aman terhadap deadlock — di dalamnya berlaku algoritma yang sama dengan std::lock. Tidak ada jendela “memegang satu, menunggu satu”.
  2. Baris 21 (std::lock(rekeningA, rekeningB)): teknik klasik — kedua mutex terkunci ketika fungsi ini kembali, urutannya tidak penting bagi keamanan.
  3. Baris 22-23 (std::adopt_lock): lock_guard dibuat tanpa mengunci ulang; ia mengadopsi kunci yang sudah terpasang oleh std::lock, dan bertugas melepasnya saat scope berakhir. Tanpa adopt_lock, lock_guard akan mencoba mengunci mutex yang sudah kita pegang sendiri — perilaku tak terdefinisi.
  4. Baris 32-33 (join()): kedua transfer selesai, tidak ada saling tunggu. Saldo akhirnya 450 dan 350; total 800 tetap terjaga karena tiap transfer mengunci kedua rekening sekaligus.

Kebiasaan yang Mencegah Deadlock #

Kedua alat di atas paling berguna bila didukung kebiasaan desain:

  • Urutan kunci yang konsisten. Bila semua kode mengunci A sebelum B (tidak pernah sebaliknya), circular wait mustahil terbentuk. Aturan ini berlaku juga untuk hierarki besar: kunci level tinggi selalu sebelum level rendah.
  • scoped_lock untuk lebih dari satu mutex. Ia menggantikan pasangan std::lock + adopt_lock dengan satu baris, dan keliru pakai hampir mustahil.
  • Minimalkan jumlah kunci yang dipegang bersamaan. Bila bisa, siapkan semua nilai sebelum mengunci, lalu di dalam critical section hanya melakukan perubahan.
  • Waspadai kunci tersembunyi. Kode pustaka atau callback yang dipanggil di dalam critical section bisa mengunci mutex lain tanpa kita sadari; jalur deadlock sering bersembunyi di sana.

Mengunci dengan aman menyelesaikan persaingan data, tetapi sering kali thread juga perlu saling memberi tahu: “datamu sudah ada”. Menunggu sinyal tanpa membakar CPU adalah pekerjaan condition_variable — topik chapter berikutnya.

Referensi: cppreference — std::lock , cppreference — std::scoped_lock .