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.

Ảnh chụp đoạn mã Go nền tối minh hoạ worker pool giới hạn và backpressure chậm lại thay vì sập, số worker cố định cộng hàng đợi giới hạn khi quá tải producer bị chặn backpressure không phình goroutine không phình bộ nhớ, một vấn đề 1 goroutine mỗi job ngây thơ for i bằng 0 i nhỏ hơn 1000000 i go lamViec i 1 triệu goroutine cùng lúc 1 triệu x 2KB stack bằng 2GB có thể OOM không kiểm soát tải, hai worker pool số worker cố định jobs bằng make chan int QUEUE channel đệm bằng hàng đợi giới hạn đúng WORKERS goroutine mỗi cái xử lý job từ channel for w bằng 0 w nhỏ hơn WORKERS w go func for job range jobs lamViec job luôn đúng WORKERS goroutine bất kể số job, ba backpressure hàng đợi đầy producer chờ for i bằng 0 i nhỏ hơn JOBS i jobs nhận 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 hàng đợi giới hạn là chìa khóa khi producer nhanh hơn consumer gửi vào channel đầy sẽ block 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ớ, bốn vì sao quan trọng goroutine giới hạn không OOM vì quá nhiều stack hàng đợi giới hạn không OOM vì quá nhiều job chờ backpressure quá tải chậm lại block không sập đây là nền tảng hệ thống ổn định dưới tải cao

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ơ:

Ảnh chụp bảng kết quả đo thật nền tối pool giữ goroutine ở 6 ngây thơ bùng lên 102 go run Go 1.23 arm64 10 core 100 job mỗi job 5ms, số goroutine đỉnh pool vs ngây thơ cách Worker pool 4 worker hàng đợi 8 goroutine đỉnh 6 backpressure có producer chặn 89 lần Ngây thơ 1 goroutine job goroutine đỉnh 102 backpressure không chất đống pool giữ goroutine 6 4 worker cộng main cộng monitor bất kể 100 job cách ngây thơ bùng lên 102 với 1 triệu job ngây thơ tạo 1 triệu goroutine 2GB stack OOM pool vẫn 6, backpressure hoạt động worker pool 100 job hàng đợi 8 producer bị chặn 89 lần xong 100 job trong 135ms 100 chia 4 x 5ms bằng 125ms lý thuyết producer bị chặn 89 lần bằng 89 lần hàng đợi đầy khiến producer phải chờ worker lấy bớt đây là backpressure producer tự chậm về tốc độ hệ thống chịu được không đẩy công việc vô hạn vào bộ nhớ, hai thuộc tính bảo vệ hệ thống goroutine giới hạn WORKERS cố định không phình stack chống OOM hàng đợi giới hạn QUEUE cố định không phình job chờ chống OOM kết quả quá tải chậm lại block producer không sập, cốt lõi worker pool WORKERS goroutine cố định đọc từ channel đệm đo thật goroutine đỉnh 6 pool vs 102 ngây thơ 1 job backpressure hàng đợi đầy producer block tự chậm lại đo thật producer chặn 89 lần 100 job xong 135ms giá trị ổn định dưới tải cao chậm lại thay vì sập OOM

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ề

  1. 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.
  2. 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).
  3. 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 WORKERS theo 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ùng errgroup.SetLimit hoặ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.