Goroutine rẻ đến mức dễ lạm dụng: thấy 1000 job, viết for i { go lamViec(i) } — 1000 goroutine, xong. Nhưng ở quy mô thật, mẫu này là công thức sập: một triệu job tạo một triệu goroutine (mỗi cái ít nhất 2KB stack = 2GB), và không có gì kiểm soát tải khi producer nhanh hơn hệ thống chịu được. Giải pháp là worker pool có giới hạn với backpressure. Bài này xây và đo thật hai thuộc tính giúp hệ thống chậm lại thay vì sập.
Vấn đề: 1 goroutine mỗi job
for i := 0; i < 1000000; i++ {
go lamViec(i) // 1 TRIỆU goroutine cùng lúc!
}
Hai vấn đề: (1) một triệu goroutine × 2KB stack = 2GB bộ nhớ chỉ cho stack, có thể OOM; (2) không kiểm soát tải — nếu lamViec gọi DB, một triệu goroutine đè lên DB cùng lúc. Số goroutine bùng nổ theo số job, không có trần.
Worker pool: số worker cố định
jobs := make(chan int, QUEUE) // channel đệm = hàng đợi GIỚI HẠN
// đúng WORKERS goroutine, mỗi cái xử lý job từ channel
for w := 0; w < WORKERS; w++ {
go func() {
for job := range jobs { lamViec(job) }
}()
}
Thay vì một goroutine mỗi job, ta có đúng WORKERS goroutine, mỗi cái lặp lấy job từ channel. Số goroutine cố định bất kể có bao nhiêu job — 100 hay một triệu job đều chỉ WORKERS goroutine.

Hình 1: Worker pool giới hạn. Cách ngây thơ (1 goroutine/job) vs pool (WORKERS cố định); backpressure khi hàng đợi giới hạn đầy chặn producer; hai thuộc tính bảo vệ hệ thống.
Backpressure: hàng đợi đầy chặn producer
Đây là phần tinh tế và quan trọng nhất. Channel jobs có đệm giới hạn (QUEUE). Khi producer đẩy job nhanh hơn worker xử lý, hàng đợi đầy — và jobs <- i sẽ block:
for i := 0; i < JOBS; i++ {
jobs <- i // khi hàng đợi đầy → gửi BLOCK
// → producer bị chặn tới khi worker lấy bớt
// → producer TỰ chậm lại theo tốc độ worker
}
Backpressure là cơ chế này: khi hệ thống quá tải, producer bị kéo về đúng tốc độ hệ thống chịu được, thay vì chất đống công việc vô hạn trong bộ nhớ. Đây là điểm khác biệt giữa hàng đợi giới hạn (an toàn) và hàng đợi vô hạn (nguy hiểm — đầy bộ nhớ rồi sập).
Đo thật: goroutine không bùng nổ, backpressure hoạt động
Chạy 100 job (mỗi job 5ms) qua pool (4 worker, hàng đợi 8) và so với cách ngây thơ:

