Worker pool Go membantu tim kecil menjalankan pekerjaan latar belakang dengan retry, pembatalan, dan shutdown bersih menggunakan pola sederhana sebelum menambah infrastruktur berat.

Di layanan Go kecil, pekerjaan latar belakang biasanya dimulai dengan tujuan sederhana: kembalikan respons HTTP dengan cepat, lalu lakukan pekerjaan lambat setelahnya. Itu bisa berupa mengirim email, meresize gambar, sinkron ke API lain, membangun ulang indeks pencarian, atau menjalankan laporan malam.
Masalahnya adalah pekerjaan ini adalah kerja produksi nyata, hanya saja tanpa pegangan yang biasanya Anda dapatkan saat menangani request. Sebuah goroutine yang dipicu dari handler HTTP terasa aman sampai ada deploy di tengah tugas, API upstream melambat, atau request yang sama coba ulang dan memicu pekerjaan dua kali.
Rasa sakit pertama bisa diprediksi:
Di sinilah pola kecil dan eksplisit seperti worker pool Go membantu. Ini membuat konkurensi menjadi pilihan (N worker), mengubah “lakukan nanti” menjadi tipe job yang jelas, dan memberi satu tempat untuk menangani retry, timeout, dan pembatalan.
Contoh: aplikasi SaaS perlu mengirim faktur. Anda tidak ingin 500 kiriman sekaligus setelah import batch, dan Anda tidak ingin mengirim ulang faktur yang sama karena request di-retry. Worker pool membiarkan Anda membatasi throughput dan memperlakukan “kirim faktur #123” sebagai unit kerja yang dilacak.
Worker pool bukan alat yang tepat saat Anda membutuhkan jaminan tahan-gangguan lintas-proses. Jika job harus bertahan setelah crash, dijadwalkan untuk waktu tertentu di masa depan, atau diproses oleh banyak layanan, kemungkinan Anda membutuhkan antrean nyata plus penyimpanan persisten untuk status job.
Worker pool Go sengaja membosankan: masukkan pekerjaan ke antrean, punya sejumlah pekerja tetap yang menarik dari situ, dan pastikan seluruh sistem bisa berhenti dengan bersih.
Istilah dasar:
Dalam banyak desain in-process, sebuah channel Go adalah antreannya. Channel berbufffer bisa menahan sejumlah job terbatas sebelum producer terblok. Pemblokiran itu adalah backpressure, dan seringkali itulah yang mencegah service Anda menerima pekerjaan tanpa batas dan kehabisan memori saat trafik melonjak.
Ukuran buffer mengubah nuansa sistem. Buffer kecil membuat tekanan terlihat cepat (pemanggil menunggu lebih awal). Buffer besar meratakan lonjakan singkat tapi bisa menyembunyikan overload sampai nanti. Tidak ada angka sempurna, hanya angka yang cocok dengan seberapa banyak penantian yang Anda toleransi.
Anda juga memilih apakah ukuran pool tetap atau bisa berubah. Pool tetap lebih mudah dipahami dan menjaga penggunaan sumber daya tetap dapat diprediksi. Auto-scaling worker bisa membantu saat beban tidak merata, tapi menambah keputusan yang harus Anda pelihara (kapan menskalakan, berapa banyak, dan kapan menurunkan).
Akhirnya, “ack” dalam pool in-process biasanya hanya berarti “worker menyelesaikan job dan tidak mengembalikan error.” Tidak ada broker eksternal untuk mengonfirmasi pengiriman, jadi kode Anda yang mendefinisikan apa arti “selesai” dan apa yang terjadi saat job gagal atau dibatalkan.
Worker pool sederhana secara mekanis: jalankan sejumlah worker tetap, beri mereka job, dan proses. Nilainya adalah kontrol: konkurensi yang dapat diprediksi, penanganan kegagalan yang jelas, dan jalur shutdown yang tidak meninggalkan pekerjaan setengah jadi.
Tiga tujuan yang membuat tim kecil tetap waras:
Sebagian besar kegagalan itu membosankan, tetapi Anda tetap ingin memperlakukan mereka berbeda:
Pembatalan bukan sama dengan “error.” Itu sebuah keputusan: pengguna membatalkan, deploy mengganti proses Anda, atau service sedang shutdown. Di Go, perlakukan pembatalan sebagai sinyal kelas-satu menggunakan pembatalan context, dan pastikan setiap job memeriksanya sebelum memulai pekerjaan mahal dan pada beberapa titik aman selama eksekusi.
Shutdown bersih adalah tempat banyak pool gagal. Tentukan sejak awal apa arti “aman” untuk job Anda: apakah Anda menyelesaikan pekerjaan yang sedang berjalan, atau berhenti cepat dan jalankan lagi nanti? Alur praktis adalah:
Jika Anda mendefinisikan aturan-aturan ini sejak awal, retry, pembatalan, dan shutdown tetap kecil dan dapat diprediksi alih-alih berubah menjadi framework buatan sendiri.
Worker pool hanyalah sekumpulan goroutine yang menarik job dari channel dan melakukan pekerjaan. Bagian penting adalah membuat dasar yang dapat diprediksi: seperti apa job, bagaimana worker berhenti, dan bagaimana Anda tahu ketika semua pekerjaan selesai.
Mulai dengan tipe Job sederhana. Beri ID (untuk log), payload (apa yang diproses), counter percobaan (berguna untuk retry), timestamp, dan tempat menyimpan data context per-job.
package jobs
import (
"context"
"sync"
"time"
)
type Job struct {
ID string
Payload any
Attempt int
Enqueued time.Time
Started time.Time
Ctx context.Context
Meta map[string]string
}
type Pool struct {
ctx context.Context
cancel context.CancelFunc
jobs chan Job
wg sync.WaitGroup
}
func New(size, queue int) *Pool {
ctx, cancel := context.WithCancel(context.Background())
p := \u00026Pool{ctx: ctx, cancel: cancel, jobs: make(chan Job, queue)}
for i := 0; i \u0003c size; i++ {
go p.worker(i)
}
return p
}
func (p *Pool) worker(_ int) {
for {
select {
case \u0003c-p.ctx.Done():
return
case job, ok := \u0003c-p.jobs:
if !ok {
return
}
p.wg.Add(1)
job.Started = time.Now()
_ = job // call your handler here
p.wg.Done()
}
}
}
// Submit blocks when the queue is full (backpressure).
func (p *Pool) Submit(job Job) error {
if job.Enqueued.IsZero() {
job.Enqueued = time.Now()
}
select {
case \u0003c-p.ctx.Done():
return context.Canceled
case p.jobs \u0003c- job:
return nil
}
}
func (p *Pool) Stop() { p.cancel() }
func (p *Pool) Wait() { p.wg.Wait() }
Beberapa pilihan praktis yang harus Anda buat segera:
Stop() dan Wait() terpisah supaya Anda bisa menghentikan intake dulu, lalu menunggu pekerjaan yang sedang berjalan selesai.Retry berguna, tapi juga tempat di mana worker pool menjadi berantakan. Persempit tujuan: retry hanya ketika percobaan ulang punya peluang nyata untuk berhasil, dan berhenti cepat saat tidak.
Mulai dengan memutuskan apa yang bisa di-retry. Masalah sementara (gangguan jaringan, timeout, respons "coba lagi nanti") biasanya layak dicoba ulang. Masalah permanen (input rusak, record hilang, izin ditolak) tidak.
Kebijakan retry kecil biasanya cukup:
Retryable(err)).Backoff tidak perlu rumit. Bentuk umum: delay = min(base * 2^(attempt-1), max), lalu tambahkan jitter (acak +/- 20%). Jitter penting karena kalau tidak, banyak worker gagal bersamaan lalu retry bersamaan.
Di mana delay ini berada? Untuk sistem kecil, tidur di dalam worker cukup, tapi itu mengikat slot worker. Jika retry jarang, itu bisa diterima. Jika retry sering atau delay panjang, pertimbangkan untuk meng-enqueue ulang job dengan timestamp “run after” sehingga worker tetap sibuk dengan pekerjaan lain.
Saat kegagalan akhir, bersikaplah eksplisit. Simpan job yang gagal (dan error terakhir) untuk ditinjau, log konteks yang cukup untuk replay, atau dorong ke daftar mati yang Anda periksa secara berkala. Hindari drop senyap. Pool yang menyembunyikan kegagalan lebih buruk daripada tidak punya retry.
Worker pool terasa aman ketika Anda bisa menghentikannya. Aturan paling sederhana: teruskan context.Context melalui setiap lapisan yang bisa memblok. Itu berarti submission, eksekusi, dan pembersihan.
Setup praktis menggunakan dua batas waktu:
Beri setiap job context sendiri yang diturunkan dari context worker. Lalu setiap panggilan lambat (database, HTTP, antrean, I/O file) harus menggunakan context itu agar bisa kembali lebih awal.
func worker(ctx context.Context, jobs \u0003c-chan Job) {
for {
select {
case \u0003c-ctx.Done():
return
case job, ok := \u0003c-jobs:
if !ok { return }
jobCtx, cancel := context.WithTimeout(ctx, job.Timeout)
_ = job.Run(jobCtx) // Run must respect jobCtx
cancel()
}
}
}
Jika Run memanggil DB atau API Anda, sambungkan context ke panggilan-panggilan itu (misalnya QueryContext, NewRequestWithContext, atau metode klien yang menerima context). Jika Anda mengabaikannya di satu tempat, pembatalan menjadi "best effort" dan biasanya gagal saat Anda paling membutuhkannya.
Pembatalan bisa terjadi di tengah job, jadi anggaplah pekerjaan parsial itu normal. Tujuannya adalah membuat langkah-langkah idempoten sehingga rerun tidak menghasilkan duplikasi. Pendekatan umum termasuk menggunakan kunci unik untuk insert (atau upsert), menulis penanda progres (started/done), menyimpan hasil sebelum melanjutkan, dan memeriksa ctx.Err() di antara langkah.
Perlakukan shutdown seperti deadline: berhenti menerima job baru, batalkan context worker, dan tunggu hanya sampai timeout shutdown untuk job yang sedang berjalan keluar.
Shutdown bersih punya satu tujuan: berhenti mengambil pekerjaan baru, beri tahu pekerjaan yang sedang berjalan untuk berhenti, dan keluar tanpa meninggalkan sistem dalam keadaan aneh.
Mulai dengan sinyal. Di sebagian besar deployment Anda akan melihat SIGINT secara lokal dan SIGTERM dari process manager atau container runtime. Gunakan context shutdown yang dibatalkan saat sinyal datang, dan teruskan ke pool serta handler job Anda.
Selanjutnya, berhenti menerima job baru. Jangan biarkan pemanggil terblok selamanya mencoba submit ke channel yang tak lagi dibaca. Taruh submission di balik satu fungsi yang memeriksa flag closed atau memilih pada context shutdown sebelum mengirim.
Lalu putuskan apa yang terjadi pada pekerjaan yang ada di antrean:
Draining lebih aman untuk hal seperti pembayaran dan email. Dropping cocok untuk tugas "bagus kalau ada" seperti recomputasi cache.
Urutan shutdown praktis:
Deadline penting. Misalnya, beri job in-flight 10 detik untuk berhenti. Setelah itu, log apa yang masih berjalan dan keluar. Itu menjaga deploy tetap dapat diprediksi dan menghindari proses yang macet.
Saat worker pool bermasalah, jarang gagal secara keras. Job melambat, retry menumpuk, dan seseorang melapor bahwa "tidak ada yang terjadi." Logging dan beberapa counter dasar mengubah itu menjadi cerita yang jelas.
Beri setiap job ID stabil (atau buat saat submit) dan sertakan di setiap baris log. Jaga konsistensi log: satu baris saat job mulai, satu saat selesai, dan satu saat gagal. Jika Anda retry, log nomor percobaan dan delay berikutnya.
Bentuk log sederhana:
Metrik bisa tetap minimal dan tetap berguna. Lacak panjang antrean, job in-flight, total sukses dan gagal, serta latensi job (setidaknya rata-rata dan maksimum). Jika panjang antrean terus naik dan in-flight tetap di angka worker count, Anda jenuh. Jika submitter terblok mengirim ke channel jobs, backpressure mencapai pemanggil. Itu tidak selalu buruk, tapi harus disengaja.
Saat "job macet", periksa apakah proses masih menerima job, apakah panjang antrean tumbuh, apakah worker hidup, dan job mana yang berjalan paling lama. Runtime panjang biasanya menunjukkan timeout yang hilang, dependensi lambat, atau loop retry yang tak berhenti.
Bayangkan SaaS kecil di mana order berubah menjadi PAID. Setelah pembayaran, Anda perlu mengirim PDF faktur, mengirim email ke pelanggan, dan memberi tahu tim internal. Anda tidak ingin pekerjaan itu memblok request web. Ini cocok untuk worker pool karena pekerjaan ini nyata, tapi sistem masih kecil.
Payload job bisa minimal: cukup untuk mengambil sisanya dari database. Handler API menulis baris seperti jobs(status='queued', type='send_invoice', payload, attempts=0) dalam transaksi yang sama dengan update order, lalu loop latar belakang polling job yang queued dan mendorongnya ke channel worker.
type SendInvoiceJob struct {
OrderID string
CustomerID string
Email string
}
Saat worker mengambilnya, jalur bahagia sederhana: muat order, buat invoice, panggil provider email, lalu tandai job selesai.
Retry adalah tempat ini menjadi nyata. Jika provider email Anda mengalami gangguan sementara, Anda tidak ingin 1.000 job gagal selamanya atau membombardir provider setiap detik. Pendekatan praktis:
Selama gangguan, job bergerak dari queued ke in_progress, lalu kembali ke queued dengan waktu run di masa depan. Saat provider pulih, worker akan menguras backlog itu.
Bayangkan deploy. Anda kirim SIGTERM. Proses harus berhenti mengambil pekerjaan baru tapi menyelesaikan yang sedang berjalan. Hentikan polling, hentikan mengisi channel worker, dan tunggu worker dengan deadline. Job yang selesai ditandai done. Job yang masih berjalan saat deadline harus dikembalikan ke queued (atau dibiarkan in_progress dengan watchdog) agar bisa diambil ulang setelah versi baru mulai.
Sebagian besar bug dalam pemrosesan latar belakang bukan pada logika job. Mereka muncul dari kesalahan koordinasi yang hanya terlihat saat beban tinggi atau saat shutdown.
Satu jebakan klasik adalah menutup channel dari lebih dari satu tempat. Hasilnya panic yang sulit direproduksi. Pilih satu pemilik untuk setiap channel (biasanya producer), dan jadikan itu satu-satunya tempat memanggil close(jobs).
Retry adalah area lain di mana niat baik menyebabkan outage. Jika Anda me-retry semuanya, Anda akan me-retry kegagalan permanen juga. Itu membuang-buang waktu, menambah beban, dan bisa mengubah masalah kecil menjadi insiden. Klasifikasikan error dan batasi retry dengan kebijakan yang jelas.
Duplikasi akan terjadi bahkan dengan desain hati-hati. Worker bisa crash di tengah job, timeout bisa terjadi setelah pekerjaan selesai, atau Anda bisa enqueue ulang saat deployment. Jika job tidak idempoten, duplikat bisa menyebabkan kerusakan nyata: dua faktur, dua email sambutan, dua refund.
Kesalahan yang paling sering muncul:
context.Context, sehingga pekerjaan terus berjalan setelah shutdown dimulai.Antrean tak terbatas sangat licik. Lonjakan pekerjaan bisa menumpuk diam-diam di RAM. Lebih baik gunakan buffer channel terbatas dan putuskan apa yang terjadi saat penuh: blok, drop, atau kembalikan error.
Sebelum Anda merilis worker pool ke produksi, Anda harus bisa menjelaskan siklus hidup job itu dengan jelas. Jika seseorang bertanya "di mana job ini sekarang?", jawabannya tidak boleh menebak.
Daftar periksa pra-terbang praktis:
workerCount), dan mengubahnya tidak perlu menulis ulang kode.Lakukan satu drill realistis sebelum rilis: enqueue 100 job "kirim email tanda terima", paksa 20 gagal, lalu restart service saat berjalan. Anda harus melihat retry berjalan sesuai harapan, tidak ada efek samping ganda, dan pembatalan benar-benar menghentikan pekerjaan saat deadline tercapai.
Jika ada item yang samar, perbaiki sekarang. Perbaikan kecil di sini menghemat hari kemudian.
Worker in-process sederhana sering cukup saat produk masih muda. Jika job Anda "bagus jika ada" (kirim email, refresh cache, buat laporan) dan Anda bisa menjalankannya ulang, worker pool membuat sistem mudah dipahami.
Perhatikan titik tekanan ini:
Jika tidak ada yang di atas benar, alat yang lebih berat bisa menambah lebih banyak bagian yang bergerak daripada manfaat.
Hedge terbaik adalah antarmuka job yang stabil: tipe payload kecil, ID, dan handler yang mengembalikan hasil yang jelas. Kemudian Anda bisa menukar backend antrean nanti (dari channel in-memory ke tabel database, lalu ke antrean khusus) tanpa mengubah kode bisnis.
Langkah tengah praktis adalah layanan Go kecil yang membaca job dari PostgreSQL, mengklaim dengan lock, dan memperbarui status. Anda dapat durability dan audit dasar sambil mempertahankan logika worker yang sama.
Jika ingin prototype cepat, Koder.ai (koder.ai) dapat menghasilkan starter Go + PostgreSQL dari prompt chat, termasuk tabel job latar belakang dan loop worker, serta snapshot dan rollback yang membantu saat Anda menyetel retry dan perilaku shutdown.