Memory leak pada aplikasi Rust tidak selalu berarti ada alokasi heap yang benar-benar hilang tanpa referensi. Pada worker berbasis Tokio, gejala yang terlihat di produksi sering berupa memory leak semu: RSS naik pelan, throughput menurun, latensi memburuk, dan pod sering direstart oleh orchestrator, padahal proses tidak panic dan tidak ada error fatal yang jelas.
Penyebabnya sering lebih subtil: task yang tidak pernah selesai, channel yang tidak pernah ditutup, JoinHandle yang diabaikan, atau retry loop tanpa mekanisme pembatalan. Secara teknis, memori masih “dimiliki” oleh runtime dan objek masih direferensikan, jadi ini bukan leak klasik. Namun dari sudut pandang operasional, efeknya sama: konsumsi memori terus tumbuh dan worker makin tidak sehat.
Artikel ini membahas studi kasus debugging backend Rust pada worker Tokio, mulai dari gejala di produksi, pola kode penyebab, langkah investigasi, root cause teknis, perbaikan kode, hingga checklist pencegahan untuk tim backend.
Konteks arsitektur dan gejala di produksi
Bayangkan sebuah service worker Rust yang:
- Mengambil job dari broker atau stream internal.
- Memproses job secara asinkron.
- Mengirim hasil ke service lain melalui HTTP/gRPC.
- Memiliki retry saat downstream gagal.
Di produksi, gejalanya biasanya terlihat seperti ini:
- RSS naik perlahan selama beberapa jam atau hari.
- Throughput turun walau traffic relatif stabil.
- Pod restart karena limit memori tercapai atau liveness/readiness mulai gagal.
- Tidak ada panic, tidak ada crash dengan stack trace yang jelas.
- CPU belum tentu tinggi; kadang justru moderat tetapi jumlah task terus bertambah.
Pola seperti ini sering menyesatkan. Karena Rust punya ownership dan borrow checker, tim kadang langsung berasumsi “mustahil ada leak”. Padahal yang terjadi bukan pelanggaran safety, melainkan siklus hidup async yang tidak terkendali.
Kenapa Tokio worker bisa terlihat bocor memori?
Pada runtime async, memori dapat tertahan karena future, task, buffer, dan state machine async tetap hidup selama masih ada referensi atau selama task belum mencapai selesai. Beberapa pola umum:
- Task orphan: task di-spawn tetapi tidak pernah ditunggu hasilnya dan tidak punya jalur shutdown.
- Receiver tidak pernah selesai: loop
while let Some(...)menunggu channel yang tidak pernah ditutup karena sender masih tersimpan di tempat lain. - JoinHandle diabaikan: task hidup lebih lama dari request, job, atau lifecycle worker yang memicunya.
- Retry loop tanpa cancellation: task terus retry dengan backoff, tetapi context shutdown atau timeout tidak pernah diperiksa.
- Backlog channel: producer lebih cepat dari consumer, sehingga antrian di memori terus membesar.
Dalam semua kasus itu, allocator bisa saja bekerja normal. Masalahnya adalah objek-objek besar tetap dianggap masih aktif.
Contoh pola kode penyebab
1. JoinHandle diabaikan, task menjadi orphan
Contoh berikut terlihat lazim, tetapi berbahaya jika worker terus membuat task baru tanpa kontrol lifecycle:
use tokio::sync::mpsc;
async fn run_worker(mut rx: mpsc::Receiver<Job>) {
while let Some(job) = rx.recv().await {
tokio::spawn(async move {
if let Err(err) = process_job(job).await {
tracing::warn!(error = %err, "job gagal diproses");
}
});
}
}
Masalahnya bukan sekadar spawn, melainkan tidak ada pembatas konkurensi, tidak ada tracking JoinHandle, dan tidak ada shutdown signal. Jika process_job bisa macet pada I/O, retry, atau menunggu resource lain, jumlah task hidup akan terus bertambah.
2. Channel tidak pernah tertutup
use tokio::sync::mpsc;
struct Worker {
tx: mpsc::Sender<Event>,
}
async fn event_loop(mut rx: mpsc::Receiver<Event>) {
while let Some(event) = rx.recv().await {
handle_event(event).await;
}
}
Loop di atas hanya berhenti jika semua Sender sudah di-drop. Jika ada clone tx yang tersimpan di registry, singleton, cache task, atau closure retry, receiver akan terus menunggu selamanya. Ini sering membuat task pengolah event tidak pernah selesai, bersama semua state yang dibawanya.
3. Retry loop tanpa pembatalan
use tokio::time::{sleep, Duration};
async fn send_with_retry(payload: Payload) {
loop {
match call_downstream(&payload).await {
Ok(_) => return,
Err(err) => {
tracing::warn!(error = %err, "gagal kirim, retry lagi");
sleep(Duration::from_secs(5)).await;
}
}
}
}
Jika downstream bermasalah lama, task-task seperti ini akan menumpuk. Secara fungsional memang “masih bekerja”, tetapi dalam sistem produksi dengan traffic kontinu, pola ini dapat menahan payload, buffer serialisasi, metadata tracing, dan referensi lain jauh lebih lama dari yang diharapkan.
Langkah investigasi yang efektif
1. Mulai dari metrik operasional, bukan dugaan
Ketika melihat RSS naik, jangan langsung menyimpulkan ada bug allocator atau kebocoran heap murni. Kumpulkan dulu metrik berikut:
- RSS / memory working set per pod atau proses.
- Jumlah task aktif atau task in-flight.
- Panjang queue/channel bila tersedia di instrumentasi aplikasi.
- Throughput, latensi, dan error rate downstream.
- Retry count dan jumlah request timeout.
Jika memori naik bersamaan dengan jumlah task aktif atau backlog queue, itu indikasi kuat bahwa memori tertahan oleh workload yang tidak selesai.
2. Tambahkan logging lifecycle task
Banyak worker hanya mencatat error, tetapi tidak mencatat kapan task dibuat dan kapan task benar-benar selesai. Tambahkan log atau tracing span di dua titik itu.
use tracing::{info, instrument};
#[instrument(skip(job))]
async fn process_job(job: Job) -> Result<(), Error> {
info!(job_id = %job.id, "mulai proses job");
let result = do_process(job).await;
info!(job_id = %job.id, success = result.is_ok(), "selesai proses job");
result
}
Jika log “mulai” terus muncul tetapi log “selesai” tertinggal jauh, Anda punya petunjuk awal bahwa task menggantung atau tertahan terlalu lama.
3. Instrumentasi metrik in-flight
Pola sederhana tetapi sangat berguna adalah menghitung task in-flight secara eksplisit. Misalnya, naikkan counter saat task dibuat dan turunkan saat selesai. Bila angkanya hanya naik dan jarang turun, root cause biasanya dekat dengan lifecycle task.
Anda juga bisa menambahkan metrik untuk:
- Jumlah retry aktif.
- Jumlah request downstream yang sedang berjalan.
- Jumlah pesan di buffer internal.
- Jumlah worker loop yang aktif saat shutdown.
4. Gunakan tracing dan tokio-console bila memungkinkan
Untuk sistem async Rust, tokio-console sangat relevan karena membantu melihat task yang hidup terlalu lama, task yang idle, lokasi spawn, dan resource async yang berkaitan. Ini berguna saat gejala bukan panic, melainkan task yang diam-diam tidak pernah selesai.
Hal yang perlu dicari:
- Task dengan durasi hidup sangat panjang tanpa alasan bisnis yang jelas.
- Task yang terus berada dalam status menunggu event/channel.
- Task yang sering dibangunkan tetapi tidak membuat progres berarti.
- Lonjakan jumlah task setelah downstream mulai lambat.
Jika Anda tidak dapat memakai tokio-console di produksi, jalankan beban yang mirip di staging atau lingkungan reproduksi yang cukup representatif. Untuk kasus async, reproduksi di lokal sering terlalu kecil untuk memunculkan akumulasi task.
5. Heap profiling: konfirmasi apa yang tertahan
Bila perlu, lakukan heap profiling untuk melihat jenis alokasi yang dominan. Tujuannya bukan selalu mencari pointer bocor, melainkan menjawab pertanyaan: objek apa yang masih hidup ketika memori terus naik?
Misalnya, jika profiling menunjukkan banyak buffer request, payload job, atau state future async masih hidup, itu menguatkan hipotesis bahwa task tidak selesai atau antrean menumpuk.
Heap profiling paling berguna jika dikombinasikan dengan metrik task. Memori tinggi tanpa konteks runtime sering sulit diinterpretasikan.
Studi kasus root cause: receiver tidak tertutup dan retry task tidak punya shutdown
Dalam salah satu pola yang sering terjadi, worker memiliki loop utama yang menerima job lalu me-spawn task pengiriman hasil. Task tersebut menyimpan clone Sender untuk melaporkan status balik ke aggregator internal. Di sisi lain, aggregator menunggu receiver selesai agar dapat flush dan shutdown dengan bersih.
Kurang lebih bentuk masalahnya seperti ini:
use tokio::sync::{mpsc, watch};
use tokio::time::{sleep, Duration};
async fn run(
mut jobs: mpsc::Receiver<Job>,
status_tx: mpsc::Sender<Status>,
mut shutdown: watch::Receiver<bool>,
) {
while let Some(job) = jobs.recv().await {
let status_tx = status_tx.clone();
tokio::spawn(async move {
let payload = build_payload(&job);
loop {
match call_downstream(&payload).await {
Ok(_) => {
let _ = status_tx.send(Status::Done(job.id)).await;
break;
}
Err(err) => {
tracing::warn!(job_id = %job.id, error = %err, "retry downstream");
sleep(Duration::from_secs(5)).await;
}
}
}
});
}
// berharap loop status di tempat lain selesai setelah semua sender hilang
drop(status_tx);
}
Secara sekilas tampak aman. Tetapi ada dua masalah besar:
- Setiap task menyimpan clone
status_tx, sehingga receiver status tidak akan pernah selesai selama task-task retry itu masih hidup. - Retry loop tidak mendengarkan shutdown, sehingga saat worker diminta berhenti, task tetap hidup dan terus menahan payload, buffer, serta sender clone.
Akibatnya:
- Task lama tidak selesai.
- Receiver status tetap terbuka.
- State shutdown tidak pernah benar-benar bersih.
- Task baru terus berdatangan, memori naik perlahan.
Inilah contoh memory leak semu yang sangat umum: tidak ada panic, tidak ada unsafe, tetapi lifecycle resource tidak pernah berakhir.
Perbaikan kode yang lebih aman
1. Tambahkan jalur pembatalan yang nyata
Task retry harus bisa berhenti saat shutdown atau saat job sudah tidak relevan lagi. Gunakan select! untuk menunggu beberapa kondisi sekaligus.
use tokio::select;
use tokio::sync::{mpsc, watch};
use tokio::time::{sleep, Duration};
async fn send_with_retry(
job: Job,
status_tx: mpsc::Sender<Status>,
mut shutdown: watch::Receiver<bool>,
) {
let payload = build_payload(&job);
loop {
select! {
_ = shutdown.changed() => {
tracing::info!(job_id = %job.id, "task dibatalkan karena shutdown");
return;
}
result = call_downstream(&payload) => {
match result {
Ok(_) => {
let _ = status_tx.send(Status::Done(job.id)).await;
return;
}
Err(err) => {
tracing::warn!(job_id = %job.id, error = %err, "gagal kirim, akan retry");
}
}
}
}
select! {
_ = shutdown.changed() => {
tracing::info!(job_id = %job.id, "task dibatalkan saat backoff");
return;
}
_ = sleep(Duration::from_secs(5)) => {}
}
}
}
Kenapa ini bekerja? Karena task tidak lagi bergantung pada keberhasilan downstream untuk keluar. Ia punya kondisi terminasi kedua, yaitu shutdown.
2. Jangan abaikan JoinHandle jika lifecycle penting
Jika task memegang resource signifikan, simpan dan kelola JoinHandle-nya. Ini tidak selalu berarti harus menunggu semua task satu per satu di hot path, tetapi setidaknya ada mekanisme saat shutdown.
use tokio::task::JoinSet;
async fn run_worker(
mut jobs: mpsc::Receiver<Job>,
status_tx: mpsc::Sender<Status>,
shutdown: watch::Receiver<bool>,
) {
let mut join_set = JoinSet::new();
while let Some(job) = jobs.recv().await {
let tx = status_tx.clone();
let shutdown_rx = shutdown.clone();
join_set.spawn(send_with_retry(job, tx, shutdown_rx));
}
drop(status_tx);
while let Some(res) = join_set.join_next().await {
if let Err(err) = res {
tracing::warn!(error = %err, "task worker berakhir dengan error join");
}
}
}
Dengan pendekatan ini, task tidak menjadi orphan tanpa pengawasan. Anda juga punya titik yang jelas untuk menunggu semua task benar-benar selesai.
3. Batasi konkurensi dan backlog
Mencegah lebih baik daripada mengejar task yang keburu menumpuk. Gunakan batas konkurensi agar jumlah task aktif tetap terkendali. Misalnya dengan semaphore, worker pool tetap, atau stream dengan buffer terbatas.
Trade-off-nya jelas:
- Pro: memori lebih stabil, downstream tidak dibanjiri request.
- Kontra: throughput puncak bisa turun jika batas terlalu konservatif.
Namun dalam sistem produksi, perilaku yang stabil biasanya lebih penting daripada throughput teoritis yang hanya tercapai saat semua dependency sehat.
4. Tutup sender dengan disiplin
Jika receiver diharapkan selesai, semua sender harus di-drop pada waktu yang tepat. Kesalahan umum:
- Menyimpan clone sender di struct global tanpa lifecycle jelas.
- Menaruh sender di closure retry atau task background yang tidak pernah selesai.
- Tidak menyadari bahwa satu clone sender saja cukup untuk membuat receiver tetap hidup.
Aturan praktisnya: setiap clone channel harus punya owner dan alasan eksistensi yang jelas.
Verifikasi setelah fix
Jangan berhenti setelah kode berhasil dikompilasi atau tes unit lulus. Untuk kasus seperti ini, verifikasi harus berorientasi runtime.
1. Uji reproduksi dengan failure yang disengaja
Buat skenario di staging atau test environment:
- Downstream dibuat lambat atau gagal terus.
- Traffic dijaga konstan selama periode cukup panjang.
- Shutdown dikirim saat masih banyak job in-flight.
Perhatikan apakah:
- Jumlah task stabil atau kembali turun.
- Task retry benar-benar berhenti saat shutdown.
- Receiver loop selesai dengan wajar.
- RSS tidak terus merangkak naik tanpa batas.
2. Bandingkan metrik sebelum dan sesudah
Metrik yang sebaiknya dibandingkan:
- Task in-flight maksimum dan rata-rata.
- Retry aktif.
- Panjang queue internal.
- RSS setelah beberapa jam beban stabil.
- Waktu shutdown dan jumlah task tersisa saat terminasi.
Kalau root cause memang lifecycle task/channel, perbaikannya biasanya terlihat pada stabilitas jumlah task lebih dulu, lalu diikuti perbaikan memori.
3. Cek bahwa throughput tidak rusak akibat fix
Fix seperti pembatasan konkurensi atau timeout dapat menstabilkan memori, tetapi juga mungkin menurunkan throughput. Ini bukan berarti fix salah, namun harus dipahami trade-off-nya. Pastikan batas yang dipilih sesuai kapasitas downstream dan SLA service Anda.
Kesalahan umum saat debug memory leak pada Tokio worker
- Fokus hanya pada heap dump tanpa melihat jumlah task hidup.
- Menganggap semua kenaikan RSS adalah leak murni. Allocator, fragmentasi, dan cache juga bisa berperan, tetapi pola task yang tak selesai sering lebih dominan pada worker async.
- Tidak punya metrik lifecycle, sehingga sulit membedakan “kerja berat normal” dari “task menumpuk”.
- Menambahkan retry tanpa timeout dan cancellation.
- Mengabaikan clone sender/channel yang memperpanjang umur pipeline internal.
- Mengandalkan restart pod sebagai solusi. Ini hanya menyembunyikan gejala, bukan memperbaiki akar masalah.
Checklist pencegahan untuk tim Rust backend
- Setiap
tokio::spawnharus punya jawaban jelas untuk pertanyaan: kapan task ini selesai? - Jangan buat loop retry tanpa timeout, backoff, dan cancellation path.
- Instrumentasikan task in-flight, queue depth, dan retry count sejak awal.
- Gunakan batas konkurensi untuk workload yang dapat melonjak.
- Audit semua clone
Sender/Receiverpada pipeline internal. - Jangan abaikan
JoinHandleuntuk task yang memegang resource penting atau hidup lama. - Rancang prosedur shutdown yang eksplisit: stop menerima job baru, batalkan task retry, tunggu task aktif selesai, lalu tutup channel.
- Gunakan tracing span agar task dapat dihubungkan ke job ID, request ID, atau tenant tertentu.
- Jika memungkinkan, siapkan workflow observability untuk async runtime, termasuk tokio-console di lingkungan non-produksi.
Penutup
Debug memory leak pada Tokio worker yang tak pernah selesai hampir selalu berujung pada analisis lifecycle, bukan sekadar analisis alokasi. Saat RSS naik perlahan, throughput turun, dan pod sering restart tanpa panic, curigai task async yang tidak punya jalur selesai yang sehat.
Di Rust, keselamatan memori tidak otomatis berarti lifecycle concurrency Anda benar. Task orphan, channel yang tak pernah tertutup, JoinHandle yang diabaikan, dan retry loop tanpa pembatalan dapat menahan memori cukup lama hingga terlihat seperti kebocoran. Dengan kombinasi metrik, tracing, observability runtime, dan disiplin shutdown, kasus seperti ini biasanya bisa dipersempit dan diperbaiki secara sistematis.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!