Goroutine murah — jauh lebih murah daripada thread OS — tetapi gratis hanya sampai batas tertentu. Ribuan goroutine yang berebut satu variabel tanpa sinkronisasi bisa jadi data race; ribuan worker untuk pekerjaan CPU-bound justru saling berebut core dan menambah biaya penjadwalan. Chapter ini merakit worker pool dengan proporsi yang tepat, menjaga hasil tetap terurut tanpa mutex, lalu membahas tiga perangkap klasik concurrency: contention, kerja sia-sia, dan data race.

Fan-out: Membagi Kerja ke Worker #

Berkas conc-demo.go membandingkan eksekusi berurutan dengan worker pool. Penyalur mengirim nomor tugas ke satu channel, sejumlah runtime.NumCPU() worker mengambil tugas dari channel yang sama, dan hasil ditulis ke indeks milik tugasnya masing-masing.

go
package main

import (
	"fmt"
	"runtime"
	"slices"
	"sync"
)

// hitungTugas mensimulasikan kerja CPU-bound murni dengan hasil tetap,
// sehingga output program tetap deterministik meski dieksekusi paralel.
func hitungTugas(n int) int {
	total := 0
	for i := 1; i <= 10_000; i++ {
		total += i * (n + 1)
	}
	return total
}

func main() {
	jumlahTugas := 8

	// Pembanding: eksekusi berurutan, satu tugas selesai baru tugas berikutnya.
	urutan := make([]int, jumlahTugas)
	for i := range jumlahTugas {
		urutan[i] = hitungTugas(i)
	}

	// Worker pool: sejumlah runtime.NumCPU() goroutine mengambil tugas
	// dari channel yang sama, hasil ditulis ke indeks masing-masing.
	hasil := make([]int, jumlahTugas)
	tugas := make(chan int)
	var wg sync.WaitGroup

	jumlahWorker := runtime.NumCPU()
	for w := 0; w < jumlahWorker; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for n := range tugas {
				hasil[n] = hitungTugas(n) // tiap indeks hanya ditulis satu worker
			}
		}()
	}

	// Penyalur tugas: kirim semua indeks lalu tutup channel agar worker
	// keluar dari loop range.
	for i := range jumlahTugas {
		tugas <- i
	}
	close(tugas)
	wg.Wait()

	fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
	fmt.Println("Worker dibuat:", jumlahWorker, "(setara runtime.NumCPU)")
	fmt.Println("Hasil paralel identik dengan berurutan:", slices.Equal(hasil, urutan))
	fmt.Println("Hasil terurut berdasar nomor tugas:", hasil)
}

Jalankan dari folder chapter:

bash
go run conc-demo.go

Output yang Dihasilkan:

text
GOMAXPROCS: 2
Worker dibuat: 2 (setara runtime.NumCPU)
Hasil paralel identik dengan berurutan: true
Hasil terurut berdasar nomor tugas: [50005000 100010000 150015000 200020000 250025000 300030000 350035000 400040000]

Mari kita telaah baris demi baris program di atas:

  1. Baris 12-18 (func hitungTugas): pekerjaan CPU-bound murni dengan hasil tetap — total 50005000 × (n+1) untuk tiap nomor tugas, identik di semua eksekusi.
  2. Baris 24-27 (pembanding berurutan): hasil eksekusi berurutan disimpan sebagai pembanding kebenaran.
  3. Baris 35-44 (worker pool): tiap worker berisi loop for n := range tugas yang terus mengambil tugas — goroutine dipakai ulang, bukan satu goroutine per tugas.
  4. Baris 41 (hasil[n] = ...): tiap indeks hanya ditulis oleh satu worker, sehingga tidak perlu mutex dan hasil tetap terurut berdasar nomor tugas.
  5. Baris 48-52: penyalur kirim semua indeks lalu close(tugas); loop range worker berakhir saat channel tertutup, dan wg.Wait() menunggu semuanya selesai.

CPU-bound atau IO-bound #

