Goroutine leak merupakan salah satu penyebab utama degradasi stabilitas pada backend berbasis Go. Berbeda dengan alokasi heap biasa yang dapat dibersihkan oleh Garbage Collector (GC), runtime Go tidak akan pernah membebaskan memori milik goroutine yang masih aktif atau terblokir selamanya. Akibatnya, setiap leak membawa overhead stack minimal 2 KB beserta seluruh objek heap yang direferensikan dalam stack frame tersebut.
Gejala Sistem: Dari Degenerasi Latensi hingga Pod OOMKilled
Kebocoran goroutine pada beban produksi umumnya menunjukkan pola degradasi spesifik pada observabilitas sistem:
- Grafik Memori Linier: Metrik
container_memory_working_set_bytesmeningkat konstan tanpa kembali turun ke baseline setelah request rate mereda. - Latensi Naik Perlahan: Waktu Stop-The-World (STW) dan durasi mark phase pada GC bertambah lama seiring banyaknya stack goroutine yang harus dipindai oleh runtime.
- Terminasi OOMKilled: Kubernetes mengirimkan sinyal
SIGKILL(exit code 137) saat pemakaian memori container menabrak limit cgroup pods. - Metrik Goroutine Monoton Naik: Metrik
go_goroutinesdi Prometheus terus bertambah sebanding dengan volume request yang gagal atau timed out.
Analisis Root Cause: Unbuffered Channel Blocking
Penyebab paling sering adalah goroutine worker yang mencoba mengirim data ke unbuffered channel setelah context pemanggil dibatalkan. Pengiriman pada unbuffered channel bersifat sinkron dan memblokir eksekusi sampai ada receiver yang membaca data.
Jika pemanggil menghentikan eksekusi lebih cepat karena batas waktu context habis, receiver channel ditutup atau diabaikan. Goroutine pengirim akan tertahan di status chan send selamanya tanpa ada komponen yang membangunkannya.
Langkah Profiling dengan net/http/pprof
Gunakan package standar net/http/pprof untuk mengambil snapshot runtime goroutine secara langsung.
1. Pasang Profiler Endpoint
Jalankan server HTTP pada port internal terpisah untuk mengekspos rute pprof tanpa mengeksposnya ke publik:
package main
import (
"net/http"
_ "net/http/pprof"
)
func initBackgroundProfiling() {
go func() {
_ = http.ListenAndServe("localhost:6060", nil)
}()
}2. Ambil Text Dump Goroutine
Ketika pods menunjukkan gejala kebocoran, ambil stack trace lengkap dari semua goroutine yang sedang berjalan:
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=2" > goroutines_dump.txtNilai debug=2 memformat output menjadi teks biasa dengan stack trace lengkap setiap goroutine yang aktif.
3. Identifikasi Stack Trace Menggantung
Buka goroutines_dump.txt dan cari goroutine dengan jumlah duplikasi tinggi yang berada pada status terblokir:
4812 @ 0x43b2f5 0x44ae12 0x72a981 0x471841
# 0x72a980 main.fetchUserData.func1+0x100 /app/service/user.go:34
goroutine 18342 [chan send]:
main.fetchUserData.func1(0xc00010a060, 0xc000102060)
/app/service/user.go:34 +0x100
created by main.fetchUserData
/app/service/user.go:28 +0x85Angka 4812 di awal menunjukkan terdapat 4.812 goroutine yang terhenti pada baris kode yang sama di /app/service/user.go:34 dengan status [chan send].
Solusi Kode: Sebelum vs Sesudah
Kode di bawah menunjukkan pola pemanggilan paralel data user yang rentan bocor dan perbaikannya.
Kode Sebelum Perbaikan (Vulnerable)
func fetchUserData(ctx context.Context, userID string) (*UserData, error) {
ch := make(chan *UserData)
errCh := make(chan error)
go func() {
data, err := queryExternalAPI(userID)
if err != nil {
errCh <- err // Terblokir jika ctx timeout terjadi sebelum baris ini
return
}
ch <- data // Terblokir jika timeout
}()
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-errCh:
return nil, err
case res := <-ch:
return res, nil
}
}Jika queryExternalAPI memerlukan 2 detik sementara ctx memiliki batas waktu 500 ms, block select utama selesai membaca ctx.Done() dan keluar dari fungsi. Goroutine latar belakang kemudian terbangun, mencoba mengeksekusi ch <- data, lalu terblokir selamanya karena receiver sudah hilang.
Kode Sesudah Perbaikan (Corrected)
Gunakan statement select pada channel write untuk mendeteksi pembatalan context:
func fetchUserData(ctx context.Context, userID string) (*UserData, error) {
ch := make(chan *UserData, 1)
errCh := make(chan error, 1)
go func() {
data, err := queryExternalAPI(userID)
if err != nil {
select {
case errCh <- err:
case <-ctx.Done():
}
return
}
select {
case ch <- data:
case <-ctx.Done():
}
}()
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-errCh:
return nil, err
case res := <-ch:
return res, nil
}
}Catatan: Menggunakan channel dengan buffer ukuran 1 (make(chan *UserData, 1)) juga mencegah goroutine terblokir jika pengiriman data dilakukan tepat sekali sebelum fungsi keluar. Kombinasi buffer dan selectctx.Done()memberikan keamanan ganda saat menangani operasi konkuren.
Pencegahan Otomatis via uber-go/goleak
Mencegah regresi goroutine leak dapat diotomatisasi pada tahap Continuous Integration (CI) menggunakan library go.uber.org/goleak di dalam pengujian unit.
Implementasi Unit Test
Pasang modul:
go get go.uber.org/goleakTambahkan verifikasi leak pada unit test:
package main
import (
"context"
"testing"
"time"
"go.uber.org/goleak"
)
func TestFetchUserData_ContextCancelNoLeak(t *testing.T) {
defer goleak.VerifyNone(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
_, _ = fetchUserData(ctx, "user-123")
// Beri waktu goroutine internal merespons sinyal cancel
time.Sleep(50 * time.Millisecond)
}Jika goroutine worker gagal berhenti ketika context timeout terpenuhi, goleak.VerifyNone menggagalkan test dan mencetak stack trace goroutine yang tertinggal.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!