Hình 2: Goroutine đỉnh — pool 6 vs ngây thơ 102. Backpressure — producer bị chặn 89 lần, 100 job xong 135ms (≈ 100/4×5ms). Hai thuộc tính bảo vệ hệ thống.
- Worker pool (4 worker, hàng đợi 8): goroutine đỉnh 6 (4 worker + main + monitor), producer bị chặn 89 lần.
- Ngây thơ (1 goroutine/job): goroutine đỉnh 102 — bùng nổ theo số job.
Pool giữ goroutine ~6 bất kể 100 job; cách ngây thơ bùng lên 102. Với một triệu job, ngây thơ tạo một triệu goroutine (~2GB stack) → OOM; pool vẫn 6. Và producer bị chặn 89 lần = 89 lần hàng đợi đầy khiến nó phải chờ — chính là backpressure hoạt động, giữ tải trong tầm kiểm soát. 100 job xong trong 135ms (≈ 100/4 × 5ms = 125ms lý thuyết cho song song có giới hạn).
Ứng dụng thực tế
Xử lý hàng loạt / ETL. Đọc một triệu dòng từ file/DB và xử lý mỗi dòng — đừng go mỗi dòng. Dùng pool worker để giới hạn song song, và channel giới hạn để producer (reader) tự chậm theo tốc độ processor. Bộ nhớ ổn định bất kể kích thước input.
Server nhận request bùng nổ. Nếu mỗi request khởi một goroutine gọi DB, một đợt traffic tạo hàng nghìn goroutine đè DB. Đặt một pool (hoặc semaphore từ bài trước) trước phần nặng để giới hạn song song, và trả 503 khi hàng đợi đầy (thay vì block) nếu cần bảo vệ độ trễ.
Số worker theo tài nguyên, không theo job. Chọn WORKERS theo cái hạn chế thực sự: CPU cores cho việc CPU-bound, giới hạn kết nối DB cho việc DB-bound, không theo số job. Đây là điều chỉnh hiệu năng chính của pool.
Đánh đổi cần cân nhắc
Block producer là đúng, nhưng đôi khi cần drop hoặc trả lỗi. Backpressure bằng block phù hợp khi producer có thể chờ (ETL, batch). Nhưng với request người dùng, block quá lâu là trải nghiệm tệ — khi đó dùng select với default để drop job hoặc trả 503 ngay ("fail fast"). Chọn block hay drop tùy job có chờ được không.
Chọn kích thước hàng đợi cẩn thận. Hàng đợi lớn hấp thụ đợt dồn tốt hơn (ít block) nhưng tăng độ trễ (job chờ lâu trong hàng đợi) và dùng nhiều bộ nhớ hơn. Hàng đợi nhỏ cho backpressure sớm và độ trễ thấp nhưng block producer nhiều hơn. Không có số đúng tuyệt đối — cân theo đợt dồn dự kiến và độ trễ chấp nhận được.
Đừng tự viết pool khi thư viện chuẩn đủ. Với các trường hợp phổ biến, golang.org/x/sync/errgroup (có SetLimit, bài sau) hoặc một semaphore đơn giản đã đủ và ít lỗi hơn tự viết pool. Chỉ tự dựng khi cần kiểm soát chi tiết (hàng đợi ưu tiên, worker động). Mẫu pool ở bài này là để hiểu cơ chế.
Ba ý mang về
- Worker pool giới hạn goroutine bằng số worker cố định: đo thật pool giữ goroutine ở 6 trong khi cách ngây thơ 1-goroutine-mỗi-job bùng lên 102 — với một triệu job, ngây thơ tạo một triệu goroutine (~2GB stack) OOM, còn pool vẫn cố định; số goroutine không bùng nổ theo số job.
- Backpressure giữ tải trong tầm kiểm soát bằng hàng đợi giới hạn: đo thật producer bị chặn 89 lần khi hàng đợi đầy — nó tự chậm lại về tốc độ hệ thống chịu được thay vì chất đống công việc vô hạn vào bộ nhớ; đây là khác biệt giữa hàng đợi giới hạn (an toàn) và vô hạn (sập).
- Kết quả là hệ thống chậm lại thay vì sập dưới tải cao: hai thuộc tính (goroutine giới hạn + hàng đợi giới hạn) chống OOM; chọn
WORKERStheo tài nguyên hạn chế thực sự, và cân nhắc block vs drop/503 tùy job có chờ được không — dùngerrgroup.SetLimithoặc semaphore cho trường hợp phổ biến.
Phần sau ta xem chính công cụ thư viện chuẩn vừa nhắc để giới hạn goroutine gọn gàng: Phần sau mổ xẻ errgroup với SetLimit — cách chạy nhóm goroutine có giới hạn số đồng thời, gom lỗi và hủy sớm, gọn hơn tự dựng pool.