Bài worker pool kết bằng lời khuyên: đừng tự dựng pool khi thư viện chuẩn đủ. Bài này là công cụ đó — golang.org/x/sync/errgroup. Nó làm ba việc mà ở bài trước ta phải tự viết ~30 dòng: chạy nhóm goroutine, gom lỗi, và hủy sớm — cộng thêm SetLimit để giới hạn số đồng thời như một worker pool. Tất cả trong ~5 dòng. Bài này đo thật ba khả năng đó.
Cơ bản: g.Go và g.Wait
errgroup.Group chạy nhiều goroutine, mỗi cái là một hàm trả error, và Wait() chờ tất cả rồi trả về lỗi đầu tiên (nếu có):
var g errgroup.Group
for _, u := range urls {
u := u
g.Go(func() error { // mỗi việc một hàm trả error
return fetch(u)
})
}
if err := g.Wait(); err != nil { // chờ tất cả; trả về lỗi ĐẦU TIÊN
...
}
So với sync.WaitGroup thuần (không có chỗ cho lỗi), errgroup gom lỗi tự nhiên — bạn không cần tự dựng channel lỗi hay biến chia sẻ.

Hình 1: errgroup. g.Go/g.Wait cơ bản, SetLimit giới hạn đồng thời, WithContext hủy sớm khi lỗi, và so với tự dựng worker pool.
SetLimit: giới hạn đồng thời như worker pool
SetLimit(n) giới hạn số goroutine chạy cùng lúc — chính là điều bài trước phải tự dựng bằng channel + worker:
g := new(errgroup.Group)
g.SetLimit(4) // tối đa 4 goroutine chạy CÙNG LÚC
for i := 0; i < 50; i++ {
g.Go(...) // việc thứ 5 chờ tới khi 1 trong 4 xong
}
g.Go sẽ block khi đã đủ giới hạn (backpressure sẵn có), hoặc dùng TryGo nếu muốn không block. Worker pool có giới hạn trong đúng ba dòng, không cần channel hay vòng lặp worker thủ công.
WithContext: hủy sớm khi một việc lỗi
errgroup.WithContext trả một context bị hủy ngay khi bất kỳ goroutine nào trả lỗi — cho các việc khác cơ hội dừng sớm thay vì phí công:
g, ctx := errgroup.WithContext(ctx)
for ... {
g.Go(func() error {
select {
case <-ctx.Done(): return ctx.Err() // việc khác lỗi → DỪNG SỚM
case r := <-work: ...
}
})
}
Đo thật: giới hạn, lỗi, và hủy
Chạy 50 việc với SetLimit(4), và 10 việc trong đó việc số 3 lỗi:

Hình 2: SetLimit(4) — 50 việc, đỉnh đồng thời đúng 4. WithContext — việc 3 lỗi, Wait() trả lỗi đó, 9 việc khác dừng sớm, goroutine cuối = 1 (sạch). Bảng so với tự dựng pool.
- SetLimit(4): 50 việc, số chạy đồng thời đỉnh = 4 (không vượt). errgroup tự quản pool.
- WithContext: việc số 3 trả lỗi →
Wait()trả về đúng lỗi đó; 9 việc còn lại dừng sớm nhờ quan sátctx.Done(); goroutine cuối = 1 (sạch, không rò).
So sánh với tự dựng worker pool (bài trước): giới hạn đồng thời (channel + N worker → SetLimit(N)), gom lỗi (tay → Wait() trả lỗi đầu), hủy khi lỗi (tay → WithContext tự hủy), ~30 dòng → ~5 dòng.
Ứng dụng thực tế
Fan-out gọi nhiều dependency song song. Cần gọi 5 API/DB song song rồi gom kết quả — dùng errgroup: mỗi lời gọi một g.Go, Wait() trả lỗi đầu tiên. Nếu một cái lỗi, WithContext hủy các cái còn lại để không phí thời gian chờ.
Xử lý hàng loạt có giới hạn. Xử lý N item song song nhưng giới hạn tải (DB, API bên ngoài): g.SetLimit(concurrency) rồi g.Go mỗi item. Gọn hơn nhiều so với dựng channel + worker, và tự có gom lỗi.
Pipeline có điểm dừng khi lỗi. Trong pipeline nhiều giai đoạn, nếu một giai đoạn lỗi, WithContext lan hủy xuống các giai đoạn sau qua context — chúng dừng sạch thay vì tiếp tục xử lý dữ liệu vô nghĩa.
Đánh đổi cần cân nhắc
errgroup chỉ giữ lỗi ĐẦU TIÊN. Wait() trả về lỗi của goroutine đầu tiên thất bại; các lỗi sau bị bỏ qua. Nếu bạn cần tất cả lỗi (ví dụ validate nhiều field, muốn báo hết), errgroup không hợp — dùng cách tự gom (slice lỗi có mutex, hoặc errors.Join). Biết giới hạn này trước khi chọn.
Hủy là hợp tác, không cưỡng chế. WithContext hủy context, nhưng goroutine phải tự quan sát ctx.Done() để dừng. Một việc đang time.Sleep hay chạy CPU thuần không kiểm ctx sẽ không dừng sớm — nó chạy hết rồi mới thoát. Đảm bảo việc của bạn kiểm ctx ở các điểm chờ/lặp để hủy thực sự có tác dụng.
SetLimit phải gọi TRƯỚC g.Go đầu tiên. Đổi giới hạn khi nhóm đang chạy không an toàn/không có tác dụng như mong đợi. Đặt SetLimit ngay sau khi tạo group, trước vòng lặp g.Go. Và SetLimit(-1) gỡ giới hạn (không giới hạn).
Ba ý mang về
- errgroup chạy nhóm goroutine và gom lỗi gọn gàng:
g.Go(fn)cho mỗi việc,g.Wait()chờ tất cả và trả về lỗi đầu tiên — thaysync.WaitGroup+ xử lý lỗi thủ công; đo thật kết thúc sạch (goroutine cuối = 1, không rò). - SetLimit biến errgroup thành worker pool có giới hạn trong vài dòng: đo thật 50 việc với
SetLimit(4)giữ đỉnh đồng thời đúng 4 — làm đúng điều bài trước phải tự dựng bằng channel + worker (~30 dòng → ~5 dòng), với backpressure sẵn có (g.Goblock khi đủ giới hạn). - WithContext hủy sớm khi một việc lỗi: đo thật việc số 3 lỗi khiến 9 việc khác dừng sớm qua
ctx.Done()— nhưng hủy là hợp tác (goroutine phải tự kiểm ctx), errgroup chỉ giữ lỗi đầu tiên, vàSetLimitphải gọi trướcg.Go.
Phần sau ta xem mặt tối của lập trình đồng thời — khi mọi thứ khóa lẫn nhau: Phần sau mổ xẻ deadlock — bốn điều kiện gây deadlock, cách Go phát hiện deadlock toàn cục lúc chạy, và các mẫu phòng tránh (thứ tự khóa nhất quán, timeout).