sync.WaitGroup chờ được nhóm goroutine nhưng không xử lý lỗi. errgroup giải đúng chỗ đó.

go get golang.org/x/sync@v0.8.0

Nó nằm ở golang.org/x/sync — kho phụ chính thức của nhóm Go, không phải thư viện chuẩn nhưng cùng mức tin cậy.

Một lỗi huỷ cả nhóm

g, ctx := errgroup.WithContext(context.Background())
for i := 1; i <= 4; i++ {
	i := i
	g.Go(func() error {
		if i == 2 { return errors.New("việc 2 hỏng") }
		select {
		case <-time.After(2 * time.Second):
			return nil
		case <-ctx.Done():
			return ctx.Err()
		}
	})
}
err := g.Wait()
  Wait trả về sau 30ms: việc 2 hỏng
  số việc chạy tới cùng: 0 (ba việc kia bị huỷ)

Việc thứ hai hỏng sau 30 mili giây, và Wait() trả về ngay — không chờ ba việc kia chạy hết 2 giây.

Cơ chế: WithContext trả về một ctx bị huỷ tự động khi goroutine đầu tiên trả về lỗi. Mọi việc khác thấy ctx.Done() và thoát.

Đây chính là điều bài 80 sê-ri Java đo được với StructuredTaskScope: bắt lỗi sau 102 ms và không tác vụ nào còn chạy. Go có sẵn từ lâu qua thư viện này.

Điều kiện để nó hoạt động: mọi việc phải kiểm ctx.Done(). errgroup không giết được goroutine — nó chỉ báo, đúng như bài 39 đã nói.

SetLimit: giới hạn đồng thời

g := new(errgroup.Group)
g.SetLimit(3)
for i := 0; i < 20; i++ {
	g.Go(func() error { ... })
}
g.Wait()
  20 việc, SetLimit(3) -> đỉnh đồng thời = 3

g.Go chặn khi đã đủ số việc đang chạy. Đây là cách gọn nhất để giới hạn đồng thời, thay cho mẫu semaphore bằng channel ở bài 37.

Rất hữu ích khi số việc phụ thuộc dữ liệu đầu vào: mười nghìn URL cần tải, nhưng chỉ muốn 20 kết nối cùng lúc.

TryGo cho trường hợp không muốn chặn — nó trả về false nếu đã đầy.

Điều errgroup KHÔNG làm

  Wait() trả về: lỗi A     <- chỉ lỗi ĐẦU TIÊN

Nó chỉ giữ lỗi đầu tiên. Hai việc cùng hỏng thì lỗi thứ hai biến mất.

Cần tất cả thì phải tự gom:

var mu sync.Mutex
var loi []error
g.Go(func() error {
	if err := lam(); err != nil {
		mu.Lock(); loi = append(loi, err); mu.Unlock()
	}
	return nil            // trả nil để KHÔNG huỷ nhóm
})
g.Wait()
return errors.Join(loi...)

Chú ý return nil — trả lỗi ở đây sẽ huỷ những việc còn lại, mà đôi khi bạn muốn tất cả chạy hết.

errors.Join từ Go 1.20 gộp nhiều lỗi thành một, và errors.Is vẫn tìm được qua từng cái.

Ba mẫu hay dùng

Gọi song song nhiều dịch vụ rồi gộp:

var hoSo *HoSo
var don []Don
g, ctx := errgroup.WithContext(ctx)
g.Go(func() (err error) { hoSo, err = layHoSo(ctx, id); return })
g.Go(func() (err error) { don, err = layDon(ctx, id); return })
if err := g.Wait(); err != nil { return nil, err }

Mỗi goroutine ghi vào biến riêng nên không cần khoá. Đây là mẫu tôi dùng nhiều nhất.

Xử lý danh sách có giới hạn:

g := new(errgroup.Group)
g.SetLimit(runtime.NumCPU())
for _, v := range ds {
	v := v
	g.Go(func() error { return xuLy(ctx, v) })
}
return g.Wait()

Chạy nhiều dịch vụ nền trong main:

g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return httpServer.Run(ctx) })
g.Go(func() error { return worker.Run(ctx) })
log.Fatal(g.Wait())     // một cái chết, cả hai dừng

Mẫu cuối là xương sống của nhiều dịch vụ Go, và bài 58 sẽ ghép nó với graceful shutdown.

Cái bẫy biến vòng lặp

for _, v := range ds {
	g.Go(func() error { return xuLy(v) })    // v
}

Với go.mod khai dưới go 1.22, đây là lỗi đua — bài 6 đã đo. Từ 1.22 thì đúng.

Nếu bạn thấy dòng v := v trong mã cũ, đó là lý do nó tồn tại.

Thử ba mươi giây

Lấy một đoạn dùng sync.WaitGroup trong dự án của bạn và hỏi: nếu một goroutine hỏng, những cái khác có biết không?

Với WaitGroup trần, câu trả lời là không — chúng chạy hết rồi bạn mới biết. Đổi sang errgroup là thay khoảng năm dòng, và bạn được huỷ sớm cùng với lỗi trả về.

Ngày mai: bài cuối chặng đồng thời — chọn channel hay mutex, đo bằng số thay vì theo châm ngôn.