← Semua pembelajaran / Go Nol → Enterprise
Fase 2 · Concurrency

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.

Sumber asli go.dev Resmi Rangkuman ~7 menit baca

Intisari

  • go f() menjalankan f di 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.
  • main selesai = semua goroutine mati seketika, tanpa defer yang 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 OSGoroutine
Memori awal1–8 MBbeberapa KB, tumbuh sesuai kebutuhan
Dibuat olehKernelRuntime Go
Perpindahan konteksMahal (kernel)Murah (user space)
Jumlah yang wajarRatusanRatusan 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

  1. Setiap goroutine punya pemilik yang tahu kapan ia berhenti — lewat WaitGroup, errgroup, atau context.
  2. Setiap goroutine berumur panjang punya recover-nya sendiri (Fase 1) — kalau tidak, satu panic mematikan seluruh container.
  3. Jangan buat goroutine di dalam pustaka tanpa memberi pemanggilnya cara menghentikan.
  4. 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.