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

• обновлено • 7 мин чтения

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

Эта статья про то, что изменилось именно в 1.25. Если нужна база, то есть счётчик, Add/Done/Wait, устройство изнутри и остальные типовые ошибки, она в полном разборе sync.WaitGroup.

Проблема: 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. Это ровно тот же паттерн, который раньше писался руками в каждом месте вызова, только теперь он записан один раз в стандартной библиотеке. Никакой дополнительной обработки паники внутри нет: документация к методу говорит прямо, The function f must not panic. Если f запаникует, процесс упадёт так же, как упал бы с рукописной обёрткой.

Два подводных камня 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. В Go 1.26 и 1.27 их тоже не добавили, так что помнить об этом придётся самостоятельно.

Когда 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 (полный разбор в статье про errgroup).

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

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

Актуально на Go 1.27

С момента выхода 1.25 сам API sync.WaitGroup не менялся. В Go 1.26 и Go 1.27 у типа нет ни новых методов, ни изменений в семантике. Всё, что описано выше, работает так же. Поменялись инструменты вокруг.

go fix умеет мигрировать код за вас

В Go 1.26 команду go fix переписали. Теперь это набор модернизаторов, которые приводят код к актуальным идиомам. Один из них находит старый паттерн с Add/Done и переписывает его на wg.Go. В 1.26 он назывался waitgroup, в Go 1.27 его переименовали в waitgroupgo:

go fix ./...

Было:

wg.Add(1)
go func() {
    defer wg.Done()
    process(item)
}()

Стало:

wg.Go(func() {
    process(item)
})

Полезно, если у вас большая кодовая база в старом стиле. Ручная миграция не нужна, но перед коммитом просмотрите изменения. Во-первых, go fix ./... прогоняет сразу все модернизаторы, а не только этот. В примере выше он заодно переписал for i := 0; i < 5; i++ на for i := range 5. Чтобы запустить только нужный, укажите его явно:

go fix -waitgroupgo ./...

Во-вторых, модернизатор трогает только те места, где паттерн распознан однозначно. Код с Add внутри горутины он не чинит: это не устаревшая идиома, а баг. Такое по-прежнему находит только go vet.

Не путайте два разных waitgroup

Имя waitgroup носили сразу два инструмента:

ИнструментЧто делаетИмя до 1.27Имя с 1.27
go vetНаходит Add внутри горутины и ругаетсяwaitgroupwaitgroup
go fixПереписывает Add/Done на wg.Gowaitgroupwaitgroupgo

В Go 1.27 переименовали именно модернизатор go fix, чтобы убрать двусмысленность. Анализатор go vet имя сохранил. Проверить легко: на 1.27 go vet -waitgroup ./... работает, а go vet -waitgroupgo ./... падает с flag provided but not defined. У go fix ровно наоборот. На запуск без флагов (go vet ./..., go fix ./...) переименование не влияет, обе проверки входят в наборы по умолчанию.

Профиль утечки горутин

В Go 1.26 появился экспериментальный профиль goroutineleak, а в Go 1.27 он стал общедоступным. Отдельный GOEXPERIMENT больше не нужен: профиль есть в runtime/pprof и на эндпоинте /debug/pprof/goroutineleak. Он ищет горутины, которые залипли навсегда на каналах, sync.Mutex, sync.Cond и других примитивах синхронизации. Это соседний инструмент против той же категории багов. go vet ловит ошибку статически, профиль показывает уже случившееся зависание в работающем процессе. Если у вас Wait, который никогда не возвращается, начинать стоит с него.

Заключение

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 vet проверяет конкретный паттерн: вызов WaitGroup.Add внутри горутины. Паттерн go wg.Go(...) формально не содержит явного Add в горутине, потому что Add вызывается внутри метода Go. Статический анализатор не разворачивает вызовы методов до их реализации.

Об авторе: Александр Бруяко — руководитель разработки с опытом в backend, инфраструктуре и управлении командами.


Теги: