Thread Pool Pattern #
Sebuah web server menerima ribuan request per detik. Tanpa pengendalian, setiap request akan membuat goroutine baru — dan di saat traffic tinggi, bisa ada ratusan ribu goroutine aktif sekaligus. Meskipun goroutine jauh lebih ringan dari thread OS, ratusan ribu goroutine tetap menghabiskan memori secara signifikan, menyebabkan GC pressure yang tinggi, dan bisa membuat sistem crash. Thread Pool Pattern menyelesaikan ini dengan cara yang elegan: buat sejumlah goroutine yang tetap (pool), antrekan semua pekerjaan yang masuk, dan biarkan goroutine yang ada mengambil dan mengerjakan antrian itu — tidak ada goroutine baru yang dibuat, tidak ada goroutine yang dibiarkan menganggur tanpa batas.
Apa itu Thread Pool Pattern? #
Thread Pool Pattern adalah concurrency pattern yang menyediakan sekumpulan goroutine yang sudah dibuat sebelumnya (pre-spawned) untuk mengeksekusi task yang masuk, alih-alih membuat goroutine baru untuk setiap task. Pool ini dibatasi jumlahnya sehingga konsumsi resource tetap terprediksi dan terkontrol.
Di Golang, “Thread Pool” lebih tepat disebut “Goroutine Pool” karena unit konkurensinya adalah goroutine, bukan thread OS. Namun konsep dan manfaatnya identik.
Tiga manfaat utama Thread Pool:
- Resource yang terkontrol — jumlah goroutine aktif dibatasi; tidak ada goroutine explosion saat traffic tinggi
- Pengurangan overhead — membuat dan menghancurkan goroutine berulang kali lebih mahal daripada menggunakannya kembali
- Backpressure alami — ketika pool penuh dan antrian penuh, task baru ditolak dengan error yang bisa ditangani
flowchart LR
subgraph "Tanpa Pool — Tak Terbatas"
T1[Task 1] --> G1[goroutine baru]
T2[Task 2] --> G2[goroutine baru]
T3[Task 3] --> G3[goroutine baru]
TN[Task N] --> GN[goroutine ke-N]
note1["N task = N goroutine\nbisa ratusan ribu!"]
end
subgraph "Dengan Pool — Terkontrol"
Q[Task Queue\nbuffered channel]
T4[Task 1] --> Q
T5[Task 2] --> Q
T6[Task N] --> Q
Q --> W1[Worker 1\ngoroutine tetap]
Q --> W2[Worker 2\ngoroutine tetap]
Q --> W3[Worker 3\ngoroutine tetap]
note2["N task, hanya 3 goroutine\nterlepas dari N"]
end
Komponen Thread Pool di Golang #
Thread Pool di Golang dibangun dari tiga primitif yang bekerja bersama.
flowchart TD
subgraph "Thread Pool"
direction TB
TQ["Task Queue\nbuffered channel Task"]
W1["Worker Goroutine 1\nfor task := range taskQueue"]
W2["Worker Goroutine 2\nfor task := range taskQueue"]
W3["Worker Goroutine N\nfor task := range taskQueue"]
CTX["context.Context\nuntuk shutdown signal"]
WG["sync.WaitGroup\ntracking worker selesai"]
end
TQ --> W1 & W2 & W3
CTX -->|cancel| W1 & W2 & W3
W1 & W2 & W3 -->|Done| WG
| Komponen | Peran | Implementasi |
|---|---|---|
| Task Queue | Buffer antrian task yang menunggu | Buffered channel chan Task |
| Worker Goroutines | Goroutine yang mengambil dan mengeksekusi task | go func() yang loop for range |
| Context | Signal untuk graceful shutdown | context.WithCancel |
| WaitGroup | Tracking semua worker sudah selesai | sync.WaitGroup |
Implementasi Lengkap: General-Purpose Thread Pool #
Task Interface dan Pool #
package pool
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"sync/atomic"
"time"
)
// Task merepresentasikan pekerjaan yang bisa dieksekusi oleh pool.
type Task interface {
Execute(ctx context.Context) error
TaskID() string
}
// TaskResult menyimpan hasil eksekusi sebuah task.
type TaskResult struct {
TaskID string
Error error
Duration time.Duration
WorkerID int
}
// PoolConfig menyimpan konfigurasi thread pool.
type PoolConfig struct {
Workers int // jumlah goroutine worker
QueueSize int // kapasitas antrian task
TaskTimeout time.Duration // timeout per task (0 = tidak ada timeout)
OnError func(result TaskResult) // callback saat task gagal
Logger *slog.Logger
}
// DefaultPoolConfig mengembalikan konfigurasi default yang masuk akal.
func DefaultPoolConfig() PoolConfig {
return PoolConfig{
Workers: 10,
QueueSize: 100,
TaskTimeout: 30 * time.Second,
Logger: slog.Default(),
}
}
// Metrics menyimpan statistik pool yang bisa dimonitor.
type Metrics struct {
TasksSubmitted atomic.Int64
TasksCompleted atomic.Int64
TasksFailed atomic.Int64
TasksRejected atomic.Int64
ActiveWorkers atomic.Int32
}
// Pool adalah Thread Pool yang thread-safe.
type Pool struct {
config PoolConfig
tasks chan Task
results chan TaskResult
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
metrics Metrics
started bool
mu sync.Mutex
}
// NewPool membuat pool baru dengan konfigurasi yang diberikan.
func NewPool(config PoolConfig) *Pool {
if config.Workers <= 0 {
config.Workers = 1
}
if config.QueueSize <= 0 {
config.QueueSize = config.Workers * 10
}
ctx, cancel := context.WithCancel(context.Background())
return &Pool{
config: config,
tasks: make(chan Task, config.QueueSize),
results: make(chan TaskResult, config.QueueSize),
ctx: ctx,
cancel: cancel,
}
}
// Start memulai semua worker goroutine.
func (p *Pool) Start() {
p.mu.Lock()
defer p.mu.Unlock()
if p.started {
return
}
p.started = true
for i := 0; i < p.config.Workers; i++ {
workerID := i + 1
p.wg.Add(1)
go p.runWorker(workerID)
}
p.config.Logger.Info("thread pool started",
"workers", p.config.Workers,
"queue_size", p.config.QueueSize,
)
}
// Submit menambahkan task ke antrian pool.
// Mengembalikan error jika pool sudah shutdown atau antrian penuh.
func (p *Pool) Submit(task Task) error {
select {
case <-p.ctx.Done():
return errors.New("pool is shutting down")
default:
}
select {
case p.tasks <- task:
p.metrics.TasksSubmitted.Add(1)
return nil
default:
p.metrics.TasksRejected.Add(1)
return fmt.Errorf("task queue is full (capacity: %d)", p.config.QueueSize)
}
}
// SubmitWait menambahkan task dan menunggu hingga ada slot tersedia.
// Mengembalikan error jika pool shutdown sebelum task bisa masuk.
func (p *Pool) SubmitWait(task Task) error {
select {
case <-p.ctx.Done():
return errors.New("pool is shutting down")
case p.tasks <- task:
p.metrics.TasksSubmitted.Add(1)
return nil
}
}
// Results mengembalikan channel untuk membaca hasil task.
func (p *Pool) Results() <-chan TaskResult {
return p.results
}
// Stop menghentikan pool secara graceful.
// Menunggu semua worker selesai memproses task yang sedang dikerjakan.
func (p *Pool) Stop() {
p.cancel() // signal semua worker untuk berhenti
close(p.tasks) // tutup channel — worker akan drain sisa task lalu berhenti
p.wg.Wait() // tunggu semua worker selesai
close(p.results) // tutup results channel setelah semua worker berhenti
p.config.Logger.Info("thread pool stopped",
"submitted", p.metrics.TasksSubmitted.Load(),
"completed", p.metrics.TasksCompleted.Load(),
"failed", p.metrics.TasksFailed.Load(),
"rejected", p.metrics.TasksRejected.Load(),
)
}
// Metrics mengembalikan snapshot statistik pool saat ini.
func (p *Pool) GetMetrics() map[string]int64 {
return map[string]int64{
"submitted": p.metrics.TasksSubmitted.Load(),
"completed": p.metrics.TasksCompleted.Load(),
"failed": p.metrics.TasksFailed.Load(),
"rejected": p.metrics.TasksRejected.Load(),
"active_workers": int64(p.metrics.ActiveWorkers.Load()),
"queue_length": int64(len(p.tasks)),
}
}
// runWorker adalah goroutine yang terus mengambil dan mengeksekusi task.
func (p *Pool) runWorker(workerID int) {
defer p.wg.Done()
defer p.metrics.ActiveWorkers.Add(-1)
p.metrics.ActiveWorkers.Add(1)
p.config.Logger.Debug("worker started", "worker_id", workerID)
for task := range p.tasks {
p.executeTask(workerID, task)
}
p.config.Logger.Debug("worker stopped", "worker_id", workerID)
}
// executeTask mengeksekusi satu task dengan timeout dan panic recovery.
func (p *Pool) executeTask(workerID int, task Task) {
start := time.Now()
// Buat context dengan timeout jika dikonfigurasi
ctx := p.ctx
var cancel context.CancelFunc
if p.config.TaskTimeout > 0 {
ctx, cancel = context.WithTimeout(p.ctx, p.config.TaskTimeout)
defer cancel()
}
// Eksekusi task dengan panic recovery
var execErr error
func() {
defer func() {
if r := recover(); r != nil {
execErr = fmt.Errorf("task panicked: %v", r)
}
}()
execErr = task.Execute(ctx)
}()
result := TaskResult{
TaskID: task.TaskID(),
Error: execErr,
Duration: time.Since(start),
WorkerID: workerID,
}
if execErr != nil {
p.metrics.TasksFailed.Add(1)
if p.config.OnError != nil {
p.config.OnError(result)
}
} else {
p.metrics.TasksCompleted.Add(1)
}
// Kirim result ke channel (non-blocking — jangan block worker)
select {
case p.results <- result:
default:
// Results buffer penuh — log tapi jangan block worker
p.config.Logger.Warn("results buffer full, dropping result",
"task_id", task.TaskID())
}
}
Concrete Tasks #
package pool
import (
"context"
"fmt"
"time"
)
// HTTPFetchTask mengambil URL dan menyimpan hasilnya.
type HTTPFetchTask struct {
id string
url string
client *http.Client
result *[]byte
}
func NewHTTPFetchTask(id, url string, client *http.Client, result *[]byte) Task {
return &HTTPFetchTask{id: id, url: url, client: client, result: result}
}
func (t *HTTPFetchTask) TaskID() string { return t.id }
func (t *HTTPFetchTask) Execute(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.url, nil)
if err != nil {
return fmt.Errorf("failed to build request: %w", err)
}
resp, err := t.client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read body failed: %w", err)
}
*t.result = body
return nil
}
// EmailTask mengirim satu email.
type EmailTask struct {
id string
recipient string
subject string
body string
mailer EmailSender
}
func NewEmailTask(id, recipient, subject, body string, mailer EmailSender) Task {
return &EmailTask{
id: id, recipient: recipient,
subject: subject, body: body, mailer: mailer,
}
}
func (t *EmailTask) TaskID() string { return t.id }
func (t *EmailTask) Execute(ctx context.Context) error {
return t.mailer.Send(ctx, t.recipient, t.subject, t.body)
}
// FuncTask memungkinkan penggunaan closure sebagai task — sangat fleksibel.
type FuncTask struct {
id string
fn func(ctx context.Context) error
}
func NewFuncTask(id string, fn func(ctx context.Context) error) Task {
return &FuncTask{id: id, fn: fn}
}
func (t *FuncTask) TaskID() string { return t.id }
func (t *FuncTask) Execute(ctx context.Context) error {
return t.fn(ctx)
}
Demonstrasi: Batch URL Fetching #
func fetchURLsConcurrently(urls []string) map[string][]byte {
config := pool.PoolConfig{
Workers: 5,
QueueSize: len(urls),
TaskTimeout: 10 * time.Second,
OnError: func(result pool.TaskResult) {
log.Printf("Task %s failed after %v: %v",
result.TaskID, result.Duration, result.Error)
},
}
p := pool.NewPool(config)
p.Start()
defer p.Stop()
results := make(map[string][]byte, len(urls))
resultBytes := make([][]byte, len(urls))
var mu sync.Mutex
// Submit semua task
for i, url := range urls {
idx := i
urlCopy := url
task := pool.NewFuncTask(fmt.Sprintf("fetch-%d", idx), func(ctx context.Context) error {
// ... fetch logic
mu.Lock()
results[urlCopy] = resultBytes[idx]
mu.Unlock()
return nil
})
if err := p.Submit(task); err != nil {
log.Printf("Cannot submit task for %s: %v", url, err)
}
}
// Baca hasil
completed := 0
for result := range p.Results() {
completed++
if completed >= len(urls) {
break
}
}
return results
}
Graceful Shutdown: Dua Strategi #
Graceful shutdown adalah aspek paling kritis dari Thread Pool. Ada dua pendekatan yang berbeda tergantung kebutuhan.
sequenceDiagram
participant C as Client
participant P as Pool
participant W as Workers
Note over C,W: Strategi 1: Drain then Stop
C->>P: Stop()
P->>P: cancel() — signal berhenti terima task baru
P->>P: close(tasks) — tutup channel task
W->>W: drain sisa task dalam channel
W->>W: selesai → exit
P->>C: wg.Wait() selesai
Note over C,W: Strategi 2: Hard Stop (abandon sisa task)
C->>P: ForceStop()
P->>P: cancel() — context cancelled
W->>W: ctx.Done() → exit segera
Note over W: Sisa task dalam queue diabaikan
// GracefulPool mendukung dua mode shutdown.
type GracefulPool struct {
Pool
}
// Stop menyelesaikan semua task yang sudah ada dalam queue sebelum berhenti.
// Tidak menerima task baru setelah Stop dipanggil.
func (p *GracefulPool) Stop() {
p.cancel() // stop menerima task baru
close(p.tasks) // signal worker untuk drain dan exit
p.wg.Wait() // tunggu semua selesai
}
// ForceStop menghentikan semua worker segera, mengabaikan sisa task dalam queue.
func (p *GracefulPool) ForceStop() {
p.cancel() // cancel context — worker akan exit saat cek ctx.Done()
// tidak menutup tasks channel — worker keluar dari ctx.Done() bukan dari range
p.wg.Wait()
}
// Contoh worker yang mendukung kedua mode:
func (p *Pool) runWorkerWithCancel(workerID int) {
defer p.wg.Done()
for {
select {
case task, ok := <-p.tasks:
if !ok {
// Channel ditutup — graceful shutdown: semua task sudah di-drain
return
}
p.executeTask(workerID, task)
case <-p.ctx.Done():
// Context cancelled — force shutdown: drain sisa tanpa execute
return
}
}
}
Dynamic Pool Sizing #
Untuk beban kerja yang tidak merata, ukuran pool bisa disesuaikan secara dinamis.
// DynamicPool menyesuaikan jumlah worker berdasarkan panjang antrian.
type DynamicPool struct {
Pool
minWorkers int
maxWorkers int
scaleUpAt int // scale up jika queue lebih dari N%
mu sync.Mutex
workerCount int
}
func NewDynamicPool(min, max int, queueSize int) *DynamicPool {
p := &DynamicPool{
minWorkers: min,
maxWorkers: max,
scaleUpAt: 70, // scale up jika antrian > 70% kapasitas
}
p.config.Workers = min
p.config.QueueSize = queueSize
return p
}
// AutoScale memeriksa kondisi pool dan menambah/mengurangi worker.
// Dipanggil secara periodik oleh background goroutine.
func (p *DynamicPool) AutoScale() {
queueUsage := float64(len(p.tasks)) / float64(cap(p.tasks)) * 100
p.mu.Lock()
defer p.mu.Unlock()
if queueUsage > float64(p.scaleUpAt) && p.workerCount < p.maxWorkers {
// Scale up: tambah satu worker
p.workerCount++
p.wg.Add(1)
go p.runWorker(p.workerCount)
p.config.Logger.Info("scaled up", "workers", p.workerCount, "queue_usage", queueUsage)
}
// Scale down: lebih kompleks, butuh sinyal ke worker tertentu untuk exit
}
// StartAutoScaler menjalankan background goroutine yang mengecek load secara periodik.
func (p *DynamicPool) StartAutoScaler(interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
p.AutoScale()
case <-p.ctx.Done():
return
}
}
}()
}
Monitoring Pool dengan Prometheus #
Untuk production, pool metrics perlu diekspor ke monitoring system.
// PoolMonitor mengekspos metrics pool sebagai Prometheus gauge.
type PoolMonitor struct {
pool *Pool
queueGauge prometheus.Gauge
workersGauge prometheus.Gauge
completedCount prometheus.Counter
failedCount prometheus.Counter
}
func NewPoolMonitor(pool *Pool, namespace string) *PoolMonitor {
return &PoolMonitor{
pool: pool,
queueGauge: prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: namespace,
Name: "thread_pool_queue_length",
Help: "Current number of tasks waiting in queue",
}),
workersGauge: prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: namespace,
Name: "thread_pool_active_workers",
Help: "Current number of active workers",
}),
}
}
// Collect dipanggil oleh Prometheus scraper untuk mengambil metrics saat ini.
func (m *PoolMonitor) Collect() {
metrics := m.pool.GetMetrics()
m.queueGauge.Set(float64(metrics["queue_length"]))
m.workersGauge.Set(float64(metrics["active_workers"]))
}
Testing Thread Pool #
func TestPool_ExecutesAllTasks(t *testing.T) {
config := pool.DefaultPoolConfig()
config.Workers = 3
config.QueueSize = 20
p := pool.NewPool(config)
p.Start()
defer p.Stop()
const taskCount = 10
var completed atomic.Int32
for i := 0; i < taskCount; i++ {
taskID := fmt.Sprintf("task-%d", i)
task := pool.NewFuncTask(taskID, func(ctx context.Context) error {
completed.Add(1)
return nil
})
if err := p.Submit(task); err != nil {
t.Fatalf("submit failed: %v", err)
}
}
// Kumpulkan semua hasil
received := 0
for result := range p.Results() {
if result.Error != nil {
t.Errorf("task %s failed: %v", result.TaskID, result.Error)
}
received++
if received >= taskCount {
break
}
}
if int(completed.Load()) != taskCount {
t.Errorf("expected %d tasks completed, got %d", taskCount, completed.Load())
}
}
func TestPool_RejectsWhenFull(t *testing.T) {
config := pool.PoolConfig{
Workers: 1,
QueueSize: 2, // sangat kecil untuk memudahkan testing
}
p := pool.NewPool(config)
p.Start()
defer p.Stop()
// Isi antrian dengan task yang lambat
slowTask := pool.NewFuncTask("slow", func(ctx context.Context) error {
time.Sleep(1 * time.Second)
return nil
})
for i := 0; i < 5; i++ {
_ = p.Submit(slowTask)
}
// Task berikutnya harus ditolak
err := p.Submit(slowTask)
if err == nil {
t.Error("expected error when queue is full")
}
}
func TestPool_PanicRecovery(t *testing.T) {
config := pool.DefaultPoolConfig()
config.Workers = 2
p := pool.NewPool(config)
p.Start()
defer p.Stop()
// Task yang panic
panicTask := pool.NewFuncTask("panic-task", func(ctx context.Context) error {
panic("intentional panic for testing")
})
normalTask := pool.NewFuncTask("normal-task", func(ctx context.Context) error {
return nil
})
_ = p.Submit(panicTask)
_ = p.Submit(normalTask)
var panicResult, normalResult pool.TaskResult
received := 0
for result := range p.Results() {
if result.TaskID == "panic-task" {
panicResult = result
} else {
normalResult = result
}
received++
if received >= 2 {
break
}
}
// Panic task harus menghasilkan error, bukan crash pool
if panicResult.Error == nil {
t.Error("expected error from panicking task")
}
// Task normal harus tetap berjalan setelah ada panic
if normalResult.Error != nil {
t.Errorf("normal task should succeed: %v", normalResult.Error)
}
}
func TestPool_GracefulShutdown(t *testing.T) {
config := pool.PoolConfig{Workers: 2, QueueSize: 10}
p := pool.NewPool(config)
p.Start()
var completed atomic.Int32
for i := 0; i < 5; i++ {
_ = p.Submit(pool.NewFuncTask(fmt.Sprintf("task-%d", i),
func(ctx context.Context) error {
time.Sleep(10 * time.Millisecond)
completed.Add(1)
return nil
}))
}
// Stop harus menunggu semua task selesai
p.Stop()
if completed.Load() != 5 {
t.Errorf("graceful shutdown should complete all queued tasks, got %d/5",
completed.Load())
}
}
Thread Pool vs Worker Pool #
Dua pattern yang sering dianggap sama padahal ada perbedaan konseptual.
| Aspek | Thread Pool | Worker Pool |
|---|---|---|
| Fokus | Mengelola lifecycle goroutine (create, reuse, destroy) | Mendistribusikan task ke worker yang spesifik |
| Task routing | Task dipilih worker mana saja yang tersedia | Task bisa diarahkan ke worker tertentu |
| Worker identity | Worker anonim dan interchaneable | Worker bisa punya state sendiri |
| Use case | HTTP request handling, batch processing | Worker dengan koneksi DB sendiri, shard-based processing |
| Kompleksitas | Lebih sederhana | Lebih fleksibel tapi lebih kompleks |
Kapan Menggunakan dan Kapan Tidak #
GUNAKAN Thread Pool jika:
✓ Ada banyak task independen yang perlu dieksekusi secara concurrent
✓ Perlu membatasi jumlah goroutine aktif untuk mengendalikan resource
✓ Task datang lebih cepat dari kemampuan satu goroutine memprosesnya
✓ Perlu backpressure — tolak task baru ketika sistem overloaded
✓ Task bersifat short-lived dan homogen
HINDARI Thread Pool jika:
✗ Hanya ada sedikit task yang jarang — goroutine biasa sudah cukup
✗ Task perlu mengakses state tertentu yang hanya dimiliki satu worker
✗ Task memerlukan ordering yang dijamin — gunakan single goroutine + channel
✗ Semua task berjalan sangat lama — pool akan cepat habis; better pakai Worker Pool
Checklist Review Thread Pool #
KONFIGURASI:
□ Jumlah worker ditentukan berdasarkan profiling, bukan tebakan
□ Ukuran antrian cukup besar untuk menyerap burst tapi tidak tak terbatas
□ Task timeout dikonfigurasi untuk mencegah worker terjebak forever
□ OnError callback dikonfigurasi untuk logging atau alerting
SHUTDOWN:
□ Stop() dipanggil — tidak ada goroutine yang bocor
□ Defer p.Stop() digunakan di main atau test untuk memastikan cleanup
□ Graceful vs force shutdown dipilih sesuai kebutuhan
□ Results channel di-drain atau ditutup dengan benar
PANIC SAFETY:
□ Setiap task execution dibungkus dengan recover()
□ Panic di satu task tidak crash seluruh pool
□ Error dari panic di-wrap sebagai error dan dikembalikan sebagai TaskResult
MONITORING:
□ Queue length dimonitor — alert jika mendekati kapasitas
□ Active worker count dimonitor
□ Task failure rate dimonitor
Ringkasan #
- Thread Pool membatasi jumlah goroutine aktif — resource terkelola, tidak ada goroutine explosion saat traffic tinggi, dan GC pressure berkurang.
- Tiga komponen kunci: task queue (buffered channel), worker goroutines (loop
for range), dan context untuk shutdown signal.- Buffered channel adalah task queue — ukurannya menentukan seberapa banyak task bisa antri sebelum Submit mulai menolak; pilih dengan bijak.
- Panic recovery wajib — satu task yang panic tidak boleh crash worker; bungkus setiap Execute dengan
defer recover().- Graceful shutdown:
cancel()+close(tasks)+wg.Wait()— urutan ini memastikan semua task dalam antrian diproses sebelum worker berhenti.- Results channel memungkinkan non-blocking result collection — worker tidak perlu menunggu consumer; gunakan buffer yang cukup.
- Dynamic sizing untuk beban tidak merata — scale up ketika antrian > 70% kapasitas, scale down saat idle.
- Monitor queue length — jika antrian sering penuh, pool perlu lebih banyak worker atau task perlu dipercepat; jika selalu kosong, pool bisa dikecilkan.