sync.WaitGroup в Go 1.25: новый метод Go() и защита от типичных ошибок

В Go 1.25 у sync.WaitGroup два изменения. go vet теперь обнаруживает вызов Add внутри горутины, а новый метод WaitGroup.Go берёт на себя boilerplate с Add/Done и убирает саму возможность этой ошибки.

Проблема: Add внутри горутины

Допустим, нужно запустить несколько горутин и дождаться их завершения:

var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    go func() {
        wg.Add(1)
        defer wg.Done()
        fmt.Printf("Воркер %d: работает...\n", i)
        time.Sleep(200 * time.Millisecond)
        fmt.Printf("Воркер %d: завершён.\n", i)
    }()
}
wg.Wait()

Выглядит нормально: добавляем задачу, отмечаем завершение через defer. Но здесь гонка. wg.Add(1) вызывается внутри горутины, а горутина может не успеть запуститься до того, как основной поток дойдёт до wg.Wait(). Если счётчик WaitGroup в этот момент равен нулю, Wait вернётся немедленно. Программа завершится, не дожидаясь горутин.

Баг воспроизводится нестабильно. Локально может работать. На CI при параллельном запуске тестов упадёт. В продакшене под нагрузкой потеряете данные.

Issue #18022 с описанием этой проблемы был открыт ещё в 2016 году. Joe Tsai описал ровно этот паттерн и предложил добавить метод Go, который делает Add и Done правильно. Спустя почти девять лет оба предложения из того issue реализованы в Go 1.25.

go vet теперь ловит эту ошибку

В Go 1.24 go vet не обнаруживал вызов Add внутри горутины. В Go 1.25 появился анализатор waitgroup, который это проверяет:

$ go vet ./...
./main.go:13:10: WaitGroup.Add called from inside new goroutine

Исправление: перенести wg.Add(1) до запуска горутины.

var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        fmt.Printf("Воркер %d: работает...\n", i)
        time.Sleep(200 * time.Millisecond)
        fmt.Printf("Воркер %d: завершён.\n", i)
    }()
}
wg.Wait()

Теперь Add вызывается в основном потоке, до go func(). К моменту Wait счётчик гарантированно равен 5, и Wait будет ждать все горутины.

Новый метод WaitGroup.Go

Второе изменение в Go 1.25: метод Go у sync.WaitGroup. Принимает функцию, сам вызывает Add(1), запускает горутину, вызывает Done по завершении:

var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    wg.Go(func() {
        fmt.Printf("Воркер %d: работает...\n", i)
        time.Sleep(200 * time.Millisecond)
        fmt.Printf("Воркер %d: завершён.\n", i)
    })
}
wg.Wait()

Реализация в стандартной библиотеке:

func (wg *WaitGroup) Go(f func()) {
    wg.Add(1)
    go func() {
        defer wg.Done()
        f()
    }()
}

Тот же паттерн, который раньше писался руками. Разница в том, что порядок вызовов зашит в реализацию метода: Add до go, Done через defer.

Два подводных камня wg.Go

wg.Go управляет и горутиной, и счётчиком. Две вещи, которые раньше делал разработчик, теперь делать не нужно.

Не добавляйте ключевое слово go

wg.Go сам запускает горутину. Если написать go wg.Go(func() { ... }), получится та же гонка: wg.Add(1) окажется внутри горутины.

// Неправильно: Add снова внутри горутины
go wg.Go(func() {
    // ...
})

Не вызывайте wg.Done внутри функции

wg.Go уже вызывает Done после завершения переданной функции. Лишний wg.Done() внутри уменьшит счётчик дважды. В лучшем случае паника (negative WaitGroup counter). В худшем Wait вернётся раньше, чем все горутины завершат работу.

// Неправильно: Done вызовется дважды
wg.Go(func() {
    defer wg.Done() // лишний вызов
    fmt.Println("работаю")
})

go vet в Go 1.25 не обнаруживает ни одну из этих ошибок. Ни go wg.Go(...), ни лишний wg.Done() внутри wg.Go. Возможно, проверки добавят в следующих версиях, но пока стоит помнить об этом самостоятельно.

Когда wg.Go удобен, а когда нет

wg.Go подходит для типичного сценария: запустить N горутин и дождаться завершения.

Но метод принимает func() без параметров и без возвращаемого значения. Если нужно собирать ошибки из горутин, wg.Go не поможет. Для таких случаев есть errgroup.Group из golang.org/x/sync/errgroup:

g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
    g.Go(func() error {
        return fetch(ctx, url)
    })
}
if err := g.Wait(); err != nil {
    // обрабатываем первую ошибку
}

У errgroup.Group тоже есть метод Go, но его функция возвращает error. Плюс errgroup отменяет контекст при первой ошибке и поддерживает ограничение параллелизма через SetLimit.

Критерийsync.WaitGroup.Goerrgroup.Group.Go
Сигнатура функцииfunc()func() error
Сбор ошибокНетДа (первая ошибка)
Отмена по контекстуНетДа
Ограничение параллелизмаНетДа (SetLimit)
ЗависимостиСтандартная библиотекаgolang.org/x/sync

Если горутины не возвращают ошибок и не нужна отмена, wg.Go проще и не тянет внешних зависимостей.

Заключение

go vet в Go 1.25 ловит wg.Add внутри горутины. Метод wg.Go делает ручной Add/Done ненужным. Если обновляетесь до 1.25, прогоните go vet по проекту. Вполне может найти баги, которые до сих пор не проявлялись.

Связанные статьи:

FAQ

Нужно ли менять существующий код с Add/Done на wg.Go?
Не обязательно. Если Add вызывается до запуска горутины и Done через defer, код корректен. wg.Go удобнее, но это не обязательная миграция. Имеет смысл переходить при написании нового кода или при рефакторинге.
wg.Go потокобезопасен?
Да. sync.WaitGroup потокобезопасен, wg.Go можно вызывать из нескольких горутин одновременно. Внутри Add использует атомарные операции.
Как передать аргументы в функцию, если wg.Go принимает func()?

Через замыкание:

for i := 0; i < 5; i++ {
    wg.Go(func() {
        // i захвачена замыканием
        fmt.Println(i)
    })
}

Начиная с Go 1.22, переменная цикла for создаётся заново на каждой итерации, поэтому проблемы с захватом устаревшего значения больше нет. Если вы на Go 1.21 и ниже, нужно явно копировать переменную:

for i := 0; i < 5; i++ {
    i := i // копия для замыкания
    wg.Go(func() {
        fmt.Println(i)
    })
}
Почему go vet не ловит go wg.Go(…)?
Анализатор waitgroup в Go 1.25 проверяет конкретный паттерн: вызов WaitGroup.Add внутри горутины. Паттерн go wg.Go(...) формально не содержит явного Add в горутине, потому что Add вызывается внутри метода Go. Статический анализатор не разворачивает вызовы методов до их реализации.

Теги: