Concurrency dan Race Condition di Go: Panduan Lengkap dari Dasar hingga Pola Produksi
21 min read

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:

AspekThread OSGoroutine
Dikelola olehKernel OSRuntime Go
Ukuran stack awal1–8 MB (tetap)~2 KB (dinamis)
Context switchLambat (kernel mode)Cepat (user space)
Jumlah praktisRatusanJutaan
KomunikasiShared memory + lockChannel 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 --> M2

Jumlah 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")
}

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, gunakan sync.Once atau koordinasi tambahan untuk memastikan close hanya 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:

  1. Baca nilai counter dari memori ke register CPU
  2. Tambahkan 1 ke nilai di register
  3. 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
}
Kondisisync.Mutexsync.RWMutex
Hanya ada writer✓ Lebih sederhana✓ Bisa, tapi overkill
Banyak reader, sedikit writer✓ Bisa, tapi lambat✓ Ideal
Banyak writer✓ CocokOverhead 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 panggil wg.Add(n) sebelum goroutine dimulai, bukan di dalam goroutine. Jika Add dipanggil di dalam goroutine, ada kemungkinan Wait() dipanggil sebelum Add() sempat dieksekusi, menyebabkan Wait() 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
    end

Pola 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 fungsi cancel yang dikembalikan oleh context.WithCancel, context.WithTimeout, dan context.WithDeadline — bahkan jika context sudah expired secara alami. Tidak memanggil cancel menyebabkan 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 -race dan go test -race secara rutin; race detector sangat efektif menemukan race condition yang tidak terlihat dari kode saja.
  • sync.Mutex untuk perlindungan umum; sync.RWMutex untuk optimasi read-heavy; sync/atomic untuk 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.Context adalah cara idiomatik untuk cancellation dan timeout; selalu panggil cancel() via defer dan pastikan goroutine menghormati ctx.Done().
  • Map bawaan Go tidak thread-safe; gunakan sync.Map atau map + sync.RWMutex untuk concurrent access.

Portofolio