Hình dung hai cách tổ chức một đội leo núi. sync.WaitGroup giống kiểu mạnh ai nấy leo, hẹn gặp nhau ở đỉnh: ai kẹt giữa đường thì những người khác cứ leo tiếp, và bạn chỉ biết có chuyện khi lên tới nơi điểm danh. errgroup thì buộc cả đội vào một sợi dây có bộ đàm: ai trượt là dây giật và bộ đàm hô "thu quân", cả đội dừng và quay về ngay, không ai phí sức leo tiếp một chuyến đã hỏng. 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 — dây giật, bộ đàm hô, cả đội quay về.

Đâ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. Bộ đàm hô "thu quân" nhưng người leo phải chịu nghe và quay lạ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 — quy định tối đa ba người trên vách cùng lúc. Đâ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.

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 — bộ đàm chỉ ghi lại tiếng kêu cứu đầu tiên.

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.

Nếu muốn tự kiểm thiết kế của mình, lấy một đoạn dùng sync.WaitGroup trong dự án và hỏi đúng ba mươi giây: 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 leo 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ề.

Mẫu số chung

errgroup là phiên bản Go của một ý tưởng có tên hẳn hoi: đồng thời có cấu trúc (structured concurrency). Nguyên tắc — do Nathaniel Smith nêu với hình ảnh "nhà trẻ" (nursery) — là các tác vụ đồng thời nên tạo thành một cây có phạm vi, nơi cha (a) chờ mọi con xong, (b) một con hỏng thì huỷ các con còn lại, và (c) không con nào sống sót vượt ra ngoài phạm vi. Cả đội cùng một sợi dây, không ai lạc lại trong núi sau khi chuyến đi kết thúc.

  • Kotlin đưa thẳng vào ngôn ngữ: coroutineScope cộng async — một con ném ngoại lệ là huỷ anh em. Swift có TaskGroup/async let hành xử y hệt. Java có StructuredTaskScope (Loom). Python có nursery của Trio/anyio — chính nơi cái tên ra đời.
  • Go không bán nó trong ngôn ngữ; errgroup cho bạn hai bảo đảm đầu (chờ hết + huỷ anh em), còn bảo đảm thứ ba bạn phải tự giữ — vì Go không giết được goroutine, bạn buộc phải kiểm ctx.Done().

Hai hằng số xuyên ngôn ngữ đáng mang theo. Một: chính sách khi hỏng là một lựa chọn — "một lỗi huỷ cả nhóm" là mặc định phổ biến, nhưng "chạy hết rồi gom lỗi" là phương án khác và phải khai tường minh (errors.Join của Go, supervisorScope của Kotlin). Hai: giới hạn đồng thời luôn là một semaphore bên dưới, dù tên gọi là SetLimit, asyncio.Semaphore, hay p-limit. Sợi chỉ chung: đồng thời không cấu trúc — một WaitGroup trần, một luồng thả rông — là nơi một tác vụ hỏng chạy tới cùng mà không ai hay, và một tác vụ bị quên thì rò rỉ. Hãy cho sự đồng thời của bạn một phạm vi làm chủ các con của nó; đó là khác biệt giữa một đội buộc dây và một đám người tình cờ cùng leo một ngọn núi.

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.

Bài tập làm thử

Bài 1 (đọc hiểu). Với đoạn mã sau, việc thứ hai hỏng ngay sau 30ms. Wait() trả về sau bao lâu, và ba việc còn lại chạy tới hết 2 giây hay không?

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()
Đáp án

Wait() trả về ngay sau khoảng 30ms với lỗi "việc 2 hỏng", không chờ hết 2 giây. Cơ chế: WithContext trả về ctx bị huỷ tự động khi goroutine đầu tiên trả lỗi; ba việc còn lại đều có select kiểm ctx.Done() nên thoát ngay khi thấy context bị huỷ, thay vì chạy hết 2 giây.

