Kami menghadapi layanan API Rust berbasis Tokio yang mendadak mengalami latensi ekstrem dan timeout request. Kondisi ini muncul saat shutdown terjadwal, terlihat dari tracing yang memperlihatkan gorong-gorong (task) hang pada operasi I/O. Artikel ini menunjukkan langkah debugging dan solusi konkrit agar deadlock I/O tidak mengganggu layanan.

1. Gejala lapangan dan metrik observasi

Awalnya alarm datang dari latency dashboard: p95 HTTP POST ke /orders melejit dari 120 ms menjadi >3 detik, sementara p99 bahkan mencapai timeout gateway. Log tracing mencatat banyak span async yang tidak pernah selesai, dan metric tokio_task_running tetap tinggi meskipun traffic rendah.

Grafik latency terkait shutdown window—short-lived deployment—menunjukkan bahwa gejala muncul setelah signal SIGTERM diterima. Saat shutdown berjalan, request baru tetap diterima dan akhirnya timeout karena worker tidak pernah bisa menyelesaikan response. Ini menandakan deadlock di jalur I/O yang menunggu resource internal (mutex/receiver).

2. Langkah reproduksi dan data observability

2.1 Reproduksi

Reproduksi dilakukan di staging:

  • Mulai layanan dengan cargo run dan load testing sederhana (wrk 5s).
  • Kirim SIGTERM, tunggu sampai shutdown dimulai.
  • Lanjutkan request POST selama shutdown.
  • Lihat sistem tidak menutup koneksi client dan request timeout.

2.2 Data tracing/log

Tracing span dengan tokio::instrument menunjukkan semua request tergantung di blok process_request yang memanggil:
let response = channel.send(req).await?;

Log tambahan mengindikasikan mpsc sender ditutup karena worker utama sudah keluar, sehingga send().await menunggu forever jika receiver juga sedang menunggu shutdown.

3. Analisis root cause

Stack trace dari trakcing:

tokio::sync::mpsc::Sender::send
service::api::handle_request
tokio::task::harness::poll

Sentral masalah: future yang mengirim request ke worker I/O tidak pernah selesai saat worker menunggu channel close. Worker sendiri menunggu mutex guard untuk menyelesaikan shutdown graceful, menyebabkan deadlock saat SIGTERM. Ada dua faktor utama:

  1. Awaiting send tanpa timeout saat receiver ditutup, sehingga sender menunggu sinyal selesai dari receiver.
  2. Worker menunggu Mutex yang di-hold oleh task lain yang juga menunggu channel selesai.

Perhatikan bahwa Tokio tidak membatalkan send().await walau receiver drop; ia hanya mengembalikan error setelah selesai. Jika receiver diblock lagi karena mutex, maka send pendukung pun deadlock.

4. Perilaku sebelum dan sesudah perbaikan

4.1 Sebelum: send await tanpa batas

async fn handle_request(...) {
    let request = RequestData::from_body(body);
    // send akan menunggu hingga receiver menerima, tapi receiver hang saat shutdown
    channel.send(request).await.map_err(|e| ...)?;
}

Dengan kode ini, request pending terus hingga timeout client, dan worker tidak bisa menyelesaikan shutdown karena receiver masih menunggu.

4.2 Setelah: bounded timeout dan refactor future

Solusi yang diterapkan:

  • Gunakan tokio::time::timeout pada send().await sehingga send gagal cepat saat receiver tidak merespons.
  • Status shutdown ditandai AtomicBool sehingga request baru menolak lebih awal.
  • Refactor worker agar tidak menunggu mutex utama saat sedang shutdown—gunakan tokio::select! dengan channel shutdown.

Contoh perbaikan:

async fn handle_request(...) -> Result {
    if SHUTTING_DOWN.load(Ordering::SeqCst) {
        return Err(Error::service_unavailable());
    }

    let send_future = channel.send(request);
    let response = tokio::time::timeout(Duration::from_millis(200), send_future)
        .await
        .map_err(|_| Error::timeout())?
        .map_err(|_| Error::service_unavailable())?;

    Ok(response)
}

Di sisi worker, shutdown channel digunakan untuk memutus loop tanpa menunggu mutex lain:

loop {
    tokio::select! {
        biased;
        _ = shutdown.recv() => break,
        Some(req) = receiver.recv() => process(req),
    }
}

5. Langkah perbaikan praktis setelah debugging

  • Refactor future agar tidak menunggu resource yang juga tergantung dari future lain—gunakan tokio::select! atau tokio::time::timeout.
  • Durasi timeout wajar pada channel send/receive agar service cepat mengembalikan error saat shutdown.
  • Observability: tambahkan metric untuk channel_send_latency dan counter shutdown_rejected_requests.
  • Graceful shutdown: gunakan atomic flag supaya entry baru bisa ditolak saat service sedang menutup, sehingga tidak menambah beban I/O.
  • Review dependency locking mencari mutex yang bisa blocking lalu diringankan atau diganti dengan struktur non-blocking.

Setelah langkah ini, latency kembali normal saat deployment, p95 tetap di bawah 200 ms, dan metric tracing menunjukkan send/receive tidak pernah pending lama. Debugging deadlock I/O membutuhkan observability yang lengkap serta refactor future agar tidak saling menunggu resource yang sama.