Menjalankan goroutine
Taruh go di depan pemanggilan fungsi, dan pemanggilan itu berjalan secara konkuren. Pernyataan itu langsung selesai; pemanggilnya tidak menunggu.
Empat goroutine menjalankan shout pada saat yang sama. Urutan selesainya tidak ditentukan, tetapi output-nya selalu dalam urutan aslinya, karena setiap goroutine menulis ke indeksnya sendiri dan main baru membaca slice itu setelah wg.Wait() selesai.
Contoh itu sudah berisi tiga hal yang dibutuhkan hampir setiap program goroutine: cara memulai pekerjaan (go), cara menunggunya (sync.WaitGroup), dan cara mendapatkan hasilnya kembali tanpa ada dua goroutine yang menyentuh memori yang sama (masing-masing satu elemen slice).
Main tidak menunggu
Saat main selesai, program berakhir. Goroutine yang masih berjalan dihentikan di mana pun posisinya. Tidak ada yang menunggu mereka.
Program ini biasanya hanya mencetak from main. Kadang goroutine-nya sempat dijadwalkan dan kamu melihat kedua baris. Kata "biasanya" itulah masalahnya: kode yang jalan di mesinmu lalu gagal di server yang sedang sibuk.
time.Sleep di akhir main membuat demo ini mencetak kedua baris, dan itu perbaikan yang salah. Cara itu hanya menebak berapa lama pekerjaannya berlangsung. Tunggu pekerjaannya sendiri, dengan WaitGroup (halaman WaitGroup membahasnya secara rinci) atau dengan channel.
Mendapatkan hasil kembali
Pernyataan go membuang nilai kembalian fungsinya. x := go f() tidak bisa dikompilasi. Ada dua cara standar untuk mengembalikan data.
Satu slot per goroutine, seperti di contoh pertama. Tentukan ukuran slice di awal, beri setiap goroutine indeksnya, lalu baca setelah Wait. Urutannya terjaga dan tidak perlu lock, karena tidak ada dua goroutine yang menulis ke elemen yang sama.
Channel. Setiap goroutine mengirim hasilnya; penerima mengumpulkannya. Hasil tiba sesuai urutan selesai, bukan urutan mulai.
Menerima tepat len(nums) nilai sekaligus berfungsi sebagai penantian: main tidak bisa melewati perulangan itu sampai setiap goroutine sudah mengirim. Urutan kedatangan berubah di setiap eksekusi, jadi program mengurutkannya sebelum mencetak apa pun yang bergantung pada urutan. Halaman channel membahas channel buffered, menutup channel, dan range atas channel.
Variabel loop dan closure (perubahan Go 1.22)
Sejak Go 1.22, setiap iterasi for loop mendapat salinan baru dari variabel loop-nya. Closure yang dijalankan di dalam goroutine menangkap nilai dari iterasi itu, jadi kode ini benar:
for i, w := range words {
go func() {
results[i] = shout(w) // Go 1.22+: i and w belong to this iteration
}()
}
Sebelum Go 1.22, semua iterasi berbagi satu i dan satu w, dan setiap goroutine cenderung melihat nilai terakhir. Kode lama mengakalinya dengan mengirim nilai-nilai itu sebagai argumen, go func(i int, w string) { ... }(i, w), atau dengan shadowing, i := i. Keduanya tidak berbahaya di Go 1.22 dan sesudahnya, dan kamu masih akan melihatnya di kode yang sudah ada. Perilaku baru ini berlaku saat go.mod milik modul menulis go 1.22 atau lebih tinggi.
Goroutine itu murah
Goroutine dimulai dengan stack kecil (beberapa kilobyte) yang diperbesar dan diperkecil oleh runtime sesuai kebutuhan. Scheduler Go menjalankan goroutine di atas kumpulan thread OS, dengan paling banyak GOMAXPROCS thread yang mengeksekusi kode Go sekaligus, dan secara default GOMAXPROCS sama dengan jumlah CPU. Tertahan pada channel, mutex, sleep, atau I/O jaringan membuat goroutine itu diparkir dan membebaskan thread-nya untuk goroutine lain.
Jadi menjalankan satu goroutine per tugas tidak masalah, bahkan dalam jumlah besar:
Seratus ribu goroutine selesai dalam sepersekian detik. Jumlahnya selalu 4999950000, karena atomic.Int64 membuat setiap penjumlahan tidak bisa terpotong. Tetapi murah bukan berarti gratis: setiap goroutine yang masih tertahan tetap menyimpan stack-nya dan semua yang dirujuknya.
| Thread OS | Goroutine | |
|---|---|---|
| Dibuat oleh | kernel | runtime Go |
| Stack awal | tetap, sering kali 1 MB atau lebih | beberapa KB, bertambah sesuai kebutuhan |
| Perpindahan | context switch kernel | scheduler Go, di user space |
| Identitas | punya ID thread | tidak punya ID yang bisa kamu baca, memang dirancang begitu |
| Jumlah umum | ratusan | ribuan sampai jutaan |
Data race
Dua goroutine yang mengakses variabel yang sama pada saat yang sama, dengan setidaknya salah satunya menulis, disebut data race. Hasilnya tidak bisa ditebak, bukan sekadar "sedikit meleset": pembaruan bisa hilang, dan race pada nilai string, slice, map, atau interface bisa membuat program crash atau merusak memori.
Di mesin multi-core, program ini mencetak angka berbeda di bawah 10000 pada kebanyakan eksekusi, karena dua goroutine membaca nilai lama yang sama dan keduanya menulis kembali nilai itu ditambah satu. Di satu core, program ini bisa mencetak 10000, dan itu lebih buruk: bug-nya lolos dari test-mu dan baru muncul di produksi.
Go menyertakan race detector. Jalankan program atau test-mu dengan -race:
go run -race main.go
go test -race ./...
==================
WARNING: DATA RACE
Read at 0x00c000090038 by goroutine 8:
main.main.func1()
/tmp/race/main.go:16 +0x94
Previous write at 0x00c000090038 by goroutine 6:
main.main.func1()
/tmp/race/main.go:16 +0xa4
...
Found 2 data race(s)
exit status 66
Race detector menunjuk baris yang tepat (counter++) dan kedua goroutine-nya. Ia hanya melaporkan race yang benar-benar terjadi selama eksekusi, jadi jalankan pada test yang menguji jalur konkuren. Race detector membuat program beberapa kali lebih lambat, jadi pakai untuk test dan staging, bukan produksi.
Perbaikannya, dari yang paling sederhana sampai yang paling umum:
- Jangan berbagi. Beri setiap goroutine datanya sendiri dan gabungkan di akhir (pola satu slot per goroutine).
- Pakai
sync/atomicuntuk satu counter atau flag:var n atomic.Int64; n.Add(1). - Pakai
sync.Mutexdi sekitar apa pun yang lebih besar, seperti map atau struct dengan beberapa field. Halaman mutex juga membahasRWMutexdansync.Once. - Kirim datanya lewat channel supaya hanya satu goroutine yang memilikinya pada satu waktu.
Panic di goroutine mematikan program
Kalau sebuah goroutine panic dan tidak ada yang me-recover di dalam goroutine yang sama, seluruh program crash, termasuk main dan setiap goroutine lainnya. recover di main tidak membantu, karena recover hanya menangkap panic di goroutine-nya sendiri.
Recover seperti ini masuk akal di tepi server yang berjalan lama, tempat satu request yang bermasalah tidak boleh menjatuhkan semuanya. Di dalam kode biasa, panic biasanya berarti bug, dan crash dengan jelas adalah hasil yang tepat.
Kebocoran goroutine
Goroutine yang tertahan selamanya tidak pernah selesai dan tidak pernah membebaskan memorinya. Penyebab klasiknya adalah pengiriman yang tidak akan pernah diterima siapa pun:
func firstResult(urls []string) string {
ch := make(chan string) // unbuffered
for _, u := range urls {
go func() { ch <- fetch(u) }()
}
return <-ch // takes the first result; the other senders block forever
}
Setiap pemanggilan membocorkan len(urls) - 1 goroutine. Di server yang menangani request ini ribuan kali, pemakaian memori terus naik sampai prosesnya mati. Ada dua perbaikan: buat channel yang cukup besar supaya setiap pengirim bisa selesai (make(chan string, len(urls))), atau beri goroutine-goroutine itu cara untuk menyerah, biasanya context.Context ditambah select pada ctx.Done(). Kamu bisa memantau kebocoran dengan runtime.NumGoroutine() di dalam test.
Membatasi berapa yang berjalan sekaligus
"Satu goroutine per item" tidak masalah untuk 10.000 perhitungan ringan. Tetapi tidak untuk 10.000 request HTTP ke server yang sama atau 10.000 file yang terbuka. Batasi konkurensinya dengan channel buffered yang dipakai sebagai semaphore:
Channel buffered itu menampung paling banyak 3 token, jadi paling banyak 3 goroutine yang sudah melewati baris sem <- pada satu waktu. Puncaknya tidak pernah bisa melebihi 3, dan dengan dua belas tugas yang masing-masing melakukan sleep, puncak itu pada praktiknya mencapai 3. Kumpulan worker goroutine dengan jumlah tetap yang membaca dari channel jobs adalah bentuk umum lainnya; halaman WaitGroup membangunnya.
Di luar standard library, golang.org/x/sync/errgroup menggabungkan WaitGroup, error pertama, pembatalan context, dan batas konkurensi (g.SetLimit(n)) dalam satu tipe. Inilah pilihan yang biasa di kode produksi yang butuh keempatnya.
Kesalahan umum
- Lupa menunggu.
mainselesai dan pekerjaannya diam-diam tidak pernah terjadi. Setiap pernyataangobutuh cara yang sepadan untuk mengetahui kapan ia selesai. - Memanggil
wg.Adddi dalam goroutine.Waitbisa berjalan sebelumAdd, melihat counter bernilai nol, lalu selesai terlalu cepat. PanggilAddsebelum pernyataango. - Berbagi variabel tanpa sinkronisasi. Map adalah kasus yang umum: penulisan konkuren ke map biasanya dideteksi runtime dan membuat program crash dengan
fatal error: concurrent map writes, yang tidak bisa ditangkap olehrecover. - Mengandalkan urutan. Goroutine berjalan dalam urutan apa pun yang dipilih scheduler. Kalau output-nya harus berurutan, kumpulkan lalu urutkan, atau tulis ke slot berindeks.
- Memakai
time.Sleepuntuk sinkronisasi. Cara ini membuat test lambat dan tetap tidak stabil. Tunggu kejadiannya, bukan tebakan. - Menjalankan goroutine tanpa cara untuk menghentikannya. Apa pun yang berputar dalam loop atau menunggu I/O sebaiknya menerima
context.Contextsupaya pemanggilnya bisa membatalkannya.
Pertanyaan yang sering diajukan
Apa itu goroutine di Go?
Goroutine adalah pemanggilan fungsi yang berjalan secara konkuren dengan bagian program lainnya. Kamu menjalankannya dengan menaruh go di depan sebuah pemanggilan: go work(). Goroutine dikelola oleh runtime Go, bukan oleh sistem operasi, dan runtime menjalankan banyak goroutine di atas sejumlah kecil thread OS, jadi menjalankan ribuan goroutine adalah hal yang biasa.
Bagaimana cara menunggu goroutine selesai di Go?
Pakai sync.WaitGroup: panggil wg.Add(1) sebelum setiap pernyataan go, defer wg.Done() di awal goroutine, dan wg.Wait() di tempat kamu butuh semuanya sudah selesai. Kalau goroutine-goroutine itu menghasilkan nilai, menerima satu nilai per goroutine dari sebuah channel juga berfungsi sebagai penantian.
Apa bedanya goroutine dan thread?
Thread OS punya stack berukuran tetap (sering kali 1 MB atau lebih) dan dijadwalkan oleh kernel. Goroutine dimulai dengan stack berukuran beberapa kilobyte yang bertambah sesuai kebutuhan, dan scheduler Go berpindah antar goroutine di user space. Runtime menjalankan goroutine di paling banyak GOMAXPROCS thread sekaligus (secara default, sebanyak jumlah CPU).
Bagaimana cara mendapatkan nilai kembalian dari goroutine?
Pernyataan go membuang nilai kembalian fungsinya. Kirim hasilnya lewat channel (results <- compute(x)) atau tulis ke slot milik goroutine itu sendiri di slice yang ukurannya sudah ditentukan (out[i] = compute(x)), lalu baca setelah wg.Wait().
Kenapa program Go saya selesai sebelum goroutine mencetak apa pun?
Saat main selesai, program berakhir dan setiap goroutine lain dihentikan tanpa menjalankan sisa kodenya. Tidak ada yang otomatis menunggu goroutine. Tahan main sampai pekerjaannya selesai dengan WaitGroup atau penerimaan dari channel. Menambahkan time.Sleep hanya menyembunyikan masalahnya.