Lỗi Gặp Phải
Chương trình Go của bạn bị crash lúc runtime với thông báo kiểu như sau:
goroutine 1 [running]:
sync: negative WaitGroup counter
goroutine 1 [running]:
sync.(*WaitGroup).Add(0xc000012090, 0xffffffffffffffff)
/usr/local/go/src/sync/waitgroup.go:74 +0x12d
main.main()
/home/user/project/main.go:18 +0x68
exit status 2
Bộ đếm nội bộ bên trong sync.WaitGroup đã giảm xuống dưới 0. Go coi đây là lỗi lập trình nghiêm trọng và lập tức panic — không có cơ chế phục hồi, không có quá trình tắt graceful.
Nguyên Nhân Gốc Rễ
Bộ đếm của WaitGroup phải luôn ở mức 0 hoặc cao hơn. Ba sai lầm sau đây chiếm phần lớn nguyên nhân gây ra panic này:
- Gọi
Done()nhiều lần hơnAdd()— nguyên nhân phổ biến nhất, thường do vô tình gọi trùng lặp. - Gọi
Add()bên trong goroutine thay vì trước khi khởi chạy nó — scheduler có thể chạyDone()trước khiAdd()kịp thực thi. - Tái sử dụng WaitGroup trong khi
Wait()vẫn đang hoạt động — một lệnhAdd()mới chạy đua với quá trình reset bị cấm rõ ràng trong đặc tả Go.
Cách Sửa 1: Gọi Done() Quá Nhiều Lần
Đây là nguyên nhân kinh điển. Mỗi lần gọi wg.Done() sẽ trừ 1 khỏi bộ đếm. Gọi hai lần cho một Add(1) duy nhất sẽ khiến bộ đếm đạt -1 — panic ngay lập tức.
Code lỗi:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done() // Done lần đầu — OK
fmt.Println("working")
wg.Done() // Done lần hai — bộ đếm về -1 → PANIC
}()
wg.Wait()
}
Cách sửa: Một goroutine, một defer wg.Done(). Đặt ở đầu hàm và không bao giờ gọi Done() thủ công bên cạnh nó.
go func() {
defer wg.Done() // chỉ ở đây thôi
fmt.Println("working")
}()
Cách Sửa 2: Add() Được Gọi Bên Trong Goroutine
Goroutine không khởi chạy ngay lập tức. Giữa từ khóa go và thời điểm goroutine thực sự chạy, scheduler có thể thực thi code khác — bao gồm cả wg.Wait(). Đặt Add(1) bên trong goroutine tạo ra một race condition mà Done() có thể kích hoạt trước khi bộ đếm được tăng lên.
Code lỗi:
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
go func(n int) {
wg.Add(1) // SAI: Add bên trong goroutine
defer wg.Done()
fmt.Println(n)
}(i)
}
wg.Wait()
}
Cách sửa: Chuyển wg.Add(1) lên ngay trước câu lệnh go, trong cùng goroutine sở hữu vòng lặp.
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // Đúng: Add trước từ khóa go
go func(n int) {
defer wg.Done()
fmt.Println(n)
}(i)
}
wg.Wait()
}
Cách Sửa 3: Truyền WaitGroup Theo Giá Trị
Truyền WaitGroup theo giá trị thì hàm nhận được bản sao riêng của nó. Done() bên trong hàm đó giảm bộ đếm của bản sao — bản gốc không hề thay đổi. Tùy vào thời điểm, bản sao có thể đạt giá trị âm hoặc bản gốc bị block mãi mãi chờ giá trị 0 không bao giờ xuất hiện.
Code lỗi:
func worker(wg sync.WaitGroup) { // sao chép theo giá trị — SAI
defer wg.Done()
fmt.Println("working")
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go worker(wg)
wg.Wait() // block mãi mãi, hoặc panic
}
Cách sửa: Luôn truyền con trỏ. Đây là một trong số ít trường hợp trong Go mà quy tắc dùng con trỏ là bắt buộc tuyệt đối.
func worker(wg *sync.WaitGroup) { // con trỏ — đúng
defer wg.Done()
fmt.Println("working")
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go worker(&wg)
wg.Wait()
}
Cách Sửa 4: Tái Sử Dụng WaitGroup Quá Sớm
Khi bộ đếm về 0, Wait() bắt đầu mở khóa. Đúng vào thời điểm đó, nếu một goroutine khác gọi Add() sẽ xảy ra race condition với quá trình reset nội bộ. Tài liệu Go cấm rõ ràng điều này — kết quả là hành vi không xác định có thể biểu hiện dưới dạng panic bộ đếm âm.
Pattern lỗi:
// goroutine A: wg.Wait() đang mở khóa (bộ đếm vừa về 0)
// goroutine B: wg.Add(1) chạy đua với quá trình reset — hành vi không xác định
Cách sửa: Dùng một WaitGroup mới cho mỗi batch. Khai báo bên trong vòng lặp gần như không tốn chi phí gì và loại bỏ hoàn toàn race condition.
for batch := 0; batch < 3; batch++ {
var wg sync.WaitGroup // WaitGroup mới cho mỗi batch
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(batch, n)
}(i)
}
wg.Wait() // hoàn tất trước khi sang vòng lặp tiếp theo
}
Phát Hiện Sớm: Race Detector
Go có sẵn một race detector tích hợp. Hãy chạy nó trong quá trình phát triển — nó phát hiện việc sử dụng sai WaitGroup và các data race với thông tin quy trách nhiệm chính xác đến cấp độ goroutine, không chỉ là một stack trace panic:
go run -race main.go
go test -race ./...
Khi phát hiện race condition, bạn sẽ thấy chính xác file, dòng code và goroutine liên quan. Hãy sửa ngay tại đó, đừng để đến môi trường production.
Kiểm Tra Lại
Ba lệnh để xác nhận bản sửa đã hoạt động:
# Chạy bình thường
go run main.go
# Với race detector
go run -race main.go
# Toàn bộ test suite
go test -race ./...
Không panic, không có output nào từ -race. Đó là dấu hiệu xanh lá.
Checklist Phòng Ngừa
- Gọi
wg.Add(n)trước câu lệnhgo— không bao giờ gọi bên trong goroutine. - Đặt
defer wg.Done()là dòng đầu tiên bên trong mỗi goroutine, đúng một lần duy nhất. - Tham số hàm phải dùng
*sync.WaitGroup(con trỏ), không bao giờ dùngsync.WaitGroup(giá trị). - Không gọi
wg.Add()sau khiwg.Wait()bắt đầu mở khóa — hãy dùng WaitGroup mới thay thế. - Thêm
go test -race ./...vào CI để các race condition này được phát hiện tự động trên mỗi pull request.

