Gejala Deadlock: Silent Freeze Tanpa Log Panic

Layanan HTTP berbasis Actix Web mendadak berhenti merespons permintaan. Nginx di layer reverse proxy mulai mengembalikan status 504 Gateway Timeout. Monitoring sistem menunjukkan anomali berikut:

  • Penggunaan CPU anjlok mendekati 0%.
  • Alokasi memori stagnan, tidak ada lonjakan memory leak.
  • Tidak ada log panic, error trace, ataupun segment fault di konsol aplikasi.
  • Worker thread mati satu per satu secara bertahap seiring masuknya traffic konkuren hingga seluruh instance hang total.

Kondisi ini menandakan terjadinya deadlock di level OS thread. Runtime Rust tidak melakukan abort atau unwind ketika deadlock terjadi, melainkan membekukan thread eksekusi secara permanen.

Arsitektur Worker Actix Web dan Penonaktifan Batasan Send

Pada multi-threaded Tokio runtime standar, future yang dieksekusi via tokio::spawn harus memenuhi trait bound Send. Tipe std::sync::MutexGuard secara eksplisit tidak mengimplementasikan Send (bersifat !Send). Menahan guard tersebut melewati titik .await pada Tokio standar akan memicu error kompilasi:

error: future cannot be sent between threads safely
   = help: within `impl Future`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, ...>`

Rust compiler menolak kode tersebut untuk mencegah thread starvation dan race condition lintas thread. Namun, aturan ini tidak berlaku di Actix Web.

Actix Web menggunakan model arsitektur multi-worker independen. Server mengalokasikan sejumlah OS thread (sesuai core CPU) di mana setiap worker menjalankan instance runtime single-threaded sendiri (tokio::task::LocalSet via actix_rt). Karena task dalam satu worker tidak pernah dipindahkan (work-stealing) ke worker lain, future dalam request handler Actix Web tidak diwajibkan memiliki trait bound Send.

Dampaknya, pemanggilan std::sync::MutexGuard yang menyeberang titik .await lolos dari pemeriksaan compiler Rust tanpa peringatan apa pun.

Reproduksi Kode: Deadlock di Bawah Traffic Konkuren

Contoh implementasi rentan berikut mendemonstrasikan bagaimana deadlock terjadi saat dua request diproses oleh worker thread yang sama:

use actix_web::{web, App, HttpServer, HttpResponse, Responder};
use std::sync::{Arc, Mutex};
use std::time::Duration;

struct AppState {
    counter: Mutex<u64>,
}

async fn vulnerable_handler(data: web::Data<Arc<AppState>>) -> impl Responder {
    // Mengambil lock synchronous
    let mut lock = data.counter.lock().unwrap();
    *lock += 1;

    // Menahan MutexGuard menyeberangi titik suspensi .await
    tokio::time::sleep(Duration::from_millis(100)).await;

    HttpResponse::Ok().body(format!("Counter: {}", *lock))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let state = Arc::new(AppState {
        counter: Mutex::new(0),
    });

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(state.clone()))
            .route("/test", web::get().to(vulnerable_handler))
    })
    .workers(1) // Reproduksi deterministik menggunakan 1 worker
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

Root Cause: Mengapa Worker Thread Terkunci Total

Mekanisme kegagalan sistematis ini terjadi melalui rangkaian sekuens berikut:

  1. Request A masuk ke Worker Thread 0. Handler mengeksekusi lock() dan berhasil mendapatkan MutexGuard. Nilai counter dimodifikasi.
  2. Handler mencapai tokio::time::sleep().await. Request A menunda eksekusi (yielding) kembali ke reactor, tetapi guard belum di-drop sehingga kepemilikan lock tetap aktif di memori.
  3. Worker Thread 0 kini bebas mengeksekusi task lain yang antre di LocalSet. Request B masuk ke thread yang sama.
  4. Request B memanggil data.counter.lock().unwrap(). Karena tipe yang dipakai adalah std::sync::Mutex, pemanggilan ini langsung memblokir OS thread yang sedang berjalan di level kernel hingga lock dilepaskan.
  5. Deadlock tercipta: OS Worker Thread 0 diblokir oleh Request B. Sementara itu, Request A (pemilik lock sesungguhnya) tidak akan pernah bisa dijadwalkan ulang atau menyelesaikan operasi sleep-nya karena thread yang bertugas mem-poll future tersebut sedang macet total.

Worker thread tersebut mengalami hung permanen. Ketika seluruh worker pool Actix Web kehabisan thread aktif akibat skenario serupa, aplikasi berhenti melayani koneksi baru secara keseluruhan.

Solusi 1: Isolasi Scope MutexGuard (Rekomendasi)

Jangan pernah menahan lock melebihi waktu komputasi kritis yang dibutuhkan. Lepaskan MutexGuard sebelum mencapai titik .await menggunakan block scope eksplisit atau fungsi drop().

async fn fixed_handler_scope(data: web::Data<Arc<AppState>>) -> impl Responder {
    let current_val = {
        // Scope terbatas: guard langsung di-drop saat keluar dari kurung kurawal
        let mut lock = data.counter.lock().unwrap();
        *lock += 1;
        *lock
    };

    // Aman: guard sudah di-drop, thread bebas menjalankan task lain selama yield
    tokio::time::sleep(Duration::from_millis(100)).await;

    HttpResponse::Ok().body(format!("Counter: {}", current_val))
}

Pendekatan ini memiliki performa paling optimal karena std::sync::Mutex tidak memicu overhead alokasi memory future tambahan di heap.

Solusi 2: Migrasi ke tokio::sync::Mutex

Jika state shared data harus dipertahankan secara utuh melintasi operasi asynchronous (misalnya menahan lock saat membaca stream network atau transaksi I/O kompleks), gunakan tokio::sync::Mutex.

use actix_web::{web, App, HttpServer, HttpResponse, Responder};
use tokio::sync::Mutex; // Gunakan async mutex milik Tokio
use std::sync::Arc;
use std::time::Duration;

struct AppState {
    counter: Mutex<u64>,
}

async fn fixed_handler_async_mutex(data: web::Data<Arc<AppState>>) -> impl Responder {
    // Non-blocking lock. Jika terkunci, task akan yield alih-alih memblokir OS thread
    let mut lock = data.counter.lock().await;
    *lock += 1;

    tokio::time::sleep(Duration::from_millis(100)).await;

    HttpResponse::Ok().body(format!("Counter: {}", *lock))
}

Komparasi Penggunaan Mutex

Tipe MutexKarakteristik Saat Terjadi KontensiDampak Terhadap Worker LoopRekomendasi Penggunaan
std::sync::MutexMemblokir OS thread secara sinkronFatal jika ditahan lintas .await (Deadlock)Komputasi in-memory murni, durasi singkat tanpa operasi I/O async
tokio::sync::MutexYield task ke runtime secara non-blockingAman melintasi .awaitState harus dikunci saat menunggu proses asynchronous lain

Aturan Desain: Gunakan std::sync::Mutex untuk operasi CPU-bound singkat dan isolasi scopenya sebelum titik suspensi. Beralih ke tokio::sync::Mutex hanya jika state harus melintasi operasi I/O async.