Lỗi gặp phải
panic: sync: WaitGroup is reused before previous Wait has returned
Lỗi này xảy ra trong một Go service xử lý batch jobs theo vòng lặp. WaitGroup được khai báo ngoài vòng lặp và tái sử dụng qua mỗi vòng. Đến một thời điểm nào đó, wg.Add() được gọi trong khi wg.Wait() trước đó vẫn chưa kết thúc hoàn toàn. Runtime của Go phát hiện điều này ngay lập tức và panic — tốt hơn là để state bị hỏng âm thầm, nhưng vẫn rất khó truy tìm nguyên nhân trên môi trường production.
Tại sao lỗi xảy ra
Go có một quy tắc cứng: không bao giờ gọi wg.Add() với delta dương trong khi counter đang chuyển về zero. "Chuyển về zero" có nghĩa là Wait() đang trong quá trình giải phóng các caller đang chờ. Gọi Add() trong khoảng thời gian đó là nguyên nhân gây panic.
Hai pattern thường xuyên gây ra lỗi này:
Pattern 1 — WaitGroup tái sử dụng qua các vòng lặp:
var wg sync.WaitGroup
for round := 0; round < 3; round++ {
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
time.Sleep(10 * time.Millisecond)
}(i)
}
wg.Wait() // Wait trả về ở vòng 0...
// Vòng tiếp theo lập tức gọi wg.Add(1).
// Nếu Wait() chưa thực sự trả về xong nội bộ, đây là race condition → panic
}
Pattern 2 — Add() được gọi bên trong goroutine sau khi Wait() có thể đã bắt đầu:
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// Tạo thêm công việc ở đây là không an toàn — nếu goroutine này
// là goroutine cuối cùng, Done() giảm counter về 0, Wait() bị unblock,
// rồi Add() của goroutine con race với quá trình unblock đó.
wg.Add(1) // ← có thể panic
go func() {
defer wg.Done()
}()
}()
wg.Wait()
Cách sửa 1: Tạo WaitGroup mới cho mỗi batch
Khai báo var wg bên trong vòng lặp. Mỗi vòng có một WaitGroup mới hoàn toàn, không còn state cũ nào — không có gì để race với. Cách này giải quyết vấn đề trong hầu hết các pattern dùng vòng lặp.
for round := 0; round < 3; round++ {
var wg sync.WaitGroup // mới hoàn toàn mỗi vòng
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
time.Sleep(10 * time.Millisecond)
}(i)
}
wg.Wait()
}
Cách sửa 2: Gọi Add() cho tất cả goroutine trước khi bất kỳ goroutine nào chạy
Biết trước tổng số lượng? Set counter một lần, rồi mới launch:
var wg sync.WaitGroup
tasks := fetchTasks()
wg.Add(len(tasks)) // set counter trước khi bất kỳ goroutine nào chạy
for _, task := range tasks {
go func(t Task) {
defer wg.Done()
process(t)
}(task)
}
wg.Wait()
Counter bắt đầu tại len(tasks) và chỉ giảm dần từ đó. Add() không bao giờ được gọi trong khi Wait() đang hoạt động, nên race condition không thể xảy ra.
Cách sửa 3: Với goroutine lồng nhau, gọi Add() trước khi goroutine cha gọi Done()
Một goroutine tạo ra goroutine con phải đăng ký những goroutine con đó trước khi nó giảm counter. Điều này giữ counter luôn dương trong suốt quá trình:
var wg sync.WaitGroup
wg.Add(1)
go func() {
// Đăng ký goroutine con TRƯỚC KHI goroutine này gọi Done()
wg.Add(2)
go func() {
defer wg.Done()
doWork()
}()
go func() {
defer wg.Done()
doWork()
}()
wg.Done() // goroutine cha kết thúc, nhưng counter vẫn còn 2 (các goroutine con)
}()
wg.Wait()
Quy tắc: counter không được bao giờ về zero trong khi bạn vẫn còn dự định gọi Add(). Zero là điểm không thể quay lại.
Cách sửa 4: Dùng errgroup cho các trường hợp fan-out phức tạp
Bất cứ thứ gì phức tạp hơn một batch đơn giản sẽ nhanh chóng trở nên rối rắm. golang.org/x/sync/errgroup xử lý toàn bộ việc Add/Done/Wait và thu thập lỗi từ các goroutine — giải quyết hai vấn đề trong một package:
import "golang.org/x/sync/errgroup"
func runBatch(tasks []Task) error {
g := new(errgroup.Group)
for _, task := range tasks {
t := task
g.Go(func() error {
return process(t)
})
}
return g.Wait()
}
Mỗi lần gọi g.Go() đều an toàn nội bộ. Không cần Add/Done thủ công, và g.Wait() trả về lỗi non-nil đầu tiên từ bất kỳ goroutine nào.
Kiểm tra sau khi sửa
Luôn chạy với race detector. Nó phát hiện lỗi sử dụng WaitGroup sai trước khi lên production và cho stack trace rõ ràng hơn nhiều so với thông báo panic:
go run -race main.go
# cho tests
go test -race ./...
Muốn stress-test thêm? Gọi hàm liên tục trong vòng lặp chặt:
for i := 0; i < 500; i++ {
go runYourBatchFunction()
}
time.Sleep(5 * time.Second)
Không panic, không có output từ race detector — vậy là xong.
Mẹo nhanh
- Luôn phát triển với
-race. Thông báo panic trên production rất mơ hồ. Race detector cho bạn đầy đủ goroutine stack, chỉ rõ chính xácAdd()nào được gọi quá muộn. - Không bao giờ truyền WaitGroup theo giá trị. Luôn dùng
*sync.WaitGroup. Sao chép một WaitGroup đang được sử dụng là hành vi không xác định và gây ra cùng loại panic. - Khai báo WaitGroup gần nơi sử dụng. Một WaitGroup cấp package được dùng chung giữa các hàm là thảm họa tái sử dụng đang chờ xảy ra.
- Counter phải luôn dương khi bạn còn dự định gọi
Add()tiếp. Nếu counter có thể về zero vàWait()có thể trả về, hãy tạo một WaitGroup mới cho batch tiếp theo.