Jumlah worker ideal mengikuti jenis pekerjaan. Pekerjaan CPU-bound — kompresi, hash, format teks — sudah jenuh ketika worker sama banyak dengan core; worker tambahan hanya menambah konteks peralihan. Pekerjaan IO-bound — panggilan HTTP, query database, baca berkas — menghabiskan sebagian besar waktu menunggu, sehingga worker bisa jauh lebih banyak daripada core (misalnya puluhan hingga ratusan) tanpa membuat CPU sibuk. Batas atas praktisnya adalah memori dan target penggunaan berkas/koneksi.

Jenis kerjaJumlah worker awal yang wajar
CPU-boundruntime.NumCPU()
IO-bound cepat (cache lokal)2-4 × NumCPU
IO-bound lambat (jaringan)puluhan, dibatasi target beban lawan

Hasil Terurut Tanpa Mutex #

Kunci pola pada program di atas adalah tiap slot keluaran hanya dimiliki satu worker. hasil[n] = hitungTugas(n) tidak butuh mutex karena tidak ada dua goroutine yang menulis indeks sama; hasil pun terurut karena indeksnya tetap. Ketika pola ini tidak memungkinkan dan banyak goroutine menulis satu variabel, contention pada mutex muncul: goroutine antre pada lock dan sebagian core menganggur. Solusi klasiknya sharding — membagi satu map besar ke N map kecil, masing-masing dengan lock sendiri, lalu menggabungkan di akhir — sehingga goroutine tersebar dan antrean lock memendek.

context Membatalkan Kerja Sia-sia #

Kerja yang hasilnya sudah tidak dibutuhkan tetap memakan CPU sampai selesai. context.WithCancel (atau WithTimeout) memberi sinyal pembatalan: worker memeriksa ctx.Done() di antara tugas dan berhenti lebih awal. Pola lengkapnya dibahas di chapter Cancellation dan Timeout ; untuk performa, intinya satu: panggilan yang dibatalkan lebih murah daripada panggilan yang selesai sia-sia.

errgroup dan Batas Goroutine #

Paket golang.org/x/sync menyediakan errgroup.Group yang menggabungkan WaitGroup dengan propagasi error, plus varian SetLimit(n) yang membatasi jumlah goroutine aktif sekaligus — pola yang lebih ringkas daripada channel buffered sebagai semaphore untuk membatasi fan-out. Untuk kontrol alur dan error lintas goroutine, errgroup layak jadi pilihan standar; untuk pola channel seperti program di atas, standard library sudah cukup.

Dengan fan-out yang proporsional dan hasil yang aman dari race, satu variabel runtime tersisa yang menentukan kecepatan keseluruhan: seberapa sering garbage collector berjalan — topik penalaan GOGC dan GOMEMLIMIT di chapter berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

conc-demo.go

Unduh berkas
go
package main

import (
	"fmt"
	"runtime"
	"slices"
	"sync"
)

// hitungTugas mensimulasikan kerja CPU-bound murni dengan hasil tetap,
// sehingga output program tetap deterministik meski dieksekusi paralel.
func hitungTugas(n int) int {
	total := 0
	for i := 1; i <= 10_000; i++ {
		total += i * (n + 1)
	}
	return total
}

func main() {
	jumlahTugas := 8

	// Pembanding: eksekusi berurutan, satu tugas selesai baru tugas berikutnya.
	urutan := make([]int, jumlahTugas)
	for i := range jumlahTugas {
		urutan[i] = hitungTugas(i)
	}

	// Worker pool: sejumlah runtime.NumCPU() goroutine mengambil tugas
	// dari channel yang sama, hasil ditulis ke indeks masing-masing.
	hasil := make([]int, jumlahTugas)
	tugas := make(chan int)
	var wg sync.WaitGroup

	jumlahWorker := runtime.NumCPU()
	for w := 0; w < jumlahWorker; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for n := range tugas {
				hasil[n] = hitungTugas(n) // tiap indeks hanya ditulis satu worker
			}
		}()
	}

	// Penyalur tugas: kirim semua indeks lalu tutup channel agar worker
	// keluar dari loop range.
	for i := range jumlahTugas {
		tugas <- i
	}
	close(tugas)
	wg.Wait()

	fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
	fmt.Println("Worker dibuat:", jumlahWorker, "(setara runtime.NumCPU)")
	fmt.Println("Hasil paralel identik dengan berurutan:", slices.Equal(hasil, urutan))
	fmt.Println("Hasil terurut berdasar nomor tugas:", hasil)
}