Debug deadlock Rust backend sering terasa sulit karena gejalanya tidak selalu berupa crash. Pada banyak layanan nyata, proses justru tetap hidup, health check kadang masih hijau, tetapi request menumpuk, worker berhenti memproses job, dan latensi melonjak tanpa error yang jelas.
Artikel ini membahas studi kasus backend Rust yang mengalami intermittent hang pada worker API dan job processor. Fokusnya bukan pada teori sinkronisasi secara umum, melainkan pada cara menyelidiki insiden, mengumpulkan bukti teknis, menemukan akar masalah berupa lock ordering yang tidak konsisten dan kombinasi Mutex + channel, lalu menutup celahnya dengan desain yang lebih aman.
Konteks insiden: gejala yang terlihat di produksi
Bayangkan sebuah layanan backend Rust dengan dua komponen utama:
- API worker yang menerima request untuk membuat atau memperbarui job.
- Job processor yang mengambil job dari antrean internal, memperbarui status, lalu mengirim hasil ke komponen lain.
Arsitekturnya sederhana: ada state bersama di memori yang dibungkus Arc<Mutex<...>>, lalu ada channel untuk memberi sinyal antar-thread. Pada traffic normal semuanya terlihat baik. Masalah muncul saat beban meningkat atau ada pola request tertentu.
Gejala utama
- Request API tertentu timeout, tetapi proses tidak crash.
- Panjang antrean job naik terus.
- CPU tidak tinggi, menandakan bukan busy loop.
- Beberapa thread tampak idle, tetapi progres sistem berhenti.
- Restart layanan langsung memulihkan kondisi, sampai insiden berikutnya muncul lagi.
Polanya khas untuk deadlock atau lock convoy berat. Pada kasus ini, masalah ternyata bukan satu mutex tunggal yang lambat, melainkan dua sumber sinkronisasi yang saling menunggu: urutan lock yang berbeda antar jalur kode dan pengiriman pesan lewat channel saat lock masih dipegang.
Kronologi debugging: dari gejala ke bukti
Saat menghadapi hang intermiten, kesalahan umum adalah langsung mengubah kode tanpa bukti. Pendekatan yang lebih aman adalah membangun garis waktu insiden.
1. Mulai dari sinyal operasional
Data awal yang berguna biasanya berasal dari metrik:
- latensi p95/p99 endpoint yang membuat job,
- jumlah job dalam antrean,
- jumlah worker aktif vs job yang selesai per menit,
- waktu tunggu lock bila tersedia lewat instrumentasi internal,
- jumlah request timeout atau dibatalkan.
Jika antrean naik, throughput turun, dan CPU tidak naik signifikan, dugaan ke arah deadlock atau blocking wait menjadi lebih kuat.
2. Tambahkan log berstruktur di titik kritis
Log biasa sering tidak cukup. Yang dibutuhkan adalah log berstruktur dengan konteks konsisten: request_id, job_id, nama thread, operasi lock, dan durasi.
use std::time::Instant;
use tracing::{info, warn};
fn update_job(job_id: &str) {
let start = Instant::now();
info!(%job_id, "acquiring jobs lock");
let jobs = JOBS.lock().unwrap();
info!(%job_id, wait_ms = start.elapsed().as_millis(), "jobs lock acquired");
// ... operasi penting
drop(jobs);
info!(%job_id, "jobs lock released");
}Tujuannya bukan membanjiri log, tetapi memberi jejak kapan lock dicoba, kapan berhasil, dan apakah ada operasi lambat di dalam critical section.
3. Tambahkan trace per alur request dan worker
Bila layanan sudah memakai structured tracing, buat span untuk alur penting:
- request masuk,
- job dibuat,
- job dikirim ke worker,
- status job diperbarui,
- notifikasi hasil dikirim.
Dengan trace, Anda bisa melihat titik terakhir sebelum progres berhenti. Sering kali thread berhenti tepat setelah log seperti acquiring state lock atau sebelum sending result to dispatcher.
4. Ambil dump stack saat layanan hang
Jika proses masih hidup tetapi tidak progres, dump stack sangat berharga. Tujuannya adalah melihat thread sedang berhenti di mana: menunggu mutex, menunggu receive channel, atau memegang lock lalu melakukan operasi blocking lain.
Pada Linux, tim biasanya menggunakan debugger atau mekanisme dump thread stack dari proses yang sedang berjalan. Detail perintah bergantung pada lingkungan debug dan simbol binary yang tersedia, jadi yang penting adalah prinsipnya: ambil snapshot beberapa kali dalam jeda singkat dan bandingkan apakah thread yang sama terus menunggu di call stack yang sama.
Pola yang sering muncul:
- Thread A memegang
jobs, lalu menunggusessions. - Thread B memegang
sessions, lalu menunggujobs. - Thread C memegang mutex lalu memanggil
sendataurecvyang ternyata menunggu progres dari thread lain.
5. Cocokkan dengan metrik antrean
Deadlock backend jarang berdiri sendiri. Biasanya ada dampak sistemik:
- antrean internal bertambah,
- waktu tunggu job meningkat tajam,
- jumlah job selesai mendekati nol,
- request baru ikut tertahan karena membaca state yang sama.
Korelasi antara stack dump, log lock acquisition, dan metrik antrean biasanya cukup untuk membedakan deadlock dari bottleneck performa biasa.
Akar masalah: lock ordering tidak konsisten
Kasus pertama berasal dari dua mutex pada state global:
jobs: Mutex<HashMap<JobId, JobState>>sessions: Mutex<HashMap<SessionId, SessionState>>
Masalah muncul karena dua jalur kode mengambil lock dalam urutan berbeda.
Contoh kode sebelum perbaikan
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
type JobId = String;
type SessionId = String;
#[derive(Default)]
struct AppState {
jobs: Mutex<HashMap<JobId, String>>,
sessions: Mutex<HashMap<SessionId, String>>,
}
fn attach_job_to_session(state: &Arc<AppState>, job_id: &str, session_id: &str) {
let mut jobs = state.jobs.lock().unwrap();
jobs.insert(job_id.to_string(), "running".to_string());
let mut sessions = state.sessions.lock().unwrap();
sessions.insert(session_id.to_string(), format!("job:{job_id}"));
}
fn cancel_session_jobs(state: &Arc<AppState>, session_id: &str, job_id: &str) {
let mut sessions = state.sessions.lock().unwrap();
sessions.remove(session_id);
let mut jobs = state.jobs.lock().unwrap();
jobs.insert(job_id.to_string(), "cancelled".to_string());
}Secara lokal, masing-masing fungsi tampak benar. Tetapi secara bersamaan:
- Thread A masuk ke
attach_job_to_session, memegangjobs, lalu menunggusessions. - Thread B masuk ke
cancel_session_jobs, memegangsessions, lalu menunggujobs.
Inilah deadlock klasik: circular wait akibat lock ordering yang tidak konsisten.
Mengapa ini sering luput saat review
- Setiap fungsi terlihat masuk akal jika dilihat terpisah.
- Test unit tunggal jarang menjalankan interleaving yang memicu deadlock.
- Insiden hanya muncul pada timing tertentu di bawah concurrency nyata.
Akar masalah kedua: mutex + channel dalam critical section
Masalah kedua lebih halus. Sebuah worker memegang lock state, lalu mengirim notifikasi ke channel. Di sisi lain, consumer channel butuh lock yang sama untuk memproses notifikasi itu. Jika channel dibatasi kapasitasnya atau alur penerima tersendat, sistem bisa macet.
Contoh pola bermasalah
use std::sync::{mpsc::SyncSender, Arc, Mutex};
struct Shared {
pending: Mutex<Vec<String>>,
}
fn produce(shared: &Arc<Shared>, tx: &SyncSender<String>, item: String) {
let mut pending = shared.pending.lock().unwrap();
pending.push(item.clone());
// Bermasalah: operasi blocking saat lock masih dipegang.
tx.send(item).unwrap();
}
fn consume(shared: &Arc<Shared>, msg: String) {
let mut pending = shared.pending.lock().unwrap();
pending.retain(|x| x != &msg);
}Jika send menunggu karena channel penuh, producer tetap memegang pending. Consumer yang perlu mengosongkan antrean mungkin tidak bisa lanjut karena butuh lock itu juga. Secara praktik, ini bisa berubah menjadi deadlock atau hang panjang yang tampak seperti deadlock.
Prinsip dasarnya: jangan lakukan operasi blocking saat memegang mutex, terutama jika operasi itu bergantung pada komponen yang pada akhirnya juga mengakses state yang sama.
Langkah perbaikan yang efektif
1. Tetapkan lock ordering global
Jika memang harus memakai lebih dari satu mutex, buat urutan global yang tidak boleh dilanggar. Misalnya:
Aturan: selalu ambil
sessionslebih dulu, lalujobs. Tidak ada pengecualian.
Lalu audit semua jalur kode agar mengikuti aturan tersebut.
fn attach_job_to_session(state: &Arc<AppState>, job_id: &str, session_id: &str) {
let mut sessions = state.sessions.lock().unwrap();
let mut jobs = state.jobs.lock().unwrap();
jobs.insert(job_id.to_string(), "running".to_string());
sessions.insert(session_id.to_string(), format!("job:{job_id}"));
}
fn cancel_session_jobs(state: &Arc<AppState>, session_id: &str, job_id: &str) {
let mut sessions = state.sessions.lock().unwrap();
let mut jobs = state.jobs.lock().unwrap();
sessions.remove(session_id);
jobs.insert(job_id.to_string(), "cancelled".to_string());
}Ini tidak otomatis membuat sistem ideal, tetapi menghilangkan circular wait dari dua lock tersebut.
2. Perkecil critical section
Semakin lama lock dipegang, semakin besar peluang kontensi dan deadlock terselubung. Pindahkan operasi yang tidak perlu keluar dari area lock.
fn produce(shared: &Arc<Shared>, tx: &std::sync::mpsc::SyncSender<String>, item: String) {
{
let mut pending = shared.pending.lock().unwrap();
pending.push(item.clone());
} // lock dilepas di sini
if let Err(err) = tx.send(item) {
// tangani error pengiriman
eprintln!("send failed: {err}");
}
}Perubahan ini sederhana tetapi penting: channel tidak lagi dipanggil saat lock masih dipegang.
3. Tambahkan timeout pada operasi tunggu
Timeout bukan solusi utama untuk deadlock, tetapi sangat membantu untuk:
- mencegah hang tanpa batas,
- menghasilkan sinyal error yang bisa dimonitor,
- mengumpulkan bukti saat sistem mendekati kondisi macet.
Pada channel atau operasi sinkronisasi tertentu, pilih API yang mendukung timeout bila tersedia. Jika memakai worker loop, Anda juga bisa membungkus operasi dengan deadline internal dan mencatat peringatan saat melebihi ambang wajar.
Catatan: timeout hanya mengurangi blast radius. Jika akar masalah adalah urutan lock yang salah, timeout tidak menggantikan perbaikan desain.
4. Refactor state bersama ke message passing
Perbaikan paling kuat pada kasus ini adalah mengurangi kebutuhan lock silang. Alih-alih banyak thread memodifikasi beberapa struktur data bersama, pindahkan kepemilikan state ke satu komponen pengelola, lalu thread lain berinteraksi lewat pesan.
Model ini cocok dengan kekuatan Rust: memperjelas kepemilikan data dan mengurangi sinkronisasi manual di banyak titik.
Contoh refactor ke message passing
use std::collections::HashMap;
use std::sync::mpsc::{channel, Receiver, Sender};
use std::thread;
type JobId = String;
type SessionId = String;
enum Command {
AttachJob {
job_id: JobId,
session_id: SessionId,
},
CancelJob {
job_id: JobId,
session_id: SessionId,
},
}
struct StateOwner {
jobs: HashMap<JobId, String>,
sessions: HashMap<SessionId, String>,
}
impl StateOwner {
fn new() -> Self {
Self {
jobs: HashMap::new(),
sessions: HashMap::new(),
}
}
fn run(mut self, rx: Receiver<Command>) {
while let Ok(cmd) = rx.recv() {
match cmd {
Command::AttachJob { job_id, session_id } => {
self.jobs.insert(job_id.clone(), "running".into());
self.sessions.insert(session_id, format!("job:{job_id}"));
}
Command::CancelJob { job_id, session_id } => {
self.sessions.remove(&session_id);
self.jobs.insert(job_id, "cancelled".into());
}
}
}
}
}
fn start_state_owner() -> Sender<Command> {
let (tx, rx) = channel();
thread::spawn(move || {
StateOwner::new().run(rx);
});
tx
}Pada desain ini, jobs dan sessions tidak lagi diakses langsung dari banyak thread. Hanya satu state owner yang memodifikasi keduanya, sehingga masalah lock ordering hilang untuk bagian itu.
Kapan message passing lebih baik daripada mutex bersama?
- Saat beberapa struktur data harus selalu konsisten satu sama lain.
- Saat banyak thread saling berbagi write access ke state yang sama.
- Saat alur kerja memang berbasis event atau perintah.
Sebaliknya, mutex tetap masuk akal untuk state kecil dengan critical section pendek dan akses yang sangat lokal. Refactor ke message passing menambah kompleksitas arsitektur, jadi pilih saat manfaatnya jelas.
Checklist verifikasi setelah perbaikan
Setelah patch dibuat, jangan berhenti di “sudah tidak reproduksi sekali”. Gunakan checklist verifikasi berikut:
- Semua lokasi yang mengambil lebih dari satu lock sudah mengikuti urutan global yang sama.
- Tidak ada operasi blocking di dalam critical section, termasuk
send,recv, I/O, sleep, atau pemanggilan callback eksternal. - Log berstruktur mencatat durasi tunggu lock dan operasi worker penting.
- Metrik antrean, throughput, dan timeout tersedia di dashboard.
- Health check membedakan proses hidup dari progres sistem yang benar-benar berjalan.
- Beban uji paralel dijalankan untuk pola request yang dulu sering memicu hang.
Test regresi untuk mencegah bug serupa
Deadlock sulit diuji secara deterministik, tetapi bukan berarti tidak bisa diuji sama sekali. Strateginya adalah menggabungkan beberapa jenis test.
1. Test invariants pada state owner
Jika sudah refactor ke message passing, uji bahwa urutan perintah tetap menjaga konsistensi state tanpa perlu lock silang.
#[test]
fn attach_then_cancel_updates_state_consistently() {
let mut state = StateOwner::new();
state.jobs.insert("job-1".into(), "queued".into());
state.sessions.insert("sess-1".into(), "idle".into());
state.jobs.insert("job-1".into(), "running".into());
state.sessions.insert("sess-1".into(), "job:job-1".into());
state.sessions.remove("sess-1");
state.jobs.insert("job-1".into(), "cancelled".into());
assert_eq!(state.jobs.get("job-1").unwrap(), "cancelled");
assert!(!state.sessions.contains_key("sess-1"));
}Contoh ini sederhana, tetapi intinya adalah menguji aturan bisnis tanpa kompleksitas sinkronisasi.
2. Stress test concurrency
Buat test atau harness terpisah yang menjalankan banyak operasi paralel berulang kali. Tujuannya bukan membuktikan ketiadaan deadlock secara matematis, melainkan meningkatkan peluang menangkap interleaving berbahaya.
- jalankan banyak producer dan consumer sekaligus,
- tambahkan delay kecil di titik rawan untuk memaksa interleaving,
- verifikasi semua thread selesai dalam batas waktu tertentu.
3. Test timeout dan degradasi aman
Jika sistem kini memakai timeout, pastikan timeout menghasilkan perilaku yang terukur:
- request gagal dengan error yang jelas,
- worker tidak menggantung selamanya,
- metric counter untuk timeout naik,
- log menyimpan konteks yang cukup untuk investigasi.
Tips debugging praktis di backend Rust
Berikan nama pada thread atau task penting
Nama thread mempermudah pembacaan log dan dump stack. Bahkan jika Anda memakai runtime async, tetap beri identitas yang jelas pada worker dan pipeline.
Instrumentasi lock lebih berguna daripada log umum
Daripada mencetak banyak event bisnis, prioritaskan log di titik berikut:
- sebelum mencoba lock,
- setelah lock didapat,
- saat lock dilepas,
- durasi lock dipegang,
- durasi operasi channel atau antrean.
Curigai callback dan notifikasi sinkron
Deadlock sering tidak muncul langsung di mutex utama, melainkan di callback, event bus internal, atau notifikasi yang diam-diam memanggil kembali komponen yang memegang state bersama.
Pisahkan “state mutation” dari “side effect” eksternal
Update state internal sebaiknya cepat dan terlokalisasi. Pengiriman notifikasi, I/O, atau operasi berat dilakukan setelah lock dilepas atau didelegasikan ke worker lain.
Pelajaran desain agar deadlock tidak terulang
- Definisikan aturan sinkronisasi sebagai bagian dari desain, bukan pengetahuan implisit di kepala tim.
- Batasi jumlah lock yang harus diambil bersamaan. Makin sedikit lock silang, makin kecil ruang deadlock.
- Jangan blokir saat memegang lock, termasuk channel blocking dan I/O.
- Pilih message passing saat state bersama kompleks dan sering ditulis dari banyak alur.
- Siapkan observability sejak awal: log berstruktur, trace, metrik antrean, dan indikator progres worker.
- Tambahkan stress test regresi setelah insiden, bukan hanya test unit fungsional.
Penutup
Debug deadlock Rust backend menuntut disiplin observability dan desain sinkronisasi yang jelas. Pada studi kasus ini, akar masalah bukan bug acak, melainkan dua pola yang sangat umum: urutan lock yang tidak konsisten dan operasi channel blocking saat mutex masih dipegang.
Perbaikannya juga bersifat sistematis: tetapkan lock ordering, perkecil critical section, gunakan timeout untuk membatasi dampak, lalu refactor bagian state yang rumit ke message passing. Hasil yang dicari bukan sekadar insiden hilang untuk sementara, tetapi backend yang lebih mudah dipahami, diuji, dan dipulihkan saat concurrency mulai bergerak ke kasus tepi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!