Goroutine — concurrency yang murah
Goroutine cuma butuh beberapa kilobita, jadi puluhan ribu sekaligus itu wajar. Justru karena murah, kesalahan yang paling sering terjadi adalah membuat goroutine yang tidak pernah selesai.
Intisari
go f()menjalankanfdi goroutine baru. Stack awalnya beberapa KB dan tumbuh sendiri.- Setiap permintaan HTTP sudah berjalan di goroutine-nya sendiri — kamu tidak perlu membuatnya.
- Goroutine yang bocor tidak pernah dikumpulkan sampah. Memori naik pelan sampai container mati.
mainselesai = semua goroutine mati seketika, tanpadeferyang dijalankan.- Aturan emas: siapa pun yang membuat goroutine harus tahu kapan dan bagaimana ia berhenti.
Menjalankan dan menunggu
go kirimEmail(u) // jalan sekarang, tidak ditunggu
// Menunggu sekelompok goroutine selesai
var wg sync.WaitGroup
for _, u := range pengguna {
wg.Add(1)
go func() {
defer wg.Done()
kirimEmail(u) // Go 1.22+: u aman dipakai langsung
}()
}
wg.Wait()
Kalau kamu membaca kode lama dengan go func(u User){...}(u) — parameter yang
dioper hanya untuk menghindari jebakan variabel loop. Sejak Go 1.22, variabel loop
dibuat baru setiap iterasi, jadi trik itu tidak diperlukan lagi. Ini salah satu perubahan yang paling
banyak menghapus bug nyata dalam sejarah Go.
Murah, tapi bukan gratis
| Thread OS | Goroutine | |
|---|---|---|
| Memori awal | 1–8 MB | beberapa KB, tumbuh sesuai kebutuhan |
| Dibuat oleh | Kernel | Runtime Go |
| Perpindahan konteks | Mahal (kernel) | Murah (user space) |
| Jumlah yang wajar | Ratusan | Ratusan ribu |
Penjadwal Go memetakan banyak goroutine ke sedikit thread OS. Saat sebuah goroutine menunggu I/O — kueri database, panggilan HTTP — thread-nya dipakai goroutine lain. Inilah alasan satu proses Go bisa menangani puluhan ribu koneksi tanpa konfigurasi apa pun, dan kenapa "worker mode" yang jadi topik besar di dunia PHP tidak pernah jadi topik di Go.
Yang perlu diketahui untuk Fase 10: jumlah thread yang benar-benar berjalan bersamaan dibatasi
GOMAXPROCS. Sejak Go 1.25, nilainya sadar container: ia
membaca batas CPU cgroup, bukan jumlah core mesin. Sebelum itu, aplikasi Go di ECS dengan 0,5 vCPU tetap
menyetel GOMAXPROCS ke jumlah core host — penyebab klasik latensi ekor yang buruk.
Kebocoran goroutine
// ❌ BOCOR — tidak ada yang membaca dari ch, goroutine ini menunggu selamanya
func bocor() {
ch := make(chan int)
go func() {
ch <- hitungLama() // menggantung di sini, sampai proses mati
}()
// fungsi keluar tanpa membaca ch
}
Goroutine yang menunggu channel yang tidak akan pernah ditulis/dibaca tidak pernah dibersihkan oleh garbage collector — ia bukan sampah, ia hidup. Gejalanya khas: memori dan jumlah goroutine naik pelan selama berhari-hari, lalu container di-OOM-kill.
// ✅ Dua perbaikan: buffer 1, ATAU context yang membatasi
func aman(ctx context.Context) (int, error) {
ch := make(chan int, 1) // pengirim tidak pernah tertahan
go func() { ch <- hitungLama() }()
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return 0, ctx.Err() // goroutine tetap bisa mengirim, lalu selesai
}
}
Mendeteksinya
# jumlah goroutine hidup — endpoint pprof (Fase 9)
curl localhost:6060/debug/pprof/goroutine?debug=1 | head -20
Go 1.26 menambahkan profil khusus kebocoran goroutine (eksperimental, aktif lewat
GOEXPERIMENT=goroutineleakprofile). Ia memanfaatkan garbage collector: kalau sebuah goroutine
menunggu primitif yang tidak mungkin lagi disentuh siapa pun, ia dilaporkan sebagai bocor. Ini
jauh lebih tajam daripada sekadar melihat grafik jumlah goroutine naik.
Tiga kesalahan yang wajar terjadi sekali
1. main tidak menunggu
func main() {
go kirimLaporan() // kemungkinan besar tidak pernah jalan
} // proses keluar; semua goroutine dimatikan seketika
Tidak ada defer yang dijalankan di goroutine yang mati begini. Untuk server, ini yang membuat
graceful shutdown (Fase 3) bukan hiasan: tanpa itu, permintaan yang sedang diproses terpotong di
tengah.
2. Menyebar ke goroutine yang salah
// ❌ handler HTTP yang memakai r.Context() di goroutine latar
go func() {
simpanAudit(r.Context(), data) // ctx dibatalkan begitu respons terkirim
}()
// ✅ konteks baru yang lepas dari umur permintaan
ctx := context.WithoutCancel(r.Context()) // Go 1.21+
go func() {
ctx, batal := context.WithTimeout(ctx, 10*time.Second)
defer batal()
simpanAudit(ctx, data)
}()
3. Tidak ada yang menangkap error
// ❌ error hilang, panic mematikan proses
go prosesBerkas(f)
// ✅ errgroup mengumpulkan error dan membatalkan sisanya
g, ctx := errgroup.WithContext(ctx)
for _, f := range berkas {
g.Go(func() error { return prosesBerkas(ctx, f) })
}
if err := g.Wait(); err != nil { ... }
Aturan yang menghindarkan semuanya
- Setiap goroutine punya pemilik yang tahu kapan ia berhenti — lewat
WaitGroup,errgroup, atau context. - Setiap goroutine berumur panjang punya
recover-nya sendiri (Fase 1) — kalau tidak, satu panic mematikan seluruh container. - Jangan buat goroutine di dalam pustaka tanpa memberi pemanggilnya cara menghentikan.
- Batasi jumlahnya. "Satu goroutine per item" pada daftar sejuta item akan membuka sejuta koneksi — dan database-mu yang mati duluan.
Yang tidak perlu kamu lakukan di aplikasi web: membuat goroutine untuk menangani
permintaan. net/http sudah menjalankan setiap permintaan di goroutine terpisah. Goroutine
tambahan hanya diperlukan saat kamu ingin melakukan beberapa hal di dalam satu permintaan secara
paralel — misalnya memanggil tiga layanan sekaligus.
Latihan: tulis program yang membuat 100.000 goroutine yang masing-masing menunggu channel yang
tidak pernah diisi, lalu cetak runtime.NumGoroutine() dan
runtime.ReadMemStats setiap detik. Amati memorinya. Lalu ganti channel-nya jadi berbuffer 1
dan isi semuanya — dan lihat angkanya kembali turun.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.