Worker Pool Pattern #
Setiap sistem backend yang memproses banyak pekerjaan secara paralel pasti menghadapi pertanyaan yang sama: berapa banyak worker yang harus dibuat? Terlalu sedikit, dan sistem menjadi lambat karena antrian job terus menumpuk. Terlalu banyak, dan sistem justru kolaps karena resource contention — CPU bersaing, koneksi database habis, dan memory meledak. Worker Pool Pattern hadir untuk menjawab ketegangan ini secara elegan. Alih-alih membuat satu goroutine per task (yang berisiko tak terkendali), pattern ini mendefinisikan jumlah worker yang terkontrol, mempertemukan mereka dengan job queue, dan membiarkan sistem berjalan stabil dalam jangka panjang.
Apa itu Worker Pool Pattern? #
Worker Pool Pattern adalah pola concurrency di mana sekumpulan worker — jumlahnya tetap atau terkontrol — dibuat terlebih dahulu dan siap memproses job yang masuk melalui sebuah antrian bersama. Worker tidak dibuat per task; sebaliknya, mereka hidup sepanjang siklus aplikasi dan mengambil job dari queue satu per satu.
Tiga komponen utama membentuk pattern ini:
| Komponen | Peran |
|---|---|
| Job Queue | Antrian job yang menunggu diproses — biasanya berupa channel di Go |
| Worker | Goroutine (atau thread) yang mengambil dan memproses job dari queue |
| Dispatcher | Bagian yang mengirim job ke queue; bisa berupa loop, HTTP handler, atau consumer Kafka |
Secara struktural, alur kerja Worker Pool terlihat seperti ini:
flowchart LR
P[Producer / Dispatcher] --> Q[(Job Queue)]
Q --> W1[Worker 1]
Q --> W2[Worker 2]
Q --> W3[Worker N]
W1 --> R[(Result / Output)]
W2 --> R
W3 --> R
Yang membedakan Worker Pool dari pendekatan naif adalah reuse. Worker tidak dibuat dan dihancurkan setiap kali ada job — mereka sudah ada, menunggu, dan langsung bekerja begitu job tiba. Ini menghilangkan overhead pembuatan goroutine berulang dan menjaga memory footprint tetap terprediksi.
Mengapa Pattern Ini Penting? #
Sebelum memahami cara implementasinya, penting untuk mengerti mengapa Worker Pool dibutuhkan — dan masalah apa yang muncul jika kamu tidak menggunakannya.
Masalah Tanpa Worker Pool #
Bayangkan sebuah HTTP handler yang memproses setiap request dengan goroutine baru:
// ANTI-PATTERN: membuat goroutine baru untuk setiap job tanpa batas
http.HandleFunc("/process", func(w http.ResponseWriter, r *http.Request) {
go processJob(r.Context()) // tidak ada kontrol jumlah goroutine
})
// BENAR: kirim job ke worker pool yang sudah berjalan
http.HandleFunc("/process", func(w http.ResponseWriter, r *http.Request) {
jobQueue <- Job{ctx: r.Context()}
})
Pendekatan pertama tampak sederhana, tapi dalam kondisi traffic tinggi ia bisa menciptakan ribuan goroutine secara bersamaan. Masing-masing goroutine mengonsumsi memory, bersaing memperebutkan CPU, dan bisa membanjiri resource eksternal seperti database atau API pihak ketiga.
Worker Pool mengatasi ini dengan menetapkan batas atas yang jelas: tidak peduli berapa banyak job yang datang, hanya N worker yang berjalan bersamaan.
Empat Tujuan Utama #
flowchart TD
WP[Worker Pool Pattern]
WP --> A[Kontrol Concurrency]
WP --> B[Resource Efficiency]
WP --> C[Stabilitas Sistem]
WP --> D[Throughput Konsisten]
A --> A1["Batasi goroutine yang berjalan bersamaan"]
B --> B1["Worker di-reuse, tidak dibuat ulang setiap task"]
C --> C1["Cegah thundering herd problem"]
D --> D1["Proses job secara steady, bukan spike ekstrem"]
Kontrol Concurrency — Jumlah worker yang berjalan bersamaan bersifat deterministik. Kamu tahu persis berapa banyak task yang diproses secara paralel pada satu waktu.
Resource Efficiency — Pembuatan goroutine memang murah di Go, tapi tidak gratis. Worker Pool menghilangkan overhead pembuatan berulang dan memastikan stack goroutine hanya dialokasikan sekali.
Stabilitas Sistem — Thundering herd problem terjadi ketika banyak permintaan serentak menyerbu resource yang sama. Worker Pool menyerap lonjakan ini melalui job queue, membiarkan worker memproses secara berurutan.
Throughput Konsisten — Sistem dengan Worker Pool berperilaku lebih dapat diprediksi di bawah beban karena tidak ada spike mendadak yang mengganggu latency.
Kapan Menggunakan Worker Pool? #
Tidak semua situasi membutuhkan Worker Pool. Pattern ini paling efektif pada kondisi tertentu.
flowchart TD
S{Apakah task-nya independen?} -- Tidak --> X1[❌ Tidak cocok — pertimbangkan Pipeline]
S -- Ya --> T{Apakah task bersifat I/O bound?}
T -- Ya --> U[✅ Worker Pool sangat cocok]
T -- Tidak --> V{Apakah task datang secara stream/queue?}
V -- Ya --> W[✅ Worker Pool cocok]
V -- Tidak --> Y{Apakah ada resource limit eksternal?}
Y -- Ya --> Z[✅ Worker Pool cocok]
Y -- Tidak --> X2[⚠️ Pertimbangkan dulu — mungkin tidak perlu]
Skenario yang Tepat #
I/O bound tasks adalah kandidat terbaik. HTTP calls ke external API, query ke database, baca/tulis disk, atau konsumsi message queue — semua ini melibatkan waktu tunggu yang lama, dan selama menunggu, worker bisa melayani job lain dari queue.
Stream atau queue-based input — Jika job datang terus-menerus dari Kafka, RabbitMQ, SQS, atau endpoint HTTP, Worker Pool adalah pasangan alami. Consumer membaca pesan, memasukkan ke job queue, dan worker memprosesnya dengan kecepatan yang terkontrol.
Resource dengan limit eksternal — Database connection pool, rate limit API pihak ketiga, dan semaphore eksplisit semuanya memiliki batas. Worker Pool memastikan kamu tidak melebihi batas tersebut dengan mengontrol berapa banyak task yang berjalan bersamaan.
Skenario yang Kurang Tepat #
Task yang sangat sedikit — Jika kamu hanya memproses 5 job sekali jalan, overhead setup Worker Pool tidak sepadan.
Task dengan dependensi kuat — Jika task B harus menunggu hasil task A sebelum bisa mulai, Worker Pool polos tidak cukup. Kamu butuh pipeline atau DAG scheduler.
Task membutuhkan prioritas kompleks — Standar channel di Go bersifat FIFO. Jika kamu butuh priority queue yang kompleks, Worker Pool dasar perlu dimodifikasi signifikan.
Implementasi Dasar di Golang #
Go memiliki goroutine dan channel yang membuat implementasi Worker Pool terasa sangat natural. Mari mulai dari implementasi paling sederhana, lalu kembangkan secara bertahap.
Struktur Dasar #
package main
import (
"fmt"
"sync"
"time"
)
// Job merepresentasikan pekerjaan yang perlu diproses
type Job struct {
ID int
Payload string
}
// Result merepresentasikan hasil pemrosesan job
type Result struct {
JobID int
Output string
Err error
}
// worker adalah fungsi yang dijalankan oleh setiap goroutine worker
func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs { // range akan keluar saat channel ditutup
fmt.Printf("Worker %d mengambil job %d\n", id, job.ID)
// simulasi pemrosesan
time.Sleep(500 * time.Millisecond)
output := fmt.Sprintf("Job %d selesai oleh Worker %d", job.ID, id)
results <- Result{JobID: job.ID, Output: output}
}
}
func main() {
const workerCount = 3
const jobCount = 10
jobs := make(chan Job, jobCount) // buffered channel untuk job queue
results := make(chan Result, jobCount) // buffered channel untuk hasil
var wg sync.WaitGroup
// Mulai worker pool
for i := 1; i <= workerCount; i++ {
wg.Add(1)
go worker(i, jobs, results, &wg)
}
// Kirim semua job ke queue
for j := 1; j <= jobCount; j++ {
jobs <- Job{ID: j, Payload: fmt.Sprintf("data-%d", j)}
}
close(jobs) // sinyal bahwa tidak ada job baru
// Tunggu semua worker selesai, lalu tutup results
go func() {
wg.Wait()
close(results)
}()
// Kumpulkan hasil
for result := range results {
fmt.Println(result.Output)
}
}
Poin-poin kunci dari implementasi ini:
jobsadalah job queue — channel yang menampung semua pekerjaan yang menungguclose(jobs)memberi sinyal ke semua worker bahwa tidak ada job baru;range jobsakan keluar secara otomatissync.WaitGroupmemastikan program menunggu sampai semua worker benar-benar selesairesultschannel memungkinkan worker mengembalikan hasil secara concurrent-safe
Alur Eksekusi #
sequenceDiagram
participant M as Main / Producer
participant Q as Job Queue (channel)
participant W1 as Worker 1
participant W2 as Worker 2
participant W3 as Worker 3
participant R as Results Channel
M->>Q: Kirim Job 1–10
M->>Q: close(jobs)
par Worker berjalan bersamaan
W1->>Q: Ambil Job 1
W2->>Q: Ambil Job 2
W3->>Q: Ambil Job 3
end
W1->>R: Result Job 1
W2->>R: Result Job 2
W3->>R: Result Job 3
W1->>Q: Ambil Job 4
W2->>Q: Ambil Job 5
Note over Q: Queue kosong + closed
W1-->>M: wg.Done()
W2-->>M: wg.Done()
W3-->>M: wg.Done()
M->>R: close(results)
M->>M: Kumpulkan semua result
Worker Pool dengan Context dan Graceful Shutdown #
Implementasi dasar di atas berfungsi, tapi production code membutuhkan lebih: kemampuan menghentikan worker secara bersih saat aplikasi menerima sinyal shutdown atau request di-cancel.
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
type Job struct {
ID int
}
type Result struct {
JobID int
Output string
Err error
}
// worker yang context-aware
func worker(ctx context.Context, id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
// BENAR: selalu tangani panic di dalam worker
defer func() {
if r := recover(); r != nil {
log.Printf("Worker %d: panic recovered: %v", id, r)
}
}()
for {
select {
case <-ctx.Done():
// Context dibatalkan — keluar dengan rapi
log.Printf("Worker %d: shutdown karena context: %v", id, ctx.Err())
return
case job, ok := <-jobs:
if !ok {
// Channel ditutup — tidak ada job baru
log.Printf("Worker %d: job channel ditutup, keluar", id)
return
}
// Proses job
result := processJob(ctx, id, job)
results <- result
}
}
}
func processJob(ctx context.Context, workerID int, job Job) Result {
// Simulasi pekerjaan dengan context awareness
select {
case <-ctx.Done():
return Result{JobID: job.ID, Err: ctx.Err()}
case <-time.After(200 * time.Millisecond):
return Result{
JobID: job.ID,
Output: fmt.Sprintf("Job %d selesai oleh Worker %d", job.ID, workerID),
}
}
}
func main() {
const workerCount = 3
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Tangkap sinyal OS untuk graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
go func() {
sig := <-sigChan
log.Printf("Menerima sinyal: %v, memulai shutdown...", sig)
cancel()
}()
jobs := make(chan Job, 50)
results := make(chan Result, 50)
var wg sync.WaitGroup
// Start worker pool
for i := 1; i <= workerCount; i++ {
wg.Add(1)
go worker(ctx, i, jobs, results, &wg)
}
// Producer goroutine
go func() {
defer close(jobs)
for j := 1; j <= 20; j++ {
select {
case <-ctx.Done():
log.Println("Producer: context dibatalkan, berhenti mengirim job")
return
case jobs <- Job{ID: j}:
log.Printf("Job %d dikirim ke queue", j)
}
}
}()
// Tunggu worker selesai lalu tutup results
go func() {
wg.Wait()
close(results)
}()
// Kumpulkan hasil
for result := range results {
if result.Err != nil {
log.Printf("Job %d error: %v", result.JobID, result.Err)
continue
}
fmt.Println(result.Output)
}
log.Println("Semua worker selesai.")
}
Diagram shutdown yang bersih:
stateDiagram-v2
[*] --> Running: Worker pool dimulai
Running --> Draining: close(jobs) atau ctx.Done()
Draining --> Finishing: Semua job di queue diproses
Finishing --> Done: wg.Wait() selesai
Done --> [*]
Running --> Interrupted: SIGTERM / SIGINT
Interrupted --> Draining: cancel() dipanggil
Worker Pool vs Thread Pool #
Kedua istilah sering dipertukarkan, tapi ada perbedaan penting — terutama saat berbicara tentang implementasi di Go.
Perbandingan Konseptual #
| Aspek | Thread Pool | Worker Pool (Go) |
|---|---|---|
| Unit Eksekusi | OS Thread | Goroutine |
| Overhead per Unit | ~1–8 MB stack, OS scheduling | ~2–8 KB stack, Go runtime scheduling |
| Mekanisme Komunikasi | Shared memory + mutex/lock | Channel (message passing) |
| Jumlah Maksimal Praktis | Ratusan hingga ribuan | Ratusan ribu hingga jutaan |
| Contoh Bahasa | Java (ExecutorService), C# (ThreadPool) |
Go (goroutine + channel) |
| Kompleksitas Sync | Tinggi (deadlock, race condition) | Lebih rendah (channel by design) |
Perbedaan Filosofi #
Thread Pool di Java atau C# biasanya dikelola oleh runtime atau framework. Kamu menyerahkan tugas ke pool dan runtime yang menentukan thread mana yang menjalankannya. Ini abstraksi yang tinggi, tapi kamu kehilangan kontrol granular.
Worker Pool di Go menggunakan goroutine yang sangat ringan dan channel sebagai primitif komunikasi. Kamu membangun sendiri mekanismenya, tapi justru itulah yang memberikan kontrol penuh — ukuran pool, buffer queue, behavior shutdown, semua ada di tanganmu.
flowchart TD
subgraph Java["Java — Thread Pool"]
J1[Task 1] --> ES[ExecutorService]
J2[Task 2] --> ES
ES --> JT1[OS Thread 1]
ES --> JT2[OS Thread 2]
ES --> JT3[OS Thread N]
end
subgraph Go["Go — Worker Pool"]
G1[Job 1] --> CH[(Channel / Queue)]
G2[Job 2] --> CH
CH --> GR1[Goroutine 1]
CH --> GR2[Goroutine 2]
CH --> GR3[Goroutine N]
end
Secara prinsip: Worker Pool adalah pattern umum, Thread Pool adalah salah satu implementasinya. Di Go, kamu mengimplementasikan Worker Pool menggunakan goroutine (bukan OS thread), yang jauh lebih skalabel.
Variasi Lanjutan #
Worker Pool dasar bisa dikembangkan menjadi berbagai variasi untuk kebutuhan yang lebih kompleks.
Dynamic Worker Pool #
Worker Pool standar memiliki jumlah worker yang tetap. Dynamic Worker Pool menyesuaikan jumlah worker berdasarkan panjang antrian:
type DynamicPool struct {
minWorkers int
maxWorkers int
jobs chan Job
mu sync.Mutex
active int
ctx context.Context
cancel context.CancelFunc
}
func (p *DynamicPool) scale() {
p.mu.Lock()
defer p.mu.Unlock()
queueLen := len(p.jobs)
// Tambah worker jika queue menumpuk dan belum mencapai batas
if queueLen > p.active*2 && p.active < p.maxWorkers {
p.active++
go p.runWorker(p.active)
}
}
Rate-Limited Worker Pool #
Berguna saat memproses job yang memanggil external API dengan rate limit:
import "golang.org/x/time/rate"
func rateLimitedWorker(
ctx context.Context,
id int,
limiter *rate.Limiter,
jobs <-chan Job,
wg *sync.WaitGroup,
) {
defer wg.Done()
for job := range jobs {
// Tunggu token dari rate limiter sebelum memproses
if err := limiter.Wait(ctx); err != nil {
log.Printf("Worker %d: rate limiter error: %v", id, err)
return
}
processJob(ctx, id, job)
}
}
// Inisialisasi: 10 request per detik, burst maksimal 20
limiter := rate.NewLimiter(rate.Limit(10), 20)
Pipeline Worker Pool (Multi-Stage) #
Beberapa Worker Pool yang terhubung secara serial — output dari pool pertama menjadi input pool berikutnya:
flowchart LR
I[Input] --> Q1[(Queue 1)]
Q1 --> S1[Stage 1\nWorker Pool]
S1 --> Q2[(Queue 2)]
Q2 --> S2[Stage 2\nWorker Pool]
S2 --> Q3[(Queue 3)]
Q3 --> S3[Stage 3\nWorker Pool]
S3 --> O[Output]
// stage1 memproses data mentah
func stage1(ctx context.Context, input <-chan RawData) <-chan ParsedData {
output := make(chan ParsedData, 100)
go func() {
defer close(output)
pool := newWorkerPool(ctx, 5, input, output, parseData)
pool.Wait()
}()
return output
}
// stage2 melakukan transformasi pada parsed data
func stage2(ctx context.Context, input <-chan ParsedData) <-chan FinalResult {
output := make(chan FinalResult, 100)
go func() {
defer close(output)
pool := newWorkerPool(ctx, 3, input, output, transformData)
pool.Wait()
}()
return output
}
Retry dan Dead Letter Queue #
flowchart TD
J[Job Masuk] --> Q[(Job Queue)]
Q --> W[Worker]
W -- Sukses --> R[Result]
W -- Gagal, retry < max --> RQ[(Retry Queue)]
RQ --> W
W -- Gagal, retry = max --> DLQ[(Dead Letter Queue)]
DLQ --> Alert[Alert / Manual Review]
type RetryableJob struct {
Job
RetryCount int
MaxRetry int
}
func workerWithRetry(jobs <-chan RetryableJob, dlq chan<- RetryableJob) {
for job := range jobs {
err := processJob(job.Job)
if err != nil {
if job.RetryCount < job.MaxRetry {
// Kembalikan ke queue dengan retry count +1
job.RetryCount++
jobs <- job // HATI-HATI: ini bisa deadlock jika channel tidak buffered
} else {
// Kirim ke dead letter queue
dlq <- job
}
}
}
}
Best Practice #
Memahami implementasi Worker Pool tidak cukup — yang membedakan sistem yang baik dari yang buruk adalah bagaimana kamu menerapkan pattern ini secara benar.
1. Tentukan Jumlah Worker Berdasarkan Jenis Task #
// ANTI-PATTERN: angka ajaib tanpa dasar
workerCount := 100 // dari mana angka ini?
// BENAR: tentukan berdasarkan karakteristik task
numCPU := runtime.NumCPU()
// CPU-bound task: sesuaikan dengan jumlah CPU
cpuBoundWorkers := numCPU
// I/O bound task: bisa lebih banyak karena worker sering menunggu
// Mulai dengan 2x-4x CPU, lalu benchmark
ioBoundWorkers := numCPU * 3
Aturan praktis: CPU-bound → runtime.NumCPU(), I/O-bound → mulai dari 2-4x NumCPU() lalu benchmark dengan load nyata.
2. Pilih Buffer Channel yang Tepat #
// ANTI-PATTERN: unbuffered channel untuk job queue tinggi volume
jobs := make(chan Job) // producer akan block setiap kali worker belum siap
// ANTI-PATTERN: buffer terlalu besar yang menyembunyikan bottleneck
jobs := make(chan Job, 1_000_000) // masalah performa tersembunyi di balik buffer besar
// BENAR: buffer yang proporsional — cukup untuk menyerap burst singkat
jobs := make(chan Job, workerCount*2) // atau sesuaikan dengan throughput yang diharapkan
Monitor panjang queue secara rutin. Jika queue selalu penuh, itu sinyal bahwa consumer (worker) tidak mampu mengimbangi producer.
3. Selalu Tangani Panic di Worker #
Satu worker yang panic tidak boleh mematikan seluruh pool:
// ANTI-PATTERN: worker tanpa panic recovery
func naiveWorker(jobs <-chan Job, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
processJob(job) // jika ini panic, goroutine mati dan wg.Done() tidak dipanggil
}
}
// BENAR: worker dengan panic recovery
func safeWorker(id int, jobs <-chan Job, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("Worker %d: recovered from panic: %v\nStack: %s",
id, r, debug.Stack())
// Restart worker jika diperlukan
}
}()
for job := range jobs {
processJob(job)
}
}
4. Jangan Kirim ke Channel yang Sudah Ditutup #
// ANTI-PATTERN: menutup channel dari sisi producer saat ada multiple producer
go func() { // Producer 1
jobs <- Job{ID: 1}
close(jobs) // Producer 2 mungkin masih mau kirim!
}()
go func() { // Producer 2
jobs <- Job{ID: 2} // PANIC: send on closed channel
}()
// BENAR: gunakan sync.Once atau WaitGroup untuk koordinasi multiple producer
var once sync.Once
closeJobs := func() { once.Do(func() { close(jobs) }) }
// atau gunakan sync.WaitGroup untuk menunggu semua producer selesai
var producerWg sync.WaitGroup
go func() {
producerWg.Wait()
close(jobs)
}()
5. Implementasikan Backpressure #
Jika producer jauh lebih cepat dari consumer, kamu butuh mekanisme backpressure:
// ANTI-PATTERN: producer terus kirim tanpa peduli queue penuh
for _, item := range bigDataset {
jobs <- Job{Data: item} // block tak terbatas jika queue penuh
}
// BENAR: producer dengan timeout dan backpressure
for _, item := range bigDataset {
select {
case jobs <- Job{Data: item}:
// berhasil dikirim
case <-time.After(5 * time.Second):
// queue masih penuh setelah 5 detik — log warning
log.Println("Backpressure: job queue penuh selama 5 detik")
metrics.Increment("worker_pool.backpressure")
case <-ctx.Done():
return
}
}
6. Observability Adalah Keharusan #
Worker Pool yang bermasalah sering menjadi silent bottleneck — sistem berjalan, tapi lambat secara misterius:
type PoolMetrics struct {
JobsEnqueued atomic.Int64
JobsProcessed atomic.Int64
JobsFailed atomic.Int64
QueueLength func() int
}
func instrumentedWorker(
id int,
jobs <-chan Job,
results chan<- Result,
metrics *PoolMetrics,
wg *sync.WaitGroup,
) {
defer wg.Done()
for job := range jobs {
start := time.Now()
result := processJob(job)
duration := time.Since(start)
metrics.JobsProcessed.Add(1)
if result.Err != nil {
metrics.JobsFailed.Add(1)
}
// Kirim ke observability system (Prometheus, Datadog, dll)
histogram.Observe("worker_job_duration_seconds", duration.Seconds(),
map[string]string{"worker_id": fmt.Sprint(id)})
results <- result
}
}
Metrik yang wajib dipantau:
| Metrik | Cara Pantau |
|---|---|
| Panjang queue | len(jobsChan) secara periodik |
| Throughput (job/detik) | Counter dengan rate |
| Latency per job | Histogram durasi |
| Error rate | Counter error / total |
| Worker utilization | Rasio waktu aktif vs idle |
Anti-Pattern yang Harus Dihindari #
Berikut anti-pattern paling umum yang sering muncul saat mengimplementasikan Worker Pool:
// ✗ Tidak menutup job channel — worker hang selamanya
func badMain() {
jobs := make(chan Job)
var wg sync.WaitGroup
wg.Add(1)
go worker(jobs, &wg)
jobs <- Job{ID: 1}
// lupa: close(jobs) -- worker akan menunggu job baru selamanya
wg.Wait() // deadlock!
}
// ✓ Selalu close(jobs) setelah semua job dikirim
// ✗ WaitGroup tidak sinkron dengan jumlah worker
wg.Add(1) // hanya tambah 1
for i := 0; i < 5; i++ {
go worker(jobs, &wg) // tapi ada 5 goroutine yang panggil wg.Done()
}
// ✓ wg.Add(workerCount) sebelum loop, atau wg.Add(1) di dalam loop sebelum go
// ✗ Membaca dari results setelah results ditutup dua kali
close(results)
close(results) // PANIC: close of closed channel
// ✓ Gunakan sync.Once untuk menutup channel
// ✗ Worker yang tidak context-aware — tidak bisa dihentikan
func badWorker(jobs <-chan Job) {
for job := range jobs {
http.Get("https://api.example.com/process") // bisa block lama tanpa timeout
}
}
// ✓ Selalu gunakan context dengan timeout untuk operasi eksternal
// ✗ Shared mutable state tanpa proteksi di antara worker
var counter int
func worker(jobs <-chan Job) {
for range jobs {
counter++ // race condition!
}
}
// ✓ Gunakan atomic, mutex, atau kirim hasil lewat channel
var counter atomic.Int64
counter.Add(1)
Checklist Implementasi Worker Pool #
SETUP:
□ Jumlah worker ditentukan berdasarkan jenis task (CPU/IO bound)
□ Buffer channel dipilih berdasarkan throughput yang diharapkan
□ WaitGroup.Add() dipanggil sebelum goroutine dimulai
SHUTDOWN:
□ close(jobs) dipanggil setelah semua job dikirim
□ Hanya satu goroutine yang menutup channel (cegah double close)
□ context.Cancel() diintegrasikan dengan sinyal OS (SIGTERM/SIGINT)
□ wg.Wait() dipanggil sebelum menutup results channel
SAFETY:
□ recover() ada di setiap worker goroutine
□ Semua operasi eksternal menggunakan context dengan timeout
□ Shared state dilindungi atomic atau mutex (atau dihindari sama sekali)
□ Producer menggunakan select dengan ctx.Done() untuk mencegah block abadi
OBSERVABILITY:
□ Queue length dipantau secara periodik
□ Throughput dan latency diukur per job
□ Error rate dicatat dan di-alert
□ Graceful shutdown di-log dengan jelas
Ringkasan #
- Worker Pool = kontrol concurrency — jumlah worker yang terbatas melindungi resource sistem dari lonjakan tak terkendali.
- Tiga komponen inti: Job Queue (channel), Worker (goroutine), dan Dispatcher (producer yang mengirim job).
- Paling efektif untuk I/O bound tasks — HTTP calls, DB queries, message queue consumer — di mana worker sering menunggu dan bisa melayani job lain.
- Buffered channel untuk job queue membantu menyerap burst singkat, tapi buffer terlalu besar bisa menyembunyikan bottleneck.
- Selalu implement graceful shutdown dengan
context.Contextdan tangkap sinyal OS (SIGTERM,SIGINT) agar worker berhenti dengan rapi.- Panic recovery wajib ada di setiap worker — satu panic tidak boleh mematikan seluruh pool.
- Backpressure bukan opsional — gunakan
selectdengan timeout di sisi producer agar queue yang penuh tidak menyebabkan block tak terbatas.- Worker Pool vs Thread Pool — keduanya menerapkan konsep yang sama, tapi di Go implementasinya menggunakan goroutine (jauh lebih ringan dari OS thread) dan channel (lebih aman dari shared memory).
- Observability adalah kewajiban — pantau panjang queue, throughput, latency, dan error rate; tanpa metrik, Worker Pool bisa menjadi silent bottleneck.
- Variasi lanjutan — Dynamic Pool, Rate-Limited Pool, Pipeline Pool, dan Retry + Dead Letter Queue — semuanya dibangun di atas fondasi yang sama.