Go Concurrency — Materi Sesi (verifikasi: Go 1.27.3, dirilis 2026-10-19)
Semua kode di bawah dijalankan nyata di mesin sesi ini (go1.27.3 linux/amd64, AMD EPYC 7K62, 2 vCPU, GOMAXPROCS=2). Angka benchmark berasal dari run aktual, bukan dari blog.
📚 Topik
Goroutine, channel, dan pola konkurensi aman — plus tiga perubahan Go 1.25→1.27 yang mengubah cara menulis kode konkuren: sync.WaitGroup.Go, testing/synctest, dan goroutineleak profile.
🎯 Tujuan Belajar
Setelah sesi ini bisa:
1. Menulis worker-pool berbatas dengan cancel propagation yang benar (tanpa goroutine leak).
2. Membuktikan race dengan -race dan membuktikan leak dengan pprof.Lookup("goroutineleak").
3. Menulis test timeout 60 detik yang selesai dalam 0.00s pakai synctest.
🏗️ Arsitektur & Alur Teknis
Model runtime: G-M-P
- G = goroutine (stack mulai 2 KB, tumbuh dinamis).
- M = OS thread.
- P = logical processor, jumlahnya =
GOMAXPROCS. Goroutine hanya jalan kalau punya P.
Konsekuensi praktis: goroutine murah (~700 ns spawn, 1 alokasi — lihat benchmark), tapi blocking syscall tanpa runtime integration memarkir M, bukan P. net, os file, time.Sleep, channel — semua terintegrasi dengan netpoller/scheduler, jadi aman.
Perubahan penting 1.25–1.27 (terverifikasi dari release notes resmi)
| Versi | Perubahan | Dampak ke kode konkuren |
|---|---|---|
| 1.25 (Agu 2025) | sync.WaitGroup.Go(f func()) |
Add(1) + go func(){defer Done()} jadi satu panggilan. Counter tidak bisa mismatch lagi. |
| 1.25 | testing/synctest stabil (Run deprecated → Test) |
Test konkuren deterministik, jam palsu. |
| 1.25 | Container-aware GOMAXPROCS |
Runtime baca cgroup CPU quota. Default = min(logical CPU, quota), dibulatkan ke atas, minimum 2. Update otomatis ~1×/detik. |
| 1.26 (Feb 2026) | goroutineleak profile (eksperimental, GOEXPERIMENT=goroutineleakprofile) |
Deteksi goroutine yang mustahil unblock, berbasis reachability GC. |
| 1.26 | Green Tea GC default | Klaim Go team: 10–40% pengurangan overhead GC (DIKETAHUI dari release notes; tidak saya benchmark). |
| 1.27 (Agu 2026) | goroutineleak GA, GOEXPERIMENT dihapus |
Tersedia /debug/pprof/goroutineleak. |
| 1.27 | synctest.Sleep(d) |
Gabungan time.Sleep + synctest.Wait. |
| 1.27 | Generic methods | func (b Box[T]) Map[U any](...) — tapi interface tidak boleh punya method generic. |
Catatan GOMAXPROCS container (penting untuk deploy): yang dibaca hanya CPU quota (Kubernetes limits.cpu, Docker --cpus), bukan CPU request/shares. Perilaku baru hanya aktif kalau go.mod menyebut go 1.25 atau lebih tinggi. Opt-out: GODEBUG=containermaxprocs=0 (abaikan quota), GODEBUG=updatemaxprocs=0 (jangan auto-update). Sebelum 1.25, semua orang pakai uber-go/automaxprocs; untuk modul ≥1.25 dependency itu jadi redundan.
💻 Kode Nyata
1. Worker pool berbatas + fail-fast (pola yang dipakai 90% kasus produksi)
pool/pool.go — lulus go test -race:
package pool
import (
"context"
"errors"
"sync"
"time"
)
// MapLimit menjalankan fn untuk setiap item, maksimal `limit` goroutine aktif.
// Hasil berurutan sesuai input; error pertama membatalkan sisanya.
func MapLimit[T, R any](ctx context.Context, items []T, limit int,
fn func(context.Context, T) (R, error)) ([]R, error) {
if limit < 1 {
limit = 1
}
ctx, cancel := context.WithCancel(ctx)
defer cancel() // wajib: lepas resource kalau return awal
out := make([]R, len(items))
sem := make(chan struct{}, limit)
var (
wg sync.WaitGroup
errOnce sync.Once
firstErr error
)
for i, item := range items {
wg.Go(func() { // Go 1.25+
select {
case sem <- struct{}{}:
case <-ctx.Done():
return
}
defer func() { <-sem }()
r, err := fn(ctx, item)
if err != nil {
errOnce.Do(func() { firstErr = err; cancel() })
return
}
out[i] = r
})
}
wg.Wait()
if firstErr != nil {
return nil, firstErr
}
return out, ctx.Err()
}
var ErrTimeout = errors.New("timeout")
func Read(in <-chan int) (int, error) {
select {
case v := <-in:
return v, nil
case <-time.After(60 * time.Second):
return 0, ErrTimeout
}
}
Tiga detail yang bukan opsional:
- defer cancel() — tanpa ini, ctx bocor sampai parent cancel.
- sem <- struct{}{} dibungkus select dengan ctx.Done() — tanpa ini, goroutine bisa nyangkut menunggu slot setelah pembatalan.
- out[i] = r (bukan append) — append dari banyak goroutine butuh mutex; index unik tidak.
2. Test: batas konkurensi + fail-fast + fake clock
package pool
import (
"context"
"errors"
"sync/atomic"
"testing"
"testing/synctest"
"time"
)
func TestMapLimitOrder(t *testing.T) {
var peak, cur atomic.Int32
got, err := MapLimit(context.Background(), []int{1, 2, 3, 4, 5}, 2,
func(ctx context.Context, v int) (int, error) {
n := cur.Add(1)
for {
p := peak.Load()
if n <= p || peak.CompareAndSwap(p, n) {
break
}
}
time.Sleep(10 * time.Millisecond)
cur.Add(-1)
return v * 10, nil
})
if err != nil {
t.Fatal(err)
}
want := []int{10, 20, 30, 40, 50}
for i := range want {
if got[i] != want[i] {
t.Fatalf("index %d: got %d want %d", i, got[i], want[i])
}
}
if p := peak.Load(); p > 2 {
t.Fatalf("limit breached: peak=%d", p)
}
}
func TestMapLimitFailFast(t *testing.T) {
boom := errors.New("boom")
_, err := MapLimit(context.Background(), []int{1, 2, 3}, 3,
func(ctx context.Context, v int) (int, error) {
if v == 2 {
return 0, boom
}
<-ctx.Done() // dibatalkan oleh v==2
return v, nil
})
if !errors.Is(err, boom) {
t.Fatalf("got %v want boom", err)
}
}
func TestReadTimeoutFakeClock(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ch := make(chan int)
start := time.Now()
_, err := Read(ch)
if !errors.Is(err, ErrTimeout) {
t.Fatalf("got %v want timeout", err)
}
if d := time.Since(start); d != 60*time.Second {
t.Fatalf("fake clock advanced %v", d)
}
})
}
Hasil nyata:
=== RUN TestMapLimitOrder
--- PASS: TestMapLimitOrder (0.03s)
=== RUN TestMapLimitFailFast
--- PASS: TestMapLimitFailFast (0.00s)
=== RUN TestReadTimeoutFakeClock
--- PASS: TestReadTimeoutFakeClock (0.00s)
ok demo/pool 1.043s
synctest bekerja begini: setiap goroutine di "bubble" pakai jam palsu mulai 2000-01-01 00:00 UTC. Jam maju hanya ketika semua goroutine di bubble blocked. Jadi test timeout 60 detik selesai instan, tapi time.Since tetap melaporkan 60s. Ini menghapus seluruh kelas flakiness time.Sleep di CI.
Batasan synctest yang harus diingat:
- t di dalam bubble bukan *testing.T normal — jangan panggil t.Run, t.Parallel, t.Deadline.
- Semua goroutine yang dipakai harus lahir di dalam bubble. Goroutine yang sudah ada sebelum bubble = panic.
- Go 1.27 menambah httptest.NewTestServer (in-memory fake network) supaya HTTP handler bisa diuji di dalam bubble.
3. Deteksi goroutine leak (Go 1.27 GA)
func leak() {
ch := make(chan int) // hanya goroutine ini yang pegang ch
ch <- 1 // blocked selamanya: tidak ada yang receive
}
func main() {
go leak()
runtime.Gosched()
pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 1)
}
Output nyata:
goroutineleak profile: total 1
1 @ 0x47d82a 0x41421c 0x413e17 0x4de9c8 0x483561
# 0x4de9c7 main.leak+0x27 /tmp/goconc/leak/main.go:12
Kasus realistis — early return dari fan-in (contoh dari release notes Go 1.26, saya reproduksi):
type result struct{ v int; err error }
func process(n int) ([]int, error) {
ch := make(chan result) // unbuffered
for i := range n {
go func() { ch <- result{v: i} }()
}
var out []int
for range n {
r := <-ch
if r.v == 2 {
return nil, errors.New("early return") // 4 goroutine leak
}
out = append(out, r.v)
}
return out, nil
}
goroutineleak profile: total 4
4 @ 0x47d82a 0x41421c 0x413e17 0x4ded2b 0x483561
# 0x4ded2a main.process.func1+0x2a /tmp/goconc/leak2/main.go:16
Mekanismenya: GC-based reachability. Kalau goroutine G blocked di primitive P, dan P tidak reachable dari goroutine runnable mana pun (maupun dari goroutine yang mereka bisa unblock), maka G mustahil bangun → leak. Karena itu runtime.GC() (atau scrape endpoint) diperlukan sebelum baca profile.
Batas deteksi (dari release notes, eksplisit): leak lewat global variable atau local variable goroutine yang masih runnable tidak terdeteksi. Ini bukan silver bullet.
Cara pakai di produksi:
GET /debug/pprof/goroutineleak?debug=1
Biaya runtime nol kalau tidak dipakai (desainnya: on-demand).
4. Membuktikan race
func TestCounterUnsafe(t *testing.T) {
var c Counter
var wg sync.WaitGroup
for range 100 {
wg.Go(func() { c.n++ }) // tanpa lock
}
wg.Wait()
}
go test -race:
==================
WARNING: DATA RACE
Read at 0x00c000018288 by goroutine 9:
demo/race.TestCounterUnsafe.func1()
/tmp/goconc/race/race_test.go:21 +0x37
Previous write at 0x00c000018288 by goroutine 25:
demo/race.TestCounterUnsafe.func1()
/tmp/goconc/race/race_test.go:21 +0x4f
Race detector = happens-before dinamis (ThreadSanitizer-style). Konsekuensi: hanya menangkap race yang benar-benar terjadi di run itu. Coverage bukan jaminan. Jalankan -race -count=N di CI, dan -race wajib untuk paket apa pun yang menyentuh shared state.
📊 Angka & Benchmark
Run nyata di sesi ini — go test -bench=. -benchmem -count=3, Go 1.27.3, 2 vCPU:
| Benchmark | ns/op (3 run) | allocs/op | Catatan |
|---|---|---|---|
WaitGroup.Go spawn + join |
835.6 / 693.4 / 741.6 | 1 (24–29 B) | biaya minimum satu goroutine |
| Channel ping-pong (2 goroutine, unbuffered) | 687.5 / 701.7 / 796.8 | 0 | ~700 ns per round-trip = ~2 context switch |
sync.Mutex lock/unlock |
13.01 / 14.21 / 13.68 | 0 | uncontended |
atomic.Int64.Add |
6.92 / 7.31 / 6.66 | 0 | ~½ biaya mutex |
Cara baca: - Spawn goroutine ≈ 50× lebih mahal dari satu mutex lock. Untuk 5 item, fan-out goroutine per item masih menang (I/O latency dominan). Untuk 10 juta item CPU-bound, goroutine-per-item = bencana; pakai worker pool berbatas. - Channel round-trip ≈ 700 ns di mesin ini. Kalau butuh <100 ns per transfer, channel adalah pilihan salah — pakai sharding + mutex, atau batch. - Ini 1 mesin, 2 vCPU, uncontended. Angka mutex/atomic akan memburuk drastis saat contended (cache-line ping-pong antar core). Jangan generalisasi ke server 64 core.
Klaim vendor yang tidak saya verifikasi (sebut sebagai klaim, bukan fakta): Green Tea GC 10–40% pengurangan overhead GC; size-specialized malloc "hingga 30% lebih murah untuk alokasi <80 byte, ~1% overall, +60 KB binary" (Go 1.27 release notes).
⚖️ Trade-off & Alternatif
| Kebutuhan | Pilihan | Kenapa |
|---|---|---|
| N task independen, error bikin batal semua | errgroup.Group + SetLimit(n) |
Sudah menangani cancel + first-error + limit. Jangan tulis ulang. |
| N task, butuh hasil berurutan per index | MapLimit di atas |
errgroup tidak preserve index. |
| Fire-and-forget, tidak peduli selesai | go f() |
Tapi ini sumber leak #1. Kalau ada kemungkinan block, jangan. |
| Pipeline multi-stage | channel + close() di tiap stage |
Stage wajib close(out) supaya range di stage berikutnya berhenti. |
| Counter/metrik panas | atomic.Int64 atau sharded counter |
2× lebih cepat dari mutex, uncontended. Contended: shard per-P. |
| Shared map read-heavy | sync.Map hanya kalau key stabil & write jarang |
Untuk kasus umum, RWMutex + map biasa lebih cepat dan lebih jelas. |
| Test dengan timer/timeout | testing/synctest |
Deterministik, instan. |
errgroup vs MapLimit vs WaitGroup: errgroup = 80% kasus, pakai itu. WaitGroup.Go = saat tidak butuh error propagation. MapLimit custom = hanya kalau butuh index-preserving result atau semantik limit yang beda dari SetLimit (yang blocking, bukan select-able).
Alternatif bahasa/model: kalau masalahnya benar-benar CPU-bound paralel numeric, goroutine + channel bukan alat terbaik — simd/archsimd (eksperimental, Go 1.26, amd64 saja, API belum stabil) atau pindah ke Rust/Rayon. Channel bukan pengganti parallel-for.
🏭 Use Case Produksi & Failure Mode Umum
Pola yang terbukti: fan-out HTTP call ke N upstream dengan batas konkurensi, context.WithTimeout di level request, errgroup.SetLimit(8). Ini yang dipakai di hampir semua service Go yang saya tahu.
Failure mode, urut dari yang paling sering saya lihat:
- Early return tanpa
defer cancel()→ context leak + goroutine leak. Terdeteksi olehgoroutineleakprofile. - Unbuffered channel + early return di fan-in → persis contoh
process()di atas. Fix: bufferlen(items)atau pakaierrgroup. for range chtanpaclose(ch)→ consumer hang selamanya. Kalau ada >1 producer,closeharus setelahwg.Wait(), dan hanya sekali.- Closure capture loop variable — sudah tidak masalah sejak Go 1.22 (per-iteration variable). Tapi kalau
go.modmasihgo 1.21, bug lama masih hidup. Cekgodirective sebelum percaya. - GOMAXPROCS salah di Kubernetes (pre-1.25) → Go melihat 64 CPU host padahal limit 2 → throttling parah, GC agresif. Sejak 1.25 otomatis, asalkan
go.mod≥go 1.25dan tidak adaGOMAXPROCSenv override yang nyangkut dari masa lalu. Cek:fmt.Println(runtime.GOMAXPROCS(0))di startup log. - Mutex di dalam loop yang memanggil I/O → seluruh throughput jadi serial. Semaphore/channel untuk I/O, mutex hanya untuk state in-memory pendek.
time.Afterdi dalam loop panas → alokasi timer tiap iterasi, dan timer tidak di-GC sebelum fire (pre-1.23). Pakaitime.NewTimer+Reset, ataucontext.WithTimeout.- Panic di dalam
WaitGroup.Go— docs eksplisit: "The function f must not panic." Panic akan meng-crash program (bukan ditelan). Kalau butuh recover, bungkus sendiri.
Catatan deployment: goroutineleak endpoint ada di /debug/pprof/ — jangan expose ke publik. net/http/pprof default-nya sudah ada di DefaultServeMux kalau di-import; mount di listener internal saja.
🛠️ Latihan Praktis Kecil
Semua di laptop, <10 menit:
# setup (go1.27.3)
mkdir -p /tmp/goconc && cd /tmp/goconc && go mod init demo
# 1. buktikan leak profile bekerja
mkdir -p leak && cat > leak/main.go <<'EOF'
package main
import ("fmt";"os";"runtime";"runtime/pprof")
func leak(){ ch := make(chan int); ch <- 1 }
func main(){ go leak(); runtime.Gosched()
pprof.Lookup("goroutineleak").WriteTo(os.Stdout,1) }
EOF
go run ./leak
Lalu, berurutan:
1. Hapus go dari go leak() → jadi deadlock di main, bukan leak. Bandingkan outputnya.
2. Tambahkan runtime.GC() sebelum WriteTo pada kasus fan-in early-return. Amati jumlah leak berubah/tidak.
3. Jalankan go test ./pool -race -count=50 pada TestMapLimitOrder. Kalau flaky, berarti ada bug di pola limit — cari.
4. Di dalam synctest.Test, hapus time.Sleep dari handler dan lihat apakah test masih pass (petunjuk: jam palsu hanya maju saat semua blocked).
5. Cek GOMAXPROCS container:
docker run --rm --cpus=1 golang:1.27-alpine \
sh -c 'go run /dev/stdin <<EOF
package main
import ("fmt";"runtime")
func main(){fmt.Println(runtime.NumCPU(), runtime.GOMAXPROCS(0))}
EOF'
Harapan: NumCPU = CPU host, GOMAXPROCS = 2 (minimum, walau limit 1).
✅ Cek Pemahaman
MapLimitdi atas punyadefer cancel(). Kalau dihapus, apakahgoroutineleakprofile akan menandai sesuatu? (Petunjuk:cancelbukan goroutine. Pikirkan apa yang tidak dilepas.)- Kenapa
synctestmengharuskan semua goroutine lahir di dalam bubble? Apa yang terjadi kalau satu goroutine di-spawn sebelumsynctest.Test? - Sebuah service Go 1.26 di Kubernetes punya
limits.cpu: 500m,requests.cpu: 2. BerapaGOMAXPROCSefektif, dan kenapa itu bisa jadi masalah?
🔑 Poin Kunci
- Goroutine murah (~700 ns, 1 alloc) — bukan gratis. Spawn ≈ 50× mutex lock.
WaitGroup.Go(1.25) menghapus kelas bugAdd/Donemismatch. Pakai.synctest(stabil 1.25) = test konkuren deterministik. Ini perubahan terbesar untuk kualitas test Go dalam beberapa tahun.goroutineleakprofile GA di 1.27. Deteksi berbasis GC reachability; butuh GC cycle; tidak menangkap leak lewat global.GOMAXPROCScontainer-aware (1.25) baca quota, bukan request. Syarat:go.mod≥ 1.25, tidak ada env override.- Channel ≈ 700 ns round-trip. Untuk transfer sub-100ns, channel salah alat.
- Loop variable per-iteration sejak 1.22 — tapi cek
godirective digo.mod, bukan versi toolchain. errgroup.SetLimitmenutupi 80% kebutuhan worker pool. Tulis custom hanya kalau butuh index-preserving.
➡️ Eksplorasi Lanjut
go tool trace— Go 1.27 membatasi-http=:6060ke localhost (breaking untuk setup lama yang expose ke LAN).runtime/metricsGo 1.26 menambah/sched/goroutines,/sched/threads,/sched/goroutines-created— bagus untuk dashboard scheduler pressure.- Baca paper di balik
goroutineleak: Saioc et al., ACM (link di release notes Go 1.26, proposal issue #74609). errgroupv0.24.0 (golang.org/x/sync) — cekTryGountuk kasus backpressure non-blocking.- Latihan lanjut: implementasi bounded pipeline 3-stage dengan
closeyang benar, lalu buktikan nol leak via profile setelah 1 juta item.
Limitasi riset sesi ini: semua benchmark = 1 mesin 2 vCPU uncontended; klaim GC 10–40% dan malloc 30% adalah klaim Go team yang tidak saya ukur ulang. simd/archsimd masih eksperimental dan API belum stabil — jangan dipakai di produksi.