Go làm goroutine rẻ đến mức sinh ra một thói quen nguy hiểm: "cứ go func() cho mỗi task". Với vài chục task thì không sao, nhưng với hàng triệu task, bạn tạo hàng triệu goroutine — tốn RAM (mỗi goroutine vài KB stack), đè scheduler, và tệ hơn: hàng triệu goroutine cùng gọi database sẽ đè sập database. Worker pool giới hạn số việc chạy đồng thời ở một con số N cố định, tái dùng goroutine thay vì tạo mới. Bài này (phần 7 loạt Hệ thống chịu tải) chạy thật để thấy sự bùng nổ goroutine và tìm số worker tối ưu.

Cơ chế: N worker cố định đọc chung một channel

Thay vì một goroutine mỗi task, ta tạo đúng N goroutine (worker), tất cả đọc từ một channel công việc chung:

jobs := make(chan Task, cap)
for w := 0; w < N; w++ {           // tạo ĐÚNG N goroutine
    go func() { for t := range jobs { handle(t) } }()  // tái dùng
}
for _, t := range tasks { jobs <- t }  // đẩy việc vào
close(jobs)

Dù có 1 triệu task, số goroutine luôn là N. Song song bị giới hạn ở N — vừa kiểm soát tài nguyên của chính mình, vừa bảo vệ downstream (chỉ N kết nối database cùng lúc, chẳng hạn).

Ảnh chụp đoạn mã Go nền tối minh hoạ worker pool, vấn đề goroutine-per-task bùng nổ khi nhiều task go func cho mỗi task nghe tiện nhưng với hàng triệu task for range tasks go handle t tạo hàng triệu goroutine tốn RAM mỗi goroutine vài KB stack cộng đè scheduler quá nhiều song song đè downstream DB API tới sập, cơ chế N worker cố định đọc chung 1 channel jobs make chan Task cap for w 0 tới N go func for t range jobs handle t tái dùng for range tasks jobs nhận t đẩy việc vào close jobs song song bị giới hạn ở N dù có 1 triệu task, chọn N CPU-bound số core I/O-bound cao hơn CPU-bound N khoảng số core thêm nữa chỉ tốn context switch không nhanh hơn I/O-bound chờ mạng DB N cao hơn nhiều worker chờ không ăn CPU nhưng vẫn giới hạn theo capacity downstream số kết nối DB

Hình 1: Worker pool tạo đúng N goroutine đọc chung channel jobs — song song giới hạn ở N dù bao nhiêu task; chọn N theo loại việc (CPU-bound ~ số core, I/O-bound cao hơn nhưng giới hạn theo downstream).

Đo thật: bão hòa quanh số core, và 11 vs 200.001 goroutine

Mình đo throughput theo số worker (CPU-bound, 10 core) và so số goroutine đỉnh:

Ảnh chụp bảng kết quả chạy thật worker pool output thật, một throughput theo số worker CPU-bound 10 core workers task mỗi giây 1 14778 2 30117 4 43748 8 66638 16 69359 32 67974 100 70270 1000 69866 tăng tới khoảng 8-16 worker xấp xỉ số core 10 rồi bão hòa 70k task mỗi giây thêm worker không nhanh hơn chênh lệch nhỏ là nhiễu, hai số goroutine đỉnh goroutine-per-task vs pool goroutine-per-task 200k task đỉnh 200001 goroutine pool 10 worker 200k task đỉnh 11 goroutine 10 worker cộng main pool tái dùng 10 goroutine cho mọi task per-task phình theo số task, kết luận pool giới hạn song song cộng tái dùng goroutine 11 vs 200001 CPU-bound throughput bão hòa quanh số core N khoảng số core là đủ quá ít worker không tận dụng core quá nhiều tốn context switch RAM I/O-bound N cao hơn worker chờ nhưng giới hạn theo downstream DB conn pool cũng là điểm áp backpressure chan đầy cộng load shedding tự nhiên

Hình 2: Chạy thật — throughput CPU-bound: 1 worker 14.778 task/s tăng dần tới ~70k ở 8-16 worker (≈ 10 core) rồi bão hòa; số goroutine: per-task 200.001 vs pool 10 worker chỉ 11.

