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

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:

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

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