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ẻ.

Ảnh chụp đoạn mã Go nền tối minh hoạ errgroup chạy nhóm goroutine có giới hạn gom lỗi hủy sớm, golang.org x sync errgroup thay 30 dòng worker pool tự viết bằng 5 dòng giới hạn lỗi và hủy đều tự lo, một cơ bản g.Go cộng g.Wait var g errgroup.Group for underscore u range urls u bằng u g.Go func error mỗi việc một hàm trả error return fetch u if err bằng g.Wait err khác nil chờ tất cả trả về lỗi đầu tiên, hai SetLimit giới hạn số goroutine đồng thời g bằng new errgroup.Group g.SetLimit 4 tối đa 4 goroutine chạy cùng lúc for i bằng 0 i nhỏ hơn 50 i g.Go việc thứ 5 chờ tới khi 1 trong 4 xong worker pool có giới hạn không cần channel worker tự viết SetLimit làm đúng việc bài trước phải tự dựng bằng channel cộng worker g.Go block khi đủ giới hạn backpressure sẵn có hoặc TryGo không block, ba WithContext hủy sớm khi một việc lỗi g ctx bằng 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 bằng work 1 việc trả lỗi ctx bị hủy các việc còn lại tự dừng WithContext hủy context của nhóm ngay khi bất kỳ goroutine nào trả lỗi các việc khác quan sát ctx.Done để dừng không phí công, bốn so với tự dựng worker pool bài trước tự dựng channel job cộng N worker cộng wg cộng xử lý lỗi tay 30 dòng errgroup g.SetLimit N cộng g.Go cộng g.Wait 5 dòng lỗi hủy tự lo

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:

Ảnh chụp bảng kết quả đo thật nền tối errgroup giới hạn đồng thời gom lỗi hủy sớm go run golang.org x sync errgroup v0.8.0 Go 1.23 arm64 10 core, SetLimit 4 50 việc đỉnh đồng thời đúng 4 SetLimit 4 50 việc số chạy đồng thời đỉnh bằng 4 không vượt 4 errgroup tự quản pool 50 việc nhưng không bao giờ quá 4 chạy cùng lúc việc thứ 5 chờ tới khi 1 trong 4 xong worker pool có giới hạn trong 5 dòng không cần channel worker tự viết, WithContext một việc lỗi các việc khác dừng sớm WithContext lỗi trả về bằng việc số 3 lỗi Wait trả lỗi đầu tiên số việc dừng sớm nhờ ctx bằng 9 9 việc còn lại thấy ctx.Done goroutine cuối bằng 1 sạch không rò goroutine việc số 3 trả lỗi WithContext hủy ctx 9 việc còn lại quan sát ctx.Done và dừng ngay không phí công Wait trả về lỗi đầu tiên kết thúc sạch goroutine bằng 1, errgroup vs tự dựng worker pool việc giới hạn đồng thời tự dựng channel cộng N worker errgroup SetLimit N gom lỗi tự dựng tay chan error errgroup Wait trả lỗi đầu hủy khi lỗi tự dựng tay ctx tự truyền errgroup WithContext tự hủy dòng code tự dựng 30 errgroup 5, cốt lõi errgroup nhóm goroutine g.Go fn cộng g.Wait trả lỗi đầu tiên SetLimit giới hạn đồng thời đo thật 50 việc đỉnh bằng 4 WithContext 1 việc lỗi hủy ctx việc khác dừng sớm đo 9 sạch goroutine cuối bằng 1 không rò dùng khi fan-out có lỗi giới hạn gọn hơn tự dựng pool nhiều

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át ctx.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ề

  1. 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 — thay sync.WaitGroup + xử lý lỗi thủ công; đo thật kết thúc sạch (goroutine cuối = 1, không rò).
  2. 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.Go block khi đủ giới hạn).
  3. 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à SetLimit phải gọi trước g.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).