Đọc kết quả đo được:

  • Throughput tăng tới số core rồi bão hòa: 1 worker 14.778 task/s, 2 → 30k, 4 → 43k, 8 → 66k, 16 → 69k. Tới đây đạt trần ~70k task/s và dừng — 32, 100, 1000 worker đều quanh 70k (chênh lệch nhỏ là nhiễu đo). Với công việc CPU-bound, throughput bị chặn bởi số core (10): khi đã dùng hết core, thêm worker chỉ tạo thêm context switch chứ không có core nào rảnh để chạy.
  • Goroutine: 11 vs 200.001: goroutine-per-task với 200.000 task tạo đỉnh 200.001 goroutine cùng tồn tại; worker pool 10 giữ đỉnh chỉ 11 (10 worker + main). Đây là khác biệt sống còn về tài nguyên: 200.000 goroutine × vài KB stack = hàng trăm MB RAM chỉ cho stack, chưa kể áp lực scheduler — trong khi pool giữ mức không đổi bất kể số task.
  • Pool = kiểm soát, không phải giới hạn tốc độ: điều đẹp là pool không làm chậm khi N đủ lớn (throughput ở 16 worker ≈ 1000 worker), nó chỉ chặn phần thừa vô ích. Bạn được throughput tối đa mà không trả giá bùng nổ goroutine.

Đánh đổi cần cân nhắc

CPU-bound: N ≈ số core; I/O-bound: N cao hơn nhiều. Đây là quyết định quan trọng nhất. Với việc CPU-bound (tính toán), như đo được, N vượt số core là vô ích — throughput bão hòa. Với việc I/O-bound (chờ mạng, database, đĩa), worker phần lớn thời gian chờ chứ không ăn CPU, nên N có thể cao hơn số core nhiều lần để lấp thời gian chờ. Nhưng đừng đặt N vô hạn: giới hạn theo capacity downstream — nếu database chỉ chịu 50 kết nối, pool không nên có 500 worker cùng gọi nó.

Pool cũng là điểm áp backpressure và load shedding tự nhiên. Channel jobs có buffer chính là bounded queue (bài 5): khi đầy, người đẩy việc bị chặn (backpressure) hoặc có thể từ chối (load shedding, bài 6). Vậy worker pool không chỉ giới hạn song song mà còn là nơi tự nhiên để áp các mẫu kia — ba mẫu này thường đi cùng nhau trong một pipeline xử lý.

Đo để tìm N, đừng đoán. Con số "số core" là điểm khởi đầu tốt cho CPU-bound, nhưng workload thật thường hỗn hợp (vừa tính vừa chờ I/O). Cách đúng là đo throughput theo N như bài này trên workload thật của bạn, tìm điểm bão hòa. Đặt N ngay trước điểm đó — đủ để tận dụng, không thừa để lãng phí. Và nhớ GOMAXPROCS (mặc định = số core) là trần song song thực sự trên CPU, khác với số worker.

Ba ý mang về

  1. Worker pool chống bùng nổ goroutine: đo thật 200.000 task với goroutine-per-task tạo 200.001 goroutine, còn pool 10 worker chỉ 11 — pool tái dùng số goroutine cố định bất kể số task, tránh cạn RAM và đè scheduler.
  2. CPU-bound: throughput bão hòa quanh số core: đo thật throughput tăng tới ~70k task/s ở 8-16 worker (≈ 10 core) rồi dừng — thêm worker chỉ tốn context switch; N ≈ số core là đủ cho CPU-bound.
  3. Chọn N theo loại việc và downstream, đo đừng đoán: I/O-bound cần N cao hơn (worker chờ) nhưng giới hạn theo capacity downstream (số kết nối DB); pool cũng là điểm áp backpressure/load shedding; đo throughput theo N trên workload thật để tìm điểm bão hòa.

Nguồn

Phần sau ta sang caching và cache stampede — cache tăng tốc bằng cách nhớ kết quả, nhưng khi một key nóng hết hạn, hàng nghìn request cùng miss và cùng dội xuống database (stampede); đo thật singleflight gộp chúng thành một lần tính.