BAB 22: Goroutine — Konkurensi di Go
Pahami cara Go menjalankan banyak pekerjaan secara bersamaan menggunakan goroutine, unit ringan konkurensi bawaan runtime Go.
Reflect di bab sebelumnya memberi program kemampuan untuk melihat ke dalam dirinya sendiri saat runtime. Kali ini kita bergerak ke arah yang berbeda — bukan tentang introspeksi, tapi tentang eksekusi. Selama dua puluh satu bab, semua kode yang kita tulis berjalan satu langkah demi satu langkah: satu instruksi selesai, baru instruksi berikutnya dimulai. Model ini sederhana dan mudah dipahami, tapi tidak efisien untuk pekerjaan yang bisa dikerjakan bersamaan.
Go dirancang sejak awal untuk konkurensi. Bukan sebagai fitur tambahan, tapi sebagai bagian inti dari bahasa. Dan unit paling fundamental dari konkurensi di Go adalah goroutine.
Apa Itu Goroutine?
Goroutine adalah fungsi yang dijalankan secara concurrent oleh Go runtime — artinya bisa berjalan bersamaan dengan fungsi lain tanpa harus saling menunggu. Secara konseptual mirip dengan thread di sistem operasi, tapi jauh lebih ringan.
Satu thread OS biasanya membutuhkan memori sekitar 1–2MB untuk stack-nya. Goroutine dimulai dengan stack yang jauh lebih kecil — sekitar 2–8KB — dan bisa tumbuh sesuai kebutuhan. Go runtime bisa menjalankan ribuan bahkan jutaan goroutine secara bersamaan pada sejumlah thread OS yang jauh lebih sedikit.
Cara membuat goroutine sangat sederhana: tambahkan keyword go sebelum pemanggilan fungsi.
// main.go
package main
import "fmt"
func cekStok(namaGudang string) {
fmt.Printf("mengecek stok di gudang %s\n", namaGudang)
}
func main() {
go cekStok("Jakarta")
go cekStok("Surabaya")
go cekStok("Medan")
fmt.Println("permintaan cek stok dikirim")
}
permintaan cek stok dikirim
Hanya satu baris yang tercetak. Ketiga goroutine diluncurkan, tapi main selesai sebelum mereka sempat mencetak apapun. Ini adalah perilaku yang disengaja — goroutine berjalan asynchronous, dan saat main keluar, semua goroutine yang masih aktif langsung dihentikan.
Program Go berhenti ketika fungsi main selesai, berapapun goroutine yang masih berjalan. Ini bukan bug — ini kontrak eksplisit antara programmer dan runtime. Kamu bertanggung jawab untuk menunggu goroutine selesai jika hasilnya penting.
Mengontrol Jumlah CPU
Go runtime secara default menggunakan semua CPU yang tersedia. Kamu bisa mengecek dan mengatur jumlah ini lewat package runtime.
// main.go
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("CPU tersedia:", runtime.NumCPU())
fmt.Println("GOMAXPROCS saat ini:", runtime.GOMAXPROCS(0))
// atur ke 2 core
runtime.GOMAXPROCS(2)
fmt.Println("GOMAXPROCS setelah diubah:", runtime.GOMAXPROCS(0))
}
CPU tersedia: 8
GOMAXPROCS saat ini: 8
GOMAXPROCS setelah diubah: 2
runtime.GOMAXPROCS(0) dengan argumen 0 hanya membaca nilai saat ini tanpa mengubahnya. Untuk kebutuhan sehari-hari, biarkan nilainya default — Go sudah cukup pintar mengoptimalkan penggunaan CPU.
Goroutine Anonim
Tidak harus mendefinisikan fungsi terpisah untuk membuat goroutine. Fungsi anonim bisa langsung diluncurkan sebagai goroutine, berguna untuk pekerjaan kecil yang tidak perlu nama tersendiri.
// main.go
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
tugas := []string{"ekspor-laporan", "kirim-notifikasi", "perbarui-cache"}
for _, t := range tugas {
wg.Add(1)
go func(nama string) {
defer wg.Done()
fmt.Printf("menjalankan tugas: %s\n", nama)
}(t) // teruskan nilai t sebagai argumen
}
wg.Wait()
fmt.Println("semua tugas selesai")
}
menjalankan tugas: kirim-notifikasi
menjalankan tugas: ekspor-laporan
menjalankan tugas: perbarui-cache
semua tugas selesai
Ada dua hal penting di contoh ini. Pertama, variabel loop t diteruskan sebagai argumen ke fungsi anonim — bukan di-capture langsung. Ini mencegah semua goroutine membaca nilai t yang sama (nilai terakhir dari loop). Kedua, kita menggunakan sync.WaitGroup untuk menunggu semua goroutine selesai.
Jangan capture variabel loop langsung di dalam goroutine anonim. Tulis go func(nama string) { ... }(t) bukan go func() { fmt.Println(t) }(). Tanpa melewatkan argumen, semua goroutine bisa berakhir membaca nilai terakhir dari t karena loop sudah selesai sebelum goroutine sempat berjalan.
Sinkronisasi dengan WaitGroup
sync.WaitGroup adalah cara idiomatik Go untuk menunggu sekumpulan goroutine selesai. Cara kerjanya seperti counter:
wg.Add(n)— tambahkannke counter (biasanya dipanggil sebelum meluncurkan goroutine)wg.Done()— kurangi counter sebesar 1 (dipanggil di dalam goroutine saat selesai)wg.Wait()— blokir sampai counter mencapai nol
// main.go
package main
import (
"fmt"
"sync"
)
func prosesTransaksi(id int, nominal float64, wg *sync.WaitGroup) {
defer wg.Done() // pastikan selalu terpanggil
fmt.Printf("transaksi #%03d: Rp%.0f diproses\n", id, nominal)
}
func main() {
var wg sync.WaitGroup
transaksi := []float64{125000, 87500, 340000, 15000, 210000}
for i, nominal := range transaksi {
wg.Add(1)
go prosesTransaksi(i+1, nominal, &wg)
}
wg.Wait()
fmt.Printf("semua %d transaksi selesai diproses\n", len(transaksi))
}
transaksi #002: Rp87500 diproses
transaksi #001: Rp125000 diproses
transaksi #005: Rp210000 diproses
transaksi #003: Rp340000 diproses
transaksi #004: Rp15000 diproses
semua 5 transaksi selesai diproses
Perhatikan defer wg.Done() — defer memastikan Done selalu dipanggil meski fungsi berakhir karena panic. Ini adalah pola defensif yang sangat dianjurkan. Juga perhatikan bahwa wg dikirim sebagai pointer (*sync.WaitGroup) — WaitGroup tidak boleh di-copy, harus selalu dioperasikan lewat pointer atau variabel aslinya.
WaitGroup vs Channel
WaitGroup cocok ketika kamu hanya perlu menunggu goroutine selesai tanpa perlu datanya. Jika goroutine perlu mengembalikan hasil, gunakan channel (yang akan kita bahas di bab berikutnya). Keduanya sering dipakai bersama dalam program yang lebih kompleks.
Race Condition — Bahaya yang Tersembunyi
Konkurensi membawa risiko: race condition terjadi ketika dua atau lebih goroutine mengakses variabel yang sama dan setidaknya satu di antaranya melakukan penulisan, tanpa koordinasi yang tepat.
// main.go — KODE BERMASALAH
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
totalPenjualan := 0
for i := 0; i < 100; i++ {
wg.Add(1)
go func(nilai int) {
defer wg.Done()
totalPenjualan += nilai // race condition di sini
}(i + 1)
}
wg.Wait()
fmt.Println("total penjualan:", totalPenjualan)
}
Jalankan program ini beberapa kali dan hasilnya akan berbeda-beda — karena banyak goroutine membaca dan menulis totalPenjualan secara bersamaan tanpa koordinasi. Go menyediakan race detector untuk mendeteksinya:
go run -race main.go
Solusinya bisa menggunakan sync.Mutex untuk mengunci akses, atau sync/atomic untuk operasi atomik pada tipe numerik sederhana.
// main.go — diperbaiki dengan Mutex
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var mu sync.Mutex
totalPenjualan := 0
for i := 0; i < 100; i++ {
wg.Add(1)
go func(nilai int) {
defer wg.Done()
mu.Lock()
totalPenjualan += nilai
mu.Unlock()
}(i + 1)
}
wg.Wait()
fmt.Println("total penjualan:", totalPenjualan)
}
total penjualan: 5050
mu.Lock() memastikan hanya satu goroutine yang bisa mengakses totalPenjualan pada satu waktu. Goroutine lain akan menunggu sampai mu.Unlock() dipanggil.
Selalu jalankan go run -race atau go test -race saat mengembangkan program concurrent. Race detector tidak mempengaruhi kebenaran kode — hanya memperlambat eksekusi sedikit — tapi bisa menyelamatkan kamu dari bug yang sangat sulit dilacak.
Latihan
Latihan 1 — Cek paralel:
Buat program yang memeriksa ketersediaan lima produk secara bersamaan. Setiap pemeriksaan membutuhkan waktu berbeda (simulasikan dengan time.Sleep acak antara 100–500ms). Gunakan sync.WaitGroup untuk menunggu semua pemeriksaan selesai, lalu cetak semua hasilnya.
Latihan 2 — Counter aman:
Buat struct KasirHarian dengan field totalTransaksi int dan mu sync.Mutex. Tambahkan method TambahTransaksi(nilai int) yang menggunakan mutex untuk keamanan. Jalankan 50 goroutine yang masing-masing memanggil TambahTransaksi dengan nilai acak, lalu pastikan totalnya konsisten di setiap eksekusi.
Latihan 3 — Pipeline sederhana tanpa channel: Buat dua tahap pemrosesan menggunakan goroutine dan WaitGroup. Tahap pertama: validasi 10 nomor faktur (cek apakah formatnya valid). Tahap kedua: setelah semua validasi selesai, proses faktur yang valid saja. Gunakan slice dengan mutex untuk mengumpulkan faktur yang lolos validasi dari tahap pertama.
Goroutine adalah fondasi dari semua yang membuat Go unggul dalam sistem terdistribusi dan server berperforma tinggi. Dengan sync.WaitGroup dan sync.Mutex, kamu sudah punya alat untuk mengkoordinasikan goroutine dengan aman. Tapi koordinasi lewat shared memory punya keterbatasannya — kode bisa rumit dan error-prone saat banyak goroutine saling bergantung. Go punya cara yang lebih elegan untuk itu: alih-alih berbagi memori untuk berkomunikasi, goroutine berkomunikasi untuk berbagi memori. Di bab berikutnya, kita akan melihat bagaimana channel mewujudkan filosofi itu.