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
Hai thí nghiệm riêng: (1) chạy 50 việc với SetLimit(4); (2) chạy 10 việc với WithContext, 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.
Bài tập làm thử
Bài 1 (đọc hiểu cơ bản). Giải thích khác biệt giữa sync.WaitGroup thường và errgroup.Group, dựa trên đoạn code sau.
var g errgroup.Group
for _, u := range urls {
u := u
g.Go(func() error {
return fetch(u)
})
}
if err := g.Wait(); err != nil {
// ...
}
Đáp án
sync.WaitGroup chỉ giúp chờ một nhóm goroutine hoàn tất, không có cơ chế nào để thu thập lỗi trả về từ các goroutine — nếu cần biết goroutine nào lỗi, lập trình viên phải tự dựng thêm channel lỗi hoặc biến chia sẻ có mutex. errgroup.Group tích hợp sẵn việc này: mỗi công việc đưa vào g.Go là một hàm trả về error, và g.Wait() vừa chờ tất cả goroutine hoàn tất, vừa tự động trả về lỗi của goroutine đầu tiên bị lỗi (nếu có) — không cần dựng thêm cơ chế thu thập lỗi thủ công.
Bài 2 (vận dụng — SetLimit). Viết một đoạn code dùng errgroup.Group với SetLimit để xử lý 200 phần tử trong slice items, mỗi phần tử gọi hàm xuLy(item) error, nhưng không bao giờ quá 10 goroutine chạy đồng thời.
Đáp án
g := new(errgroup.Group)
g.SetLimit(10) // đặt trước khi gọi g.Go lần đầu
for _, item := range items {
item := item
g.Go(func() error {
return xuLy(item)
})
}
if err := g.Wait(); err != nil {
// xử lý lỗi đầu tiên gặp phải
}
SetLimit(10) khiến g.Go tự block khi đã có đủ 10 goroutine đang chạy — việc thứ 11 trở đi phải chờ tới khi một trong 10 việc đang chạy hoàn tất mới được bắt đầu, cho hành vi giống hệt một worker pool có giới hạn nhưng không cần tự dựng channel/worker thủ công.
Bài 3 (đọc hiểu cơ chế — WithContext). Trong đoạn code sau, nếu công việc thứ 3 trả về lỗi, điều gì xảy ra với công việc thứ 7 (giả sử nó đang chờ ở nhánh select)?
g, ctx := errgroup.WithContext(parentCtx)
for _, w := range works {
w := w
g.Go(func() error {
select {
case <-ctx.Done():
return ctx.Err()
case r := <-w:
return xuLyKetQua(r)
}
})
}
Đáp án
Ngay khi công việc thứ 3 trả về lỗi (khác nil), errgroup.WithContext sẽ tự động hủy ctx (gọi cancel function bên trong). Công việc thứ 7, đang chờ ở select với hai nhánh <-ctx.Done() và <-w, sẽ thấy nhánh ctx.Done() sẵn sàng (vì ctx vừa bị hủy) và có thể được chọn, khiến nó return ctx.Err() và dừng sớm — không cần chờ dữ liệu thực sự từ w. Đây là cơ chế hủy sớm giúp các công việc còn lại không phí công tiếp tục xử lý khi cả nhóm đã coi như thất bại.
Bài 4 (bẫy — hủy chỉ là hợp tác). Đoạn code sau dùng errgroup.WithContext nhưng công việc bên trong lại không kiểm tra ctx ở đâu cả. Giải thích tại sao hủy sớm sẽ không có tác dụng thực sự với hàm này, dù Wait() vẫn trả về lỗi đúng.
g, ctx := errgroup.WithContext(parentCtx)
g.Go(func() error {
time.Sleep(30 * time.Second) // không kiểm ctx ở đâu
return tinhToanNang()
})
Đáp án
Hủy trong errgroup (qua context) là hợp tác (cooperative), không phải cưỡng chế (preemptive) — ctx bị hủy chỉ đặt một cờ/đóng một channel (ctx.Done()), nó không có khả năng dừng một goroutine đang chạy nếu goroutine đó không tự kiểm tra ctx.Done() ở đâu. Trong ví dụ này, time.Sleep(30 * time.Second) không quan sát ctx chút nào, nên dù một công việc khác trong nhóm đã lỗi và ctx đã bị hủy, hàm này vẫn tiếp tục ngủ đủ 30 giây rồi mới chạy tinhToanNang() và trả về — nó hoàn toàn không "dừng sớm". Wait() vẫn trả đúng lỗi đầu tiên (từ công việc kia), nhưng riêng công việc này đã lãng phí toàn bộ thời gian chờ dù về lý thuyết không cần thiết nữa — muốn hủy thực sự có tác dụng, hàm phải chủ động kiểm ctx.Done() tại các điểm chờ/lặp của nó (ví dụ dùng select giữa ctx.Done() và time.After, hoặc kiểm tra ctx.Err() định kỳ trong vòng lặp tính toán).
Bài 5 (bẫy/đánh đổi — giới hạn của errgroup). Một hệ thống validate 5 trường dữ liệu độc lập, mỗi trường validate bằng một goroutine riêng qua errgroup.Group, và muốn báo cho người dùng tất cả các trường bị lỗi cùng lúc (không chỉ lỗi đầu tiên). errgroup.Group mặc định có đáp ứng được yêu cầu này không? Nếu không, hướng khắc phục là gì?
Đáp án
Không đáp ứng được trực tiếp. errgroup.Group.Wait() chỉ trả về lỗi của goroutine đầu tiên thất bại — các lỗi từ những goroutine thất bại sau đó bị bỏ qua hoàn toàn, không có cách nào lấy lại chúng qua API mặc định của errgroup. Với yêu cầu cần thu thập tất cả lỗi (như validate nhiều trường độc lập, muốn báo hết một lần), cần tự dựng cơ chế gom lỗi khác: ví dụ mỗi goroutine ghi lỗi (nếu có) vào một slice dùng chung được bảo vệ bằng mutex, sau đó dùng sync.WaitGroup (không phải errgroup) để chờ tất cả rồi gộp các lỗi lại bằng errors.Join(...), thay vì trông cậy vào errgroup.Group vốn được thiết kế cho mô hình "dừng lại ngay khi có một lỗi", không phải "thu thập toàn bộ lỗi".
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).