Sửa lỗi 'panic: sync: negative WaitGroup counter' Khi Quản Lý Goroutine trong Go

intermediate🔷 Go2026-04-28| Go 1.13+, Linux / macOS / Windows, mọi chương trình dùng sync.WaitGroup với goroutine

Error Message

panic: sync: negative WaitGroup counter
#go#goroutine#sync#waitgroup#panic

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ơn Add() — 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ạy Done() trước khi Add() kịp thực thi.
  • Tái sử dụng WaitGroup trong khi Wait() vẫn đang hoạt động — một lệnh Add() 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ệnh go — không bao giờ gọi bên trong goroutine.
  • Đặt defer wg.Done()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ùng sync.WaitGroup (giá trị).
  • Không gọi wg.Add() sau khi wg.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.

Related Error Notes