Bài 2 (sửa lỗi). Đoạn mã dưới đây muốn giới hạn tối đa 3 việc song song nhưng dùng biến vòng lặp sai cách theo phiên bản Go cũ (go.mod khai go 1.21). Chỉ ra lỗi và sửa.

g := new(errgroup.Group)
g.SetLimit(3)
for _, v := range ds {
	g.Go(func() error { return xuLy(v) })
}
g.Wait()
Đáp án

Với go.mod khai dưới go 1.22, biến v được chia sẻ giữa các lần lặp — mọi closure có thể thấy cùng giá trị v cuối cùng (hoặc giá trị bị ghi đè), gây lỗi đua dữ liệu. Sửa bằng cách tạo bản sao cục bộ trong mỗi lần lặp:

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

(Từ Go 1.22 trở lên, mỗi lần lặp for tự tạo biến mới nên không cần dòng v := v nữa.)

Bài 3 (vận dụng thực tế). Bạn cần chạy đồng thời việc xử lý một danh sách lớn, nhưng muốn tất cả các việc đều chạy xong dù một số việc hỏng, rồi gộp hết lỗi lại thành một. Viết đoạn mã dùng errgroup cho đúng yêu cầu này (không dùng errors.New tuỳ ý mà dùng đúng cơ chế gộp lỗi đã nêu trong bài).

Đáp án
var mu sync.Mutex
var loi []error
g := new(errgroup.Group)
for _, v := range ds {
	v := v
	g.Go(func() error {
		if err := lam(v); err != nil {
			mu.Lock()
			loi = append(loi, err)
			mu.Unlock()
		}
		return nil // trả nil để KHÔNG huỷ các việc còn lại
	})
}
g.Wait()
return errors.Join(loi...)

Điểm mấu chốt: mỗi hàm trong g.Go phải return nil (không trả lỗi thật) để errgroup không huỷ các goroutine còn lại — vì mặc định errgroup chỉ giữ và phản ứng với lỗi đầu tiên. Lỗi được tự gom vào slice có khoá mu, rồi errors.Join (Go 1.20+) gộp lại thành một lỗi mà errors.Is vẫn dò được qua từng cái.

Bài 4 (bẫy/đánh đổi). Vì sao SetLimit(3) không "giết" các goroutine đang chạy khi hạn mức đã đầy, mà là chặn ở đâu? Và nếu một việc trong g.Go không bao giờ trả về (bị treo mãi, ví dụ chờ một channel không ai gửi), điều gì xảy ra với SetLimit?

Đáp án

g.Go chặn ở lệnh gọi g.Go tiếp theo khi số việc đang chạy đã đạt giới hạn — nó không giết hay tạm dừng goroutine nào cả, chỉ đơn giản là không cho thêm việc mới bắt đầu cho tới khi có một slot trống. Nếu một việc bị treo mãi mà không bao giờ trả về, nó chiếm vĩnh viễn một trong ba slot của SetLimit(3), khiến các lệnh g.Go sau đó bị chặn mãi mãi (chỉ còn tối đa 2 việc thật sự chạy được) — đây chính là hệ quả của việc errgroup (và Go nói chung) không có cách giết một goroutine từ bên ngoài, mọi việc phải tự kiểm ctx.Done() để thoát.

Bài 5 (đọc hiểu — errgroup và context trong main). Với mẫu chạy nhiều dịch vụ nền sau, nếu worker.Run(ctx) gặp lỗi và trả về, điều gì xảy ra với httpServer.Run(ctx)? Giả sử httpServer.Run có kiểm ctx.Done() đúng cách.

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())
Đáp án

Khi worker.Run(ctx) trả lỗi, errgroup tự huỷ ctx (vì dùng WithContext). httpServer.Run(ctx) thấy ctx.Done() và thoát theo, nên cả hai dịch vụ cùng dừng — "một cái chết, cả hai dừng" đúng như mẫu trong bài. g.Wait() trả về lỗi của worker, và log.Fatal kết thúc chương trình với lỗi đó.