Concurrency dan Race Condition di Go: Panduan Lengkap dari Dasar hingga Pola Produksi
Concurrency adalah salah satu fitur paling menonjol yang membuat Go populer. Dengan keyword go yang hanya dua huruf, kamu bisa menjalankan ribuan tugas secara bersamaan tanpa overhead thread OS yang mahal. Go dirancang sejak awal dengan concurrency sebagai first-class citizen — bukan tambahan belakangan — dan filosofi desainnya yang terkenal berbunyi: “Do not communicate by sharing memory; instead, share memory by communicating.” Filosofi ini bukan sekadar slogan; ia mencerminkan cara Go mendorong developer untuk berfikir tentang concurrency melalui channel, bukan melalui lock dan shared variable.
Namun kemudahan menulis kode concurrent di Go juga membawa tanggung jawab baru. Goroutine yang ringan dan mudah dibuat justru membuat developer terkadang terlalu mudah meluncurkan goroutine tanpa memikirkan siapa yang memiliki data, siapa yang boleh membacanya, dan kapan boleh menulisnya. Hasilnya adalah race condition — bug yang paling berbahaya sekaligus paling sulit dilacak, karena ia bisa muncul hanya pada beban tertentu, hanya di lingkungan produksi, dan tidak bisa direproduksi secara konsisten. Artikel ini membahas seluruh ekosistem concurrency di Go dari dasar, termasuk cara mendeteksi, mencegah, dan memperbaiki race condition, serta pola-pola concurrency yang umum dipakai di kode produksi.
Goroutine: Unit Dasar Concurrency di Go
Goroutine adalah fungsi yang berjalan secara concurrent dengan fungsi lain dalam program yang sama. Berbeda dengan thread OS tradisional, goroutine sangat ringan — ukuran stack awalnya hanya sekitar 2 KB dan bisa tumbuh secara dinamis sesuai kebutuhan, dibanding thread OS yang biasanya memiliki stack tetap sebesar 1–8 MB.
Membuat goroutine sangat sederhana, cukup dengan keyword go di depan pemanggilan fungsi:
package main
import (
"fmt"
"time"
)
func cetakAngka(id int) {
for i := 0; i < 5; i++ {
fmt.Printf("Goroutine %d: %d\n", id, i)
time.Sleep(10 * time.Millisecond)
}
}
func main() {
go cetakAngka(1) // goroutine pertama
go cetakAngka(2) // goroutine kedua
cetakAngka(3) // berjalan di goroutine main
}
Urutan output dari program ini tidak bisa diprediksi — goroutine 1, 2, dan 3 berjalan bersamaan dan scheduler Go yang memutuskan kapan masing-masing mendapat giliran CPU.
Goroutine vs Thread OS
Perbedaan fundamental antara goroutine dan thread OS terletak pada siapa yang mengelolanya:
| Aspek | Thread OS | Goroutine |
|---|---|---|
| Dikelola oleh | Kernel OS | Runtime Go |
| Ukuran stack awal | 1–8 MB (tetap) | ~2 KB (dinamis) |
| Context switch | Lambat (kernel mode) | Cepat (user space) |
| Jumlah praktis | Ratusan | Jutaan |
| Komunikasi | Shared memory + lock | Channel atau shared memory |
Model Scheduler M:N
Go menggunakan model scheduler M:N — artinya M goroutine dijadwalkan di atas N thread OS. Runtime Go memiliki scheduler sendiri (package runtime) yang bertanggung jawab untuk:
- Mendistribusikan goroutine ke thread OS yang tersedia
- Melakukan context switch antar goroutine tanpa bantuan kernel
- Menangani goroutine yang melakukan blocking I/O agar tidak memblokir thread OS
flowchart TD
subgraph "Runtime Go"
subgraph "P1 (Processor)"
LRQ1[Local Run Queue]
end
subgraph "P2 (Processor)"
LRQ2[Local Run Queue]
end
GRQ[Global Run Queue]
end
subgraph "OS"
M1[Thread OS 1]
M2[Thread OS 2]
end
subgraph "Goroutines"
G1[G1] & G2[G2] & G3[G3] --> LRQ1
G4[G4] & G5[G5] --> LRQ2
G6[G6] --> GRQ
end
LRQ1 --> M1
LRQ2 --> M2Jumlah P (Processor) ditentukan oleh GOMAXPROCS, yang secara default sama dengan jumlah core CPU yang tersedia.
Channel: Komunikasi Antar Goroutine
Channel adalah mekanisme komunikasi utama antara goroutine di Go. Sebuah channel adalah “pipa” bertipe yang memungkinkan satu goroutine mengirim nilai dan goroutine lain menerima nilai tersebut, dengan sinkronisasi yang sudah built-in.
// Membuat channel
ch := make(chan int) // unbuffered channel
ch := make(chan int, 10) // buffered channel dengan kapasitas 10
// Mengirim nilai ke channel
ch <- 42
// Menerima nilai dari channel
nilai := <-ch
Unbuffered Channel
Unbuffered channel menyediakan synchronous rendezvous — pengirim akan blok hingga ada penerima yang siap, dan penerima akan blok hingga ada pengirim yang mengirimkan nilai. Ini menjadikan unbuffered channel sebagai mekanisme sinkronisasi yang kuat, bukan hanya transfer data.
func main() {
ch := make(chan string)
go func() {
fmt.Println("Goroutine mulai bekerja...")
time.Sleep(500 * time.Millisecond)
ch <- "selesai" // blok di sini sampai main() siap menerima
}()
hasil := <-ch // blok di sini sampai goroutine mengirim
fmt.Println("Goroutine:", hasil)
}
Buffered Channel
Buffered channel memiliki kapasitas internal. Pengirim baru akan blok ketika buffer penuh, dan penerima akan blok ketika buffer kosong. Ini memungkinkan decoupling antara pengirim dan penerima hingga batas kapasitas buffer.
func main() {
ch := make(chan int, 3) // kapasitas 3
ch <- 1 // tidak blok, buffer masih kosong
ch <- 2 // tidak blok
ch <- 3 // tidak blok, buffer penuh
// ch <- 4 // ini akan blok karena buffer sudah penuh
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3
}
Select Statement
select memungkinkan satu goroutine menunggu dari beberapa channel sekaligus. Go akan memilih case pertama yang siap; jika lebih dari satu siap bersamaan, dipilih secara acak.
func prosesRequest(reqCh <-chan string, stopCh <-chan struct{}) {
for {
select {
case req := <-reqCh:
fmt.Println("Memproses:", req)
case <-stopCh:
fmt.Println("Dihentikan")
return
}
}
}
select dengan default case menjadikannya non-blocking:
select {
case nilai := <-ch:
fmt.Println("Ada nilai:", nilai)
default:
fmt.Println("Channel kosong, lanjut tanpa blok")
}
Menutup Channel
Channel bisa ditutup oleh pengirim dengan close(ch). Penerima bisa mendeteksi channel yang sudah ditutup menggunakan idiom dua-nilai:
nilai, ok := <-ch
if !ok {
fmt.Println("Channel sudah ditutup")
}
Atau dengan ranging langsung atas channel, yang otomatis berhenti saat channel ditutup:
for nilai := range ch {
fmt.Println(nilai)
}
Hanya pengirim yang boleh menutup channel, bukan penerima. Mengirim nilai ke channel yang sudah ditutup akan menyebabkan panic. Menutup channel yang sudah ditutup juga menyebabkan panic. Jika ada beberapa pengirim, gunakansync.Onceatau koordinasi tambahan untuk memastikanclosehanya dipanggil sekali.
Apa Itu Race Condition?
Race condition terjadi ketika dua atau lebih goroutine mengakses lokasi memori yang sama secara concurrent, dan setidaknya satu di antara mereka melakukan penulisan, tanpa sinkronisasi yang tepat. Hasil dari program dalam kondisi ini menjadi tidak deterministik — bergantung pada urutan eksekusi yang dikontrol oleh scheduler, bukan logika program.
Ini adalah contoh race condition paling klasik: counter yang diakses oleh beberapa goroutine secara bersamaan:
// ANTI-PATTERN: race condition pada counter bersama
package main
import (
"fmt"
"sync"
)
var counter int // shared state
func increment(wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 1000; i++ {
counter++ // TIDAK AMAN: baca + tambah + tulis bukan operasi atomic
}
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go increment(&wg)
}
wg.Wait()
fmt.Println("Counter:", counter) // hasilnya tidak terprediksi, jarang 10000
}
Program ini seharusnya mencetak 10000 (10 goroutine × 1000 increment). Tapi kenyataannya, hasilnya berbeda setiap kali dijalankan — bisa 8573, 9102, atau angka lain. Kenapa?
Mengapa counter++ Tidak Aman?
Meski terlihat seperti satu instruksi, counter++ sebenarnya terdiri dari tiga langkah terpisah di level hardware:
- Baca nilai
counterdari memori ke register CPU - Tambahkan 1 ke nilai di register
- Tulis kembali nilai dari register ke memori
Ketika dua goroutine melakukan ini secara bersamaan tanpa sinkronisasi, bisa terjadi interleaving yang menyebabkan salah satu increment “hilang”:
sequenceDiagram
participant G1 as Goroutine 1
participant Mem as Memori (counter)
participant G2 as Goroutine 2
Note over Mem: counter = 5
G1->>Mem: Baca (dapat 5)
G2->>Mem: Baca (dapat 5)
G1->>G1: Tambah 1 = 6
G2->>G2: Tambah 1 = 6
G1->>Mem: Tulis 6
G2->>Mem: Tulis 6
Note over Mem: counter = 6, bukan 7!<br/>Satu increment hilang!Inilah sebabnya race condition sangat berbahaya — dari kode yang terlihat benar secara logika, bug bisa muncul hanya karena waktu eksekusi yang sedikit berbeda, yang sangat sulit direproduksi dan dilacak.
Mendeteksi Race Condition dengan Race Detector
Go menyediakan built-in race detector yang sangat powerful. Ia bekerja dengan menginstrumentasi binary program untuk memantau setiap akses memori dan mendeteksi akses concurrent yang tidak disinkronkan.
Cara menggunakannya sangat mudah, cukup tambahkan flag -race:
# Menjalankan program dengan race detection
go run -race main.go
# Menjalankan test dengan race detection
go test -race ./...
# Build binary dengan race detection (untuk staging/testing environment)
go build -race -o app
Untuk contoh counter di atas, race detector akan menghasilkan output seperti ini:
==================
WARNING: DATA RACE
Write at 0x00c000018090 by goroutine 7:
main.increment()
/path/to/main.go:14 +0x30
Previous write at 0x00c000018090 by goroutine 6:
main.increment()
/path/to/main.go:14 +0x30
Goroutine 7 (running) created at:
main.main()
/path/to/main.go:22 +0x78
==================
Output ini memberikan informasi yang sangat berguna:
- Lokasi race:
main.go:14— baris mana yang menyebabkan race - Jenis operasi:
Write— dua goroutine melakukan penulisan - Goroutine mana yang terlibat dan di mana goroutine tersebut dibuat
Jalankan go test -race secara rutin di CI/CD pipeline. Race detector memiliki overhead performa (biasanya 5–10x lebih lambat), jadi tidak cocok untuk production build — tapi sangat berharga di test suite karena bisa menangkap race condition yang tidak terlihat dari logika kode saja.Cara Mengatasi Race Condition
Ada beberapa pendekatan untuk mengatasi race condition di Go. Pilihan yang tepat bergantung pada pola akses data dan trade-off performa yang dibutuhkan.
1. Mutex — Kunci Eksklusi Mutual
sync.Mutex adalah mekanisme locking paling fundamental. Ia memastikan hanya satu goroutine yang bisa berada di antara Lock() dan Unlock() pada satu waktu. Goroutine lain yang mencoba Lock() akan blok hingga mutex dilepas.
// BENAR: melindungi akses counter dengan mutex
package main
import (
"fmt"
"sync"
)
type SafeCounter struct {
mu sync.Mutex
value int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock() // selalu gunakan defer agar Unlock tidak terlupa
c.value++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
func main() {
counter := &SafeCounter{}
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
counter.Increment()
}
}()
}
wg.Wait()
fmt.Println("Counter:", counter.Value()) // selalu 10000
}
2. RWMutex — Optimasi untuk Read-Heavy Data
Jika data lebih sering dibaca daripada ditulis, sync.RWMutex memberikan performa yang lebih baik. Beberapa goroutine bisa membaca secara bersamaan (read lock), tapi penulisan membutuhkan akses eksklusif (write lock).
type Cache struct {
mu sync.RWMutex
data map[string]string
}
// Banyak goroutine bisa memanggil Get secara bersamaan
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
// Set membutuhkan akses eksklusif
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
| Kondisi | sync.Mutex | sync.RWMutex |
|---|---|---|
| Hanya ada writer | ✓ Lebih sederhana | ✓ Bisa, tapi overkill |
| Banyak reader, sedikit writer | ✓ Bisa, tapi lambat | ✓ Ideal |
| Banyak writer | ✓ Cocok | Overhead RWMutex sia-sia |
3. sync/atomic — Operasi Atomik untuk Tipe Sederhana
Untuk operasi sederhana pada tipe numerik, package sync/atomic menyediakan operasi yang dijamin atomik di level hardware — tanpa overhead locking:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter int64 // harus menggunakan tipe yang didukung atomic
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
atomic.AddInt64(&counter, 1) // atomik, aman tanpa mutex
}
}()
}
wg.Wait()
fmt.Println("Counter:", atomic.LoadInt64(&counter)) // selalu 10000
}
sync/atomic juga menyediakan operasi Compare-And-Swap (CAS) yang berguna untuk membangun struktur data lock-free:
// Hanya update jika nilai saat ini adalah expected
swapped := atomic.CompareAndSwapInt64(&counter, old, new)
sync/atomic sangat efisien untuk tipe sederhana (integer, pointer), tetapi tidak bisa digunakan untuk tipe komposit seperti struct atau map. Untuk struct, tetap gunakan mutex.4. Channel sebagai Mekanisme Sinkronisasi
Alih-alih berbagi data dan melindunginya dengan lock, Go mendorong pendekatan lain: berikan kepemilikan data (ownership) hanya kepada satu goroutine, dan goroutine lain berkomunikasi dengannya melalui channel. Ini adalah filosofi CSP (Communicating Sequential Processes) yang menjadi fondasi concurrency di Go.
// BENAR: satu goroutine "memiliki" counter, yang lain berkomunikasi via channel
type CounterMsg struct {
op string // "inc" atau "get"
replyCh chan int // channel untuk mengembalikan nilai
}
func counterActor(msgCh <-chan CounterMsg) {
count := 0 // hanya goroutine ini yang menyentuh count
for msg := range msgCh {
switch msg.op {
case "inc":
count++
case "get":
msg.replyCh <- count
}
}
}
func main() {
msgCh := make(chan CounterMsg, 100)
go counterActor(msgCh)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
msgCh <- CounterMsg{op: "inc"}
}
}()
}
wg.Wait()
replyCh := make(chan int)
msgCh <- CounterMsg{op: "get", replyCh: replyCh}
fmt.Println("Counter:", <-replyCh) // selalu 10000
}
Pendekatan ini — sering disebut actor model — menghilangkan kebutuhan lock sama sekali karena tidak ada shared state. Data count hanya pernah diakses oleh satu goroutine (counterActor), sehingga tidak mungkin ada race condition.
WaitGroup dan Once: Sinkronisasi Lifecycle Goroutine
sync.WaitGroup
sync.WaitGroup digunakan untuk menunggu sekumpulan goroutine selesai bekerja. Ini bukan untuk mencegah race condition secara langsung, tetapi untuk mengkoordinasikan kapan goroutine-goroutine selesai.
func main() {
var wg sync.WaitGroup
results := make([]string, 5)
for i := 0; i < 5; i++ {
wg.Add(1) // tambahkan counter SEBELUM goroutine dimulai
go func(idx int) {
defer wg.Done() // kurangi counter saat selesai
results[idx] = fmt.Sprintf("hasil-%d", idx)
}(i)
}
wg.Wait() // blok sampai counter mencapai nol
fmt.Println(results)
}
Selalu panggilwg.Add(n)sebelum goroutine dimulai, bukan di dalam goroutine. JikaAdddipanggil di dalam goroutine, ada kemungkinanWait()dipanggil sebelumAdd()sempat dieksekusi, menyebabkanWait()kembali terlalu cepat.
sync.Once
sync.Once memastikan sebuah fungsi hanya dieksekusi tepat satu kali, tidak peduli berapa banyak goroutine yang mencoba memanggilnya. Ini sangat berguna untuk inisialisasi lazy yang thread-safe.
type Singleton struct {
db *sql.DB
}
var (
instance *Singleton
once sync.Once
)
func GetInstance() *Singleton {
once.Do(func() {
// hanya dieksekusi sekali, meskipun dipanggil dari banyak goroutine
instance = &Singleton{
db: initDatabase(),
}
})
return instance
}
Anti-Pattern Umum dalam Kode Concurrent Go
Anti-Pattern 1: Goroutine Leak
Goroutine yang tidak pernah selesai adalah salah satu bug paling umum dan paling sulit dilacak. Jika goroutine menunggu pada channel yang tidak pernah ditutup atau tidak pernah menerima nilai, goroutine tersebut akan hidup selamanya dan memakan memori.
// ANTI-PATTERN: goroutine leak — goroutine menunggu selamanya
func fetchData(url string) <-chan string {
ch := make(chan string)
go func() {
result := doHTTPRequest(url) // anggap ini memakan waktu
ch <- result // jika pemanggil tidak menerima, goroutine ini blok selamanya
}()
return ch
}
func main() {
ch := fetchData("https://example.com")
// jika main() selesai atau kita tidak membaca dari ch,
// goroutine di dalam fetchData() akan leak
}
// BENAR: gunakan context untuk cancellation
func fetchData(ctx context.Context, url string) <-chan string {
ch := make(chan string, 1) // buffered, goroutine tidak perlu menunggu penerima
go func() {
result := doHTTPRequest(url)
select {
case ch <- result:
// berhasil kirim
case <-ctx.Done():
// pemanggil sudah cancel, kita bisa keluar dengan aman
}
}()
return ch
}
Anti-Pattern 2: Closure Menangkap Variabel Loop
Ini adalah bug yang sangat umum, terutama bagi developer yang baru pindah ke Go:
// ANTI-PATTERN: semua goroutine menangkap variabel i yang sama
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i) // BUG: semua goroutine mungkin mencetak 5
}()
}
wg.Wait()
}
Masalahnya: semua goroutine menangkap referensi ke variabel i yang sama. Saat goroutine-goroutine tersebut dieksekusi, loop mungkin sudah selesai dan nilai i sudah menjadi 5.
// BENAR: pass nilai sebagai argumen
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(val int) { // val adalah salinan nilai i saat itu
defer wg.Done()
fmt.Println(val) // benar: 0, 1, 2, 3, 4 (dalam urutan acak)
}(i)
}
wg.Wait()
}
Sejak Go 1.22, perilaku loop variable telah diubah sehingga setiap iterasi loop memiliki salinan variabelnya sendiri. Ini menyelesaikan masalah closure menangkap variabel loop secara default. Namun jika kamu bekerja dengan codebase yang target Go-nya lebih lama, tetap perlu hati-hati dengan pola ini.
Anti-Pattern 3: Deadlock
Deadlock terjadi ketika dua atau lebih goroutine saling menunggu satu sama lain untuk melepaskan resource, sehingga keduanya tidak bisa melanjutkan eksekusi selamanya.
// ANTI-PATTERN: deadlock klasik — dua goroutine saling menunggu
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
val := <-ch1 // menunggu ch1
ch2 <- val + 1 // lalu kirim ke ch2
}()
go func() {
val := <-ch2 // menunggu ch2
ch1 <- val + 1 // lalu kirim ke ch1 — DEADLOCK!
}()
// Kedua goroutine menunggu selamanya, tidak ada yang memulai
time.Sleep(1 * time.Second)
}
Deadlock bisa juga terjadi akibat mutex yang tidak dilepas atau double-locking:
// ANTI-PATTERN: mutex double-lock menyebabkan deadlock
func (s *Store) GetAndSet(key, value string) {
s.mu.Lock()
_ = s.get(key) // jika get() juga memanggil s.mu.Lock(), deadlock!
s.mu.Set(key, value)
s.mu.Unlock()
}
// BENAR: pisahkan fungsi internal (tidak lock) dari fungsi publik (dengan lock)
func (s *Store) get(key string) string { // tidak melakukan locking
return s.data[key]
}
func (s *Store) Get(key string) string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.get(key) // aman karena get() tidak melakukan locking
}
func (s *Store) GetAndSet(key, value string) {
s.mu.Lock()
defer s.mu.Unlock()
_ = s.get(key) // aman, karena get() tidak melakukan lock
s.data[key] = value
}
Anti-Pattern 4: Lock yang Terlalu Luas
Mengambil lock untuk seluruh operasi yang panjang — termasuk bagian yang tidak perlu dilindungi — merusak performa tanpa alasan:
// ANTI-PATTERN: lock terlalu luas, memblokir goroutine lain terlalu lama
func (s *Store) ProcessAndStore(key string) {
s.mu.Lock()
defer s.mu.Unlock()
data := s.data[key] // perlu dilindungi
result := heavyComputation(data) // TIDAK perlu dilindungi, tapi ikut di-lock
s.data[key] = result // perlu dilindungi
}
// BENAR: sempitkan scope lock hanya ke akses data yang memang perlu
func (s *Store) ProcessAndStore(key string) {
s.mu.RLock()
data := s.data[key]
s.mu.RUnlock()
result := heavyComputation(data) // berjalan tanpa lock
s.mu.Lock()
s.data[key] = result
s.mu.Unlock()
}
Anti-Pattern 5: Mengirim ke Channel yang Nil
Channel yang bernilai nil (belum diinisialisasi) akan menyebabkan operasi send dan receive blok selamanya, bukan langsung panic:
// ANTI-PATTERN: mengirim ke nil channel menyebabkan goroutine blok selamanya
func main() {
var ch chan int // nil channel
go func() {
ch <- 42 // blok selamanya, goroutine leak!
}()
time.Sleep(1 * time.Second)
}
// BENAR: selalu inisialisasi channel sebelum digunakan
func main() {
ch := make(chan int, 1) // channel yang sudah diinisialisasi
go func() {
ch <- 42
}()
fmt.Println(<-ch)
}
Pola Concurrency Umum di Kode Produksi
Pola 1: Worker Pool
Worker pool adalah pola untuk membatasi jumlah goroutine yang berjalan bersamaan, menghindari resource exhaustion saat ada banyak pekerjaan yang perlu diproses:
package main
import (
"fmt"
"sync"
)
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
// proses pekerjaan
result := job * 2
results <- result
fmt.Printf("Worker %d memproses job %d\n", id, job)
}
}
func main() {
const numWorkers = 3
const numJobs = 10
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
var wg sync.WaitGroup
// Jalankan pool worker
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
// Kirim pekerjaan
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs) // tutup channel jobs agar worker tahu tidak ada pekerjaan lagi
// Tunggu semua worker selesai, lalu tutup results
go func() {
wg.Wait()
close(results)
}()
// Kumpulkan hasil
for result := range results {
fmt.Println("Hasil:", result)
}
}
flowchart LR
P[Producer] -->|jobs| JQ[Jobs Channel]
JQ --> W1[Worker 1]
JQ --> W2[Worker 2]
JQ --> W3[Worker 3]
W1 -->|results| RC[Results Channel]
W2 -->|results| RC
W3 -->|results| RC
RC --> C[Consumer]Pola 2: Fan-Out / Fan-In
Fan-out berarti mendistribusikan pekerjaan dari satu sumber ke banyak goroutine. Fan-in berarti mengumpulkan hasil dari banyak goroutine ke satu channel.
// Fan-out: satu input channel, banyak goroutine proses
func fanOut(input <-chan int, numWorkers int) []<-chan int {
outputs := make([]<-chan int, numWorkers)
for i := 0; i < numWorkers; i++ {
out := make(chan int)
outputs[i] = out
go func(ch chan<- int) {
for val := range input {
ch <- val * 2
}
close(ch)
}(out)
}
return outputs
}
// Fan-in: banyak input channel, satu output channel
func fanIn(channels ...<-chan int) <-chan int {
merged := make(chan int)
var wg sync.WaitGroup
output := func(ch <-chan int) {
defer wg.Done()
for val := range ch {
merged <- val
}
}
wg.Add(len(channels))
for _, ch := range channels {
go output(ch)
}
go func() {
wg.Wait()
close(merged)
}()
return merged
}
flowchart LR
subgraph Fan-Out
SRC[Source] --> G1[Goroutine 1]
SRC --> G2[Goroutine 2]
SRC --> G3[Goroutine 3]
end
subgraph Fan-In
G1 --> SINK[Merged Output]
G2 --> SINK
G3 --> SINK
endPola 3: Pipeline
Pipeline adalah rangkaian stage di mana output dari satu stage menjadi input stage berikutnya, semuanya berjalan concurrent:
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func filter(in <-chan int, pred func(int) bool) <-chan int {
out := make(chan int)
go func() {
for n := range in {
if pred(n) {
out <- n
}
}
close(out)
}()
return out
}
func main() {
// Pipeline: generate → square → filter (hanya yang > 10)
nums := generate(1, 2, 3, 4, 5)
squares := square(nums)
result := filter(squares, func(n int) bool { return n > 10 })
for val := range result {
fmt.Println(val) // 16, 25
}
}
flowchart LR
A[generate<br/>1,2,3,4,5] -->|channel| B[square<br/>1,4,9,16,25]
B -->|channel| C[filter<br/>> 10]
C -->|channel| D[Output<br/>16, 25]Pola 4: Context untuk Cancellation dan Timeout
context.Context adalah cara idiomatik Go untuk meneruskan sinyal cancellation, deadline, dan nilai ke seluruh rantai goroutine. Setiap goroutine yang mungkin berjalan lama harus menghormati context yang diberikan kepadanya.
package main
import (
"context"
"fmt"
"time"
)
func longRunningTask(ctx context.Context) error {
select {
case <-time.After(5 * time.Second): // pekerjaan selesai
fmt.Println("Tugas selesai")
return nil
case <-ctx.Done(): // context di-cancel atau deadline terlewat
fmt.Println("Tugas dibatalkan:", ctx.Err())
return ctx.Err()
}
}
func main() {
// Buat context dengan timeout 2 detik
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // selalu panggil cancel untuk membebaskan resource
if err := longRunningTask(ctx); err != nil {
fmt.Println("Error:", err) // context deadline exceeded
}
}
Context dengan cancellation manual sangat berguna untuk menghentikan goroutine berdasarkan event, bukan waktu:
func main() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
// Jalankan goroutine
for {
select {
case <-ctx.Done():
fmt.Println("Goroutine berhenti")
return
default:
doWork()
}
}
}()
time.Sleep(1 * time.Second)
cancel() // hentikan goroutine
time.Sleep(100 * time.Millisecond) // beri waktu goroutine untuk berhenti
}
Selalu panggil fungsicancelyang dikembalikan olehcontext.WithCancel,context.WithTimeout, dancontext.WithDeadline— bahkan jika context sudah expired secara alami. Tidak memanggilcancelmenyebabkan resource leak karena context dan goroutine internal yang memantaunya tidak dibebaskan.
sync.Map: Map yang Aman untuk Concurrent Access
Map bawaan Go (map[K]V) tidak thread-safe. Mengakses map dari beberapa goroutine secara concurrent tanpa sinkronisasi adalah race condition dan bisa menyebabkan crash dengan pesan concurrent map read and map write.
Untuk kasus concurrent access, Go menyediakan sync.Map:
// ANTI-PATTERN: map biasa tidak thread-safe
var cache = make(map[string]string)
func setCache(key, value string) {
cache[key] = value // DATA RACE jika dipanggil dari beberapa goroutine!
}
// BENAR opsi 1: sync.Map
var cache sync.Map
func setCache(key, value string) {
cache.Store(key, value) // thread-safe
}
func getCache(key string) (string, bool) {
val, ok := cache.Load(key)
if !ok {
return "", false
}
return val.(string), true
}
// BENAR opsi 2: map biasa + RWMutex (lebih fleksibel untuk kasus complex)
type SafeMap struct {
mu sync.RWMutex
data map[string]string
}
func (m *SafeMap) Set(key, value string) {
m.mu.Lock()
defer m.mu.Unlock()
m.data[key] = value
}
func (m *SafeMap) Get(key string) (string, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
val, ok := m.data[key]
return val, ok
}
sync.Map dioptimalkan untuk dua kasus spesifik: (1) ketika setiap key hanya ditulis sekali tapi dibaca berkali-kali, dan (2) ketika goroutine membaca dan menulis key yang berbeda-beda (tidak ada hotspot). Untuk kasus selain ini, map biasa dengan sync.RWMutex biasanya memberikan performa yang lebih baik dan lebih mudah dipahami.
Checklist Review Kode Concurrent
Gunakan checklist ini saat mereview kode yang menggunakan goroutine, channel, atau shared state:
GOROUTINE:
□ Apakah setiap goroutine punya cara untuk berhenti (context, done channel)?
□ Apakah goroutine tidak akan leak jika pemanggil sudah selesai/error?
□ Apakah closure goroutine menangkap variabel loop dengan benar?
□ Apakah goroutine yang berjalan background punya mekanisme error reporting?
CHANNEL:
□ Apakah channel selalu diinisialisasi sebelum digunakan?
□ Apakah channel hanya ditutup oleh sisi pengirim?
□ Apakah tidak ada kemungkinan mengirim ke channel yang sudah ditutup?
□ Apakah buffered channel berukuran tepat (tidak terlalu kecil hingga blocking)?
SHARED STATE:
□ Apakah setiap akses ke shared variable dilindungi oleh mutex atau atomic?
□ Apakah map yang diakses dari beberapa goroutine menggunakan sync.Map atau mutex?
□ Apakah scope lock sesempit mungkin (tidak mencakup operasi yang tidak butuh lock)?
□ Apakah tidak ada nested lock yang bisa menyebabkan deadlock?
MUTEX:
□ Apakah Unlock selalu dipanggil (idealnya via defer)?
□ Apakah fungsi internal (dipanggil saat lock sudah dipegang) tidak melakukan lock lagi?
□ Apakah RWMutex digunakan dengan tepat (RLock untuk read, Lock untuk write)?
WAITGROUP:
□ Apakah wg.Add() dipanggil sebelum goroutine dimulai, bukan di dalam goroutine?
□ Apakah wg.Done() selalu dipanggil (idealnya via defer)?
CONTEXT:
□ Apakah context.WithCancel/WithTimeout selalu memanggil cancel() via defer?
□ Apakah goroutine long-running menghormati ctx.Done()?
□ Apakah context tidak disimpan di dalam struct (harus dipass sebagai parameter)?
RACE DETECTOR:
□ Apakah go test -race sudah dijalankan?
□ Apakah tidak ada output WARNING: DATA RACE dari race detector?
Ringkasan
- Goroutine sangat ringan (~2 KB stack awal, bisa tumbuh dinamis) dan dikelola oleh scheduler M:N runtime Go, bukan langsung oleh kernel OS.
- Channel adalah mekanisme komunikasi primer antar goroutine; unbuffered channel menyediakan sinkronisasi, buffered channel memungkinkan decoupling hingga batas kapasitas.
- Race condition terjadi saat dua goroutine mengakses shared memory tanpa sinkronisasi, setidaknya satu melakukan penulisan — hasilnya tidak deterministik.
- Gunakan
go run -racedango test -racesecara rutin; race detector sangat efektif menemukan race condition yang tidak terlihat dari kode saja.sync.Mutexuntuk perlindungan umum;sync.RWMutexuntuk optimasi read-heavy;sync/atomicuntuk operasi atomik pada tipe numerik sederhana.- Goroutine leak adalah goroutine yang tidak pernah selesai; selalu sediakan mekanisme berhenti via context atau done channel.
- Closure menangkap variabel loop adalah bug klasik; pass nilai sebagai argumen fungsi, bukan mengandalkan referensi ke variabel loop.
- Deadlock bisa terjadi akibat goroutine saling menunggu via channel, atau mutex yang di-lock dua kali (nested lock) — pisahkan fungsi internal (tanpa lock) dari fungsi publik (dengan lock).
- Worker pool membatasi jumlah goroutine concurrent; fan-out/fan-in mendistribusikan dan mengumpulkan pekerjaan; pipeline menghubungkan stage-stage pemrosesan concurrent.
context.Contextadalah cara idiomatik untuk cancellation dan timeout; selalu panggilcancel()viadeferdan pastikan goroutine menghormatictx.Done().- Map bawaan Go tidak thread-safe; gunakan
sync.Mapataumap+sync.RWMutexuntuk concurrent access.