sync.WaitGroup решает одну задачу: дождаться, пока группа горутин закончит работу. Внутри это счётчик на три метода. Почти все проблемы с ним растут из трёх вещей: из того, где именно вызван Add, из того, как вызывается Done, и из того, что структуру нельзя копировать. Разберём модель счётчика, устройство изнутри по исходникам, типовые ошибки с настоящим выводом программ и случаи, когда WaitGroup брать не стоит.
Счётчик и три метода
WaitGroup это счётчик незавершённых задач. У него три метода:
Add(delta int)меняет счётчик наdelta. Обычно на+1перед запуском горутины.Done()уменьшает счётчик на единицу.Wait()блокирует вызывающую горутину, пока счётчик не станет нулём.
Самый маленький работающий пример:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("горутина отработала")
}()
wg.Wait()
fmt.Println("main дождался")
}
Вывод всегда один и тот же:
$ go run main.go
горутина отработала
main дождался
Без WaitGroup порядок был бы непредсказуемым, а строка из горутины могла не напечататься вовсе. Программа на Go завершается, как только заканчивается main, и запущенных горутин она не ждёт. Подробнее про это в статье про горутины и каналы.
Нулевое значение WaitGroup готово к работе. Никакого конструктора нет, var wg sync.WaitGroup достаточно.
Базовый паттерн
Канонический код для N горутин выглядит так:
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // до go, а не внутри горутины
go func() {
defer wg.Done() // через defer, чтобы сработал при любом выходе
fmt.Printf("воркер %d отработал\n", i)
}()
}
wg.Wait()
Здесь три решения, и каждое неслучайно.
Add(1) стоит до go. Счётчик должен вырасти в той же горутине, которая потом вызовет Wait. Если перенести Add внутрь горутины, Wait может увидеть нулевой счётчик и вернуться раньше, чем горутины успеют себя зарегистрировать.
Done вызывается через defer. Тогда счётчик уменьшится при любом выходе из функции: обычный return, ранний return по ошибке, паника. Без defer любая новая ветка return в теле функции подвешивает Wait навсегда.
Счётчик увеличивается по одному в цикле. Можно и wg.Add(3) один раз до цикла, если количество известно заранее. Оба варианта корректны. Add(1) внутри цикла переживает рефакторинг лучше: если в цикл добавится continue, число запущенных горутин и счётчик не разъедутся.
Начиная с Go 1.22 переменная цикла for создаётся заново на каждой итерации, поэтому замыкание захватывает правильное i. На Go 1.21 и ниже нужно было передавать i аргументом или делать локальную копию. Правило привязано не к версии тулчейна, а к директиве go в go.mod: со строкой go 1.21 новый компилятор соберёт модуль по старым правилам.
Типовые ошибки
Здесь настоящий вывод программ, а не пересказ. Всё снято на Go 1.25.5, включая номера строк в трассировках.
Add внутри горутины
Самая частая ошибка. Выглядит логично: горутина сама себя регистрирует и сама себя разрегистрирует.
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
go func() {
wg.Add(1) // слишком поздно
defer wg.Done()
fmt.Println("воркер", i)
}()
}
wg.Wait()
fmt.Println("готово")
go func() только ставит горутину в очередь планировщика, выполняться она начнёт когда-то потом. Основная горутина успевает дойти до wg.Wait() раньше, чем хоть одна из пяти вызовет Add. Счётчик равен нулю, Wait возвращается немедленно.
Чаще всего программа печатает одну строку:
$ go run main.go
готово
Wait не дождался никого, процесс завершился раньше, чем хоть один воркер успел напечататься. Иногда, примерно в двух прогонах из десяти на моей машине, воркеры всё-таки проскакивают, и тогда вывод выглядит почти правильным:
$ go run main.go
воркер 1
воркер 2
воркер 3
воркер 0
воркер 4
готово
Это и есть худшее свойство ошибки. Она не даёт стабильного симптома, а даёт распределение. На своей машине вы увидите второй вариант и решите, что всё работает. Под нагрузкой получите первый и потеряете часть работы.
Важная деталь: на детектор гонок здесь полагаться нельзя. Формально рантайм эту ошибку моделирует: в Add на переходе счётчика с нуля стоит race.Read(unsafe.Pointer(&wg.sema)), а в Wait для первого засыпающего ожидающего race.Write(unsafe.Pointer(&wg.sema)). Оба обращения по одному адресу, синхронизации между ними нет, значит -race вправе выдать отчёт.
Но выдаст он его только если Wait действительно успел уснуть, то есть если хоть один Add его опередил. Я собрал пример выше с -race и прогнал 200 раз: гонка нашлась в 30 прогонах из 200. В остальных 170 детектор молчал, потому что Wait увидел нулевой счётчик и вернулся, не прикоснувшись к общей памяти.
Если тайминг сдвинуть в сторону «Add успевает первым», отчёт становится стабильным:
var wg sync.WaitGroup
go func() {
wg.Add(1) // всё ещё слишком поздно, но теперь успевает первым
defer wg.Done()
time.Sleep(200 * time.Millisecond)
fmt.Println("воркер отработал")
}()
time.Sleep(50 * time.Millisecond) // даём горутине дойти до Add
wg.Wait()
fmt.Println("готово")
Здесь Wait видит счётчик, равный единице, и засыпает. Синхронизации между Add в горутине и Wait в основной горутине по-прежнему нет, time.Sleep её не создаёт. На этом варианте гонка нашлась во всех 50 прогонах из 50:
$ go run -race main.go
==================
WARNING: DATA RACE
Write at 0x00c000190028 by main goroutine:
runtime.racewrite()
<autogenerated>:1 +0x1e
main.main()
/tmp/wg/main.go:18 +0xce
Previous read at 0x00c000190028 by goroutine 8:
runtime.raceread()
<autogenerated>:1 +0x1e
main.main.func1()
/tmp/wg/main.go:12 +0x3b
==================
воркер отработал
готово
Found 1 data race(s)
Вывод практический: зелёный прогон под -race про эту ошибку не говорит ничего. Красный говорит, но выпадает он редко и не там, где удобно.
Поэтому в Go 1.25 в go vet добавили отдельный анализатор waitgroup, который ищет этот паттерн статически, независимо от везения на конкретном запуске:
$ go vet ./...
# wgdemo
# [wgdemo]
./main.go:12:10: WaitGroup.Add called from inside new goroutine
Исправление одно: перенести wg.Add(1) на строку перед go.
Копирование WaitGroup по значению
WaitGroup нельзя копировать после первого использования. Копия получает собственный счётчик, и связь с оригиналом теряется. Ошибка легко проходит незамеченной, потому что копирование редко выглядит как копирование:
func run(wg sync.WaitGroup) { // принимаем по значению, а не по указателю
defer wg.Done()
fmt.Println("работаю")
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go run(wg)
wg.Wait()
fmt.Println("готово")
}
run уменьшает счётчик своей копии. Счётчик оригинала так и остаётся единицей:
$ go run main.go
работаю
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.WaitGroup.Wait]:
sync.runtime_SemacquireWaitGroup(0xc0000203c0?, 0x80?)
/usr/local/go/src/runtime/sema.go:114 +0x2e
sync.(*WaitGroup).Wait(0xc0000122d0)
/usr/local/go/src/sync/waitgroup.go:206 +0x85
main.main()
/tmp/wg/main.go:17 +0x76
exit status 2
Здесь повезло: горутина была ровно одна, и рантайм смог доказать, что программа встала намертво. В реальном сервисе, где живут таймеры и сетевые горутины, такого сообщения не будет. Вместо него вы получите один навсегда заблокированный Wait и медленно растущую утечку горутин.
Зато эта ошибка ловится статически. В структуру встроено поле noCopy, и go vet знает про него:
$ go vet ./...
# wgdemo
# [wgdemo]
./main.go:8:13: run passes lock by value: sync.WaitGroup contains sync.noCopy
./main.go:16:9: call of run copies lock value: sync.WaitGroup contains sync.noCopy
Тот же анализатор copylocks ловит и косвенное копирование, когда WaitGroup лежит полем внутри вашей структуры, а копируется уже структура целиком:
type job struct {
wg sync.WaitGroup
}
func main() {
var j job
j.wg.Add(1)
j2 := j // копируется вся структура вместе с WaitGroup
_ = j2
}
$ go vet ./...
# wgdemo
# [wgdemo]
./main.go:14:8: assignment copies lock value to j2: wgdemo.job contains sync.WaitGroup contains sync.noCopy
./main.go:15:6: assignment copies lock value to _: wgdemo.job contains sync.WaitGroup contains sync.noCopy
Правило простое: WaitGroup передаётся только указателем, *sync.WaitGroup. Если он лежит в структуре, эта структура тоже передаётся указателем.
Лишний Done
Каждый Done уменьшает счётчик на единицу. Если вызвать его больше раз, чем было Add, счётчик уходит в минус, и это паника, а не тихая ошибка:
var wg sync.WaitGroup
wg.Add(1)
wg.Done()
wg.Done() // на один больше
$ go run main.go
panic: sync: negative WaitGroup counter
goroutine 1 [running]:
sync.(*WaitGroup).Add(0xc000188000, 0xffffffffffffffff)
/usr/local/go/src/sync/waitgroup.go:118 +0x23a
sync.(*WaitGroup).Done(...)
/usr/local/go/src/sync/waitgroup.go:156
main.main()
/tmp/wg/main.go:9 +0x4d
exit status 2
Обратите внимание на стек. Done не имеет собственного тела и сразу проваливается в Add, а вторым аргументом там видно 0xffffffffffffffff, то есть -1. Это буквально Add(-1), и документация Go 1.25 теперь говорит это прямым текстом: It is equivalent to Add(-1).
Практический источник такой паники это defer wg.Done() внутри функции, которая и сама уже вызывает Done в какой-то ветке. Или, начиная с Go 1.25, лишний wg.Done() внутри функции, переданной в wg.Go.
Пропущенный Done
Зеркальная ошибка и куда более неприятная, потому что она не паникует. Add(1) без парного Done означает, что счётчик никогда не дойдёт до нуля, и Wait заблокируется навсегда.
wg.Add(1)
go func() {
if err := doWork(); err != nil {
return // Done не вызван
}
wg.Done()
}()
wg.Wait() // висит, если doWork вернул ошибку
Именно от этого спасает defer. Поставьте defer wg.Done() первой строкой горутины, и ни один return мимо него не пройдёт.
Переиспользование до возврата Wait
WaitGroup можно использовать повторно, но только после того, как предыдущий Wait полностью вернулся. Новые вызовы Add не должны накладываться на Wait, который ещё не вернулся. Чтобы это увидеть, нужен тесный цикл, где одна горутина закрывает партию и сразу открывает новую, пока другая ещё сидит в Wait:
for {
var wg sync.WaitGroup
wg.Add(1)
go func() {
wg.Wait() // ждёт партию
}()
go func() {
wg.Done() // закрывает партию
wg.Add(1) // и сразу открывает новую
wg.Done()
}()
}
Паника прилетает в первые же секунды:
$ go run main.go
panic: sync: WaitGroup is reused before previous Wait has returned
goroutine 8171 [running]:
sync.(*WaitGroup).Wait(0xc000644200)
/usr/local/go/src/sync/waitgroup.go:208 +0xf4
main.main.func1()
/tmp/wg/main.go:16 +0x17
created by main.main in goroutine 1
/tmp/wg/main.go:15 +0x116
Обратите внимание, что цикл здесь искусственный. Именно потому, что для срабатывания нужно попасть в узкое окно между пробуждением ожидающего и его возвратом из Wait, обычные тесты такую ошибку пропускают, а под нагрузкой она рано или поздно выпадает.
Проще не переиспользовать. Заводите новый WaitGroup на каждую партию задач, благо нулевое значение сразу готово к работе и ничего не стоит.
Add одновременно с Wait
Ещё одна паника из той же семьи, sync: WaitGroup misuse: Add called concurrently with Wait. Она срабатывает, когда счётчик поднимается с нуля в момент, когда кто-то уже ждёт. Окно ещё уже предыдущего, но и оно достижимо:
for {
var wg sync.WaitGroup
wg.Add(1)
go func() { wg.Wait() }()
go func() {
wg.Done() // счётчик в нуль при живом ожидающем
}()
go func() {
wg.Add(1) // пытаемся попасть ровно в зазор
wg.Done()
}()
}
$ go run main.go
panic: sync: WaitGroup misuse: Add called concurrently with Wait
goroutine 2472 [running]:
sync.(*WaitGroup).Add(0xc0003a3630, 0x1)
/usr/local/go/src/sync/waitgroup.go:121 +0x227
main.main.func3()
/tmp/wg/main.go:20 +0x25
created by main.main in goroutine 1
/tmp/wg/main.go:19 +0x1f
Почему зазор такой узкий, станет видно ниже, когда дойдём до кода Add. Лечится всё тем же правилом: все Add, поднимающие счётчик с нуля, должны быть сделаны до того, как кто-либо вызовет Wait.
Как найти зависший Wait
Две ошибки выше, пропущенный Done и копирование, дают один и тот же симптом: горутина висит в Wait навсегда. В долгоживущем сервисе рантайм про дедлок не сообщит, поэтому искать придётся самому.
Быстрый способ, если процесс уже завис: отправить ему SIGQUIT и получить дамп всех горутин со стеками.
$ kill -QUIT <pid>
Зависший Wait в дампе видно сразу по метке состояния:
goroutine 1 [sync.WaitGroup.Wait]:
Если в сервис уже подключён net/http/pprof, то же самое доступно без сигнала и без остановки процесса:
$ curl -s 'localhost:6060/debug/pprof/goroutine?debug=1' | head -20
Растущее со временем число горутин в этом профиле и есть утечка. Подробнее про профили в статье про pprof.
В тестах ту же проверку удобно автоматизировать через go.uber.org/goleak: он сравнивает набор горутин до и после теста и валит его, если что-то осталось висеть. Это единственный из перечисленных способов, который ловит ошибку до продакшена. Про организацию тестов есть отдельная статья.
Как WaitGroup устроен внутри
В структуре всего три поля:
type WaitGroup struct {
noCopy noCopy
state atomic.Uint64
sema uint32
}
noCopy это пустая структура, она не хранит данных и не влияет на размер. Весь смысл в двух её методах:
type noCopy struct{}
// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
Методы ничего не делают. Они нужны только чтобы тип выглядел для go vet как мьютекс, и анализатор copylocks начал ругаться на копирование. Ровно отсюда берётся диагностика contains sync.noCopy из примера выше.
state это упакованное состояние. sema это счётчик семафора рантайма, на котором засыпают ожидающие горутины; в рантайм передаётся его адрес, &wg.sema.
Одно слово вместо трёх полей
Самое интересное в state. Раскладку описывает комментарий прямо над полем:
// Bits (high to low):
// bits[0:32] counter
// bits[32] flag: synctest bubble membership
// bits[33:64] wait count
Нумерация идёт от старших битов к младшим, так что bits[0:32] это старшая половина слова. В ней счётчик задач. Дальше один бит под флаг и 31 бит под число горутин, сидящих в Wait. Сходится с константами: waitGroupBubbleFlag равен 0x8000_0000, а Add и Wait читают ожидающих как uint32(state & 0x7fffffff).
Флаг помечает принадлежность WaitGroup к synctest-пузырю. Это инфраструктура для тестов с виртуальным временем, в обычном коде она не видна. Из-за неё в Add появились две проверки, которые срабатывают через fatal, а не через panic:
sync: WaitGroup.Add called from multiple synctest bubbles
sync: WaitGroup.Add called from inside and outside synctest bubble
Разница существенная: fatal роняет процесс сразу и его нельзя перехватить через recover, в отличие от трёх обычных паник, которые мы разобрали выше.
Опираться на конкретные биты в своём коде, разумеется, нельзя, поле неэкспортируемое.
Зачем вообще упаковка? Затем, что счётчик и число ожидающих нужно читать и менять согласованно. Add должен атомарно узнать, обнулился ли счётчик и есть ли кого будить. Будь это два отдельных поля, между чтением первого и второго вклинилась бы другая горутина, и понадобился бы мьютекс. Одно слово позволяет обойтись единственной атомарной операцией и делает быстрый путь свободным от блокировок.
Что делает Add
Всё тело Add строится вокруг одной атомарной операции. Дальше код из исходников, из него выброшены блоки if race.Enabled и вся возня с synctest-пузырём, комментарии переведены:
state := wg.state.Add(uint64(delta) << 32)
v := int32(state >> 32) // счётчик задач
w := uint32(state & 0x7fffffff) // число ожидающих
if v < 0 {
panic("sync: negative WaitGroup counter")
}
if w != 0 && delta > 0 && v == int32(delta) {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
if v > 0 || w == 0 {
return
}
// сюда попали при v == 0 и w > 0
if wg.state.Load() != state {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
wg.state.Store(0)
for ; w != 0; w-- {
runtime_Semrelease(&wg.sema, false, 0)
}
Сдвиг на 32 бита кладёт delta в половину слова, отведённую под счётчик. Одна атомарная операция, и мы сразу держим в руках новое состояние целиком.
Если после уменьшения счётчик остался положительным или ожидающих нет, Add возвращается на третьей проверке. Это быстрый путь. И только когда счётчик дошёл до нуля при живых ожидающих, Add обнуляет состояние и будит всех через runtime_Semrelease в цикле.
Ровно один раз в этом коде видно, почему паника Add called concurrently with Wait так неуловима. Ей нужны одновременно нулевой счётчик и живые ожидающие, то есть v == 0 && w > 0. Но Wait регистрируется в ожидающих только при ненулевом счётчике, а тот Add, который обнуляет счётчик, тут же сбрасывает состояние в нуль и обнуляет w вместе с ним. Значит, окно, где комбинация вообще существует, это несколько инструкций внутри самого Add, между атомарным уменьшением и wg.state.Store(0). Обе проверки сторожат ровно этот зазор: первая ловит чужой Add, поднявший счётчик с нуля, вторая замечает, что состояние изменилось под ногами. Этим же объясняется, почему цикл из примера выше приходится крутить: попасть в зазор можно только случайно.
Полное тело Add длиннее приведённого. В начале стоит synctest.IsInBubble(), и при положительном ответе WaitGroup ассоциируется с пузырём. Вне тестов с виртуальным временем это одна проверка, которая почти всегда возвращает false, так что на быстрый путь она влияет мало.
Что делает Wait
Wait крутит цикл с CAS. Снова без блоков if race.Enabled и без synctest:
for {
state := wg.state.Load()
v := int32(state >> 32) // счётчик задач
w := uint32(state & 0x7fffffff) // число ожидающих
if v == 0 {
return // счётчик уже нулевой, ждать нечего
}
if wg.state.CompareAndSwap(state, state+1) {
runtime_SemacquireWaitGroup(&wg.sema, false)
if wg.state.Load() != 0 {
panic("sync: WaitGroup is reused before previous Wait has returned")
}
return
}
}
Инкремент на единицу без сдвига увеличивает младшую половину слова, то есть счётчик ожидающих. Он же объясняет, почему Wait можно спокойно звать из нескольких горутин сразу: каждая прибавляет к w свою единицу, а Add при обнулении счётчика будит их всех циклом по w. Сам цикл с CAS нужен потому, что обмен может не пройти: пока мы читали состояние, кто-то успел вызвать Add или другой Wait. Тогда пробуем снова с новым значением.
Заметьте, что Wait при нулевом счётчике вообще не трогает семафор и возвращается сразу. Именно поэтому ошибка с Add внутри горутины такая тихая: с точки зрения Wait это совершенно легальная ситуация.
Проверка после пробуждения wg.state.Load() != 0 и есть та самая защита от переиспользования. Горутину разбудили, состояние обязано быть нулевым. Если кто-то успел позвать Add, будет паника.
Почему Add обязан быть до go
Теперь понятно, откуда берётся главное правило. Документация к Add формулирует его так: вызовы с положительным delta, происходящие при нулевом счётчике, должны случиться до Wait. Вызовы с отрицательным delta или те, что стартуют при счётчике больше нуля, могут происходить когда угодно.
То есть ограничение касается только перехода счётчика с нуля вверх. Как только счётчик положительный, добавлять задачи из уже работающих горутин безопасно: они гарантированно успевают до того, как счётчик обнулится.
В терминах модели памяти Go Done синхронизирован раньше, чем возврат из Wait, который он разблокирует. Это то, что делает WaitGroup не просто счётчиком, а точкой синхронизации: после Wait вы гарантированно видите всю память, которую записали горутины до своих Done. Отдельный мьютекс для чтения результатов не нужен.
Метод Go из Go 1.25
В Go 1.25 у WaitGroup появился метод Go, который берёт на себя Add и Done:
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Go(func() {
fmt.Println("воркер", i)
})
}
wg.Wait()
Ошибку с Add внутри горутины он делает невозможной по построению. Никакой магии внутри при этом нет, реализация целиком укладывается в шесть строк:
func (wg *WaitGroup) Go(f func()) {
wg.Add(1)
go func() {
defer wg.Done()
f()
}()
}
Это ровно тот же канонический паттерн, только записанный один раз в стандартной библиотеке вместо каждого места вызова. Обработки паники здесь нет и не предполагается, документация к методу говорит об этом прямо: The function f must not panic. Если f паникует, процесс падает так же, как упал бы с рукописным defer wg.Done().
Стоит отметить и то, чего в реализации не видно. В Go 1.25 в документацию Add и Done добавили строку Callers should prefer [WaitGroup.Go]. Ручная пара Add и Done формально не устарела, но стандартная библиотека теперь прямо советует её не писать. Для нового кода это и есть ответ на вопрос, какой вариант выбирать.
При этом Go не заменяет errgroup. Он снимает boilerplate и один класс ошибок, но не даёт ни сбора ошибок, ни отмены, ни ограничения параллелизма, ни защиты от паники в задаче.
Разбор остальных изменений 1.25 и история вопроса в отдельной статье.
Когда WaitGroup не нужен
WaitGroup отвечает ровно на один вопрос: «все закончили?». Как только задача чуть шире, появляются варианты лучше.
| Задача | Инструмент |
|---|---|
| Дождаться N горутин, результат не важен | sync.WaitGroup |
| Собрать N результатов и дождаться всех | WaitGroup плюс слайс |
| Дождаться одну горутину | канал |
| Собрать ошибки, отменить остальных | errgroup.Group |
| Ограничить параллелизм | (*errgroup.Group).SetLimit или семафор |
| Дождаться с таймаутом | WaitGroup плюс select |
| Собрать результаты по мере готовности | канал |
Нужны результаты, но все сразу. Здесь WaitGroup менять не на что, вопреки частому заблуждению, что он «не умеет возвращать значения». Заведите слайс нужной длины заранее и пишите в свою ячейку:
results := make([]int, len(items))
var wg sync.WaitGroup
for i, item := range items {
wg.Add(1)
go func() {
defer wg.Done()
results[i] = process(item) // каждая горутина пишет в свой индекс
}()
}
wg.Wait()
// здесь results заполнен целиком
Мьютекс не нужен и гонки нет: горутины пишут в непересекающиеся элементы, а Wait даёт нужный барьер видимости. Важно только не трогать append и не менять длину слайса из горутин.
Одна горутина. Заводить счётчик ради единицы избыточно. Канал короче и заодно передаёт результат:
done := make(chan int, 1)
go func() { done <- compute() }()
r := <-done
Нужны ошибки. WaitGroup ничего не знает про возвращаемые значения. Собирать ошибки руками через слайс с мьютексом можно, но это ровно то, что уже сделано в errgroup. Он же отменяет контекст при первой ошибке и умеет SetLimit. Подробности в статье про errgroup.
Нужен таймаут. У Wait нет варианта с контекстом, он блокирует безусловно. Стандартный обход такой. Увести Wait в отдельную горутину и ждать на select:
func waitCtx(ctx context.Context, wg *sync.WaitGroup) error {
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
return nil // все горутины завершились
case <-ctx.Done():
return ctx.Err() // вышли по таймауту или отмене
}
}
Важно понимать, что это таймаут на ожидание, а не на сами горутины. Они продолжат работать. Чтобы действительно их остановить, нужен context.Context внутри самих горутин, про это есть отдельный разбор.
Нужны результаты по мере готовности. WaitGroup даёт одну точку синхронизации в конце. Если результаты нужно обрабатывать потоком, берите канал и паттерн fan-in из статьи про паттерны конкурентности.
Итог
WaitGroup это счётчик в одном атомарном слове плюс семафор рантайма. Быстрый путь Add и Done это одна атомарная операция. Никаких блокировок.
Практически все ошибки сводятся к трём правилам. Add вызывается до go и только в той горутине, которая потом ждёт. Done ставится через defer, чтобы пережить ранние return и паники. Структура передаётся указателем и никогда не копируется.
Первые два правила в новом коде можно не помнить вовсе: wg.Go соблюдает их за вас, и документация Add и Done теперь прямо советует предпочитать его.
Два из трёх нарушений ловятся статически: go vet находит копирование через copylocks, а начиная с Go 1.25 ещё и Add внутри горутины. Прогоняйте go vet в CI. На детектор гонок в этом вопросе не рассчитывайте: на каноническом примере он нашёл гонку в 30 прогонах из 200, то есть зелёный прогон ничего не значит.
Когда задача перестаёт быть «дождаться всех» и превращается в «собрать ошибки», «ограничить параллелизм» или «отменить по таймауту», переходите на errgroup. Он решает эти задачи, а WaitGroup в нём всё равно внутри.
FAQ
Ловит ли детектор гонок ошибку с Add внутри горутины?
Add внутри горутины 200 раз под -race: гонка нашлась в 30 прогонах, в остальных 170 детектор молчал. Молчит он тогда, когда Wait успевает увидеть нулевой счётчик и вернуться, не прикоснувшись к общей памяти. Ругается тогда, когда Wait успевает уснуть: рантайм специально моделирует первый Add как чтение, а первое засыпание в Wait как запись по адресу &wg.sema. Практический вывод: зелёный прогон под -race ничего не доказывает. Статически ошибку ловит анализатор waitgroup из go vet, начиная с Go 1.25.Что будет, если горутина под WaitGroup запаникует?
WaitGroup тут ничем не помогает, и метод wg.Go из Go 1.25 тоже: его документация прямо требует, чтобы функция не паниковала. defer wg.Done() при этом отработает, счётчик уменьшится, но выигрыш только в том, что Wait не зависнет, если панику кто-то перехватит выше по стеку. Нужна настоящая изоляция задач, берите errgroup и recover внутри задачи.Можно ли вызывать Wait из нескольких горутин?
Add, обнуливший счётчик задач, будит их всех циклом по этому числу. Ограничение ровно одно и то же: пока хоть один Wait не вернулся, новых Add с нуля быть не должно.Как дождаться WaitGroup с таймаутом?
Wait нет варианта с контекстом. Уведите Wait в отдельную горутину, закройте канал по завершении и ждите на select вместе с ctx.Done(). Помните, что это таймаут только на ожидание: сами горутины продолжат работать, если внутри них нет проверки контекста.Сколько стоит WaitGroup по производительности?
Практически ничего. Пара Add и Done на моей машине укладывается в 11 наносекунд без единой аллокации, а Wait на нулевом счётчике в 2 наносекунды:
$ go test -bench=. -benchmem -run=^$ .
BenchmarkAddDone-12 100000000 10.72 ns/op 0 B/op 0 allocs/op
BenchmarkWaitZero-12 503349948 2.133 ns/op 0 B/op 0 allocs/op
BenchmarkGoroutineAndWait-12 1483927 856.1 ns/op 32 B/op 2 allocs/op
BenchmarkWgGo-12 1386182 916.2 ns/op 40 B/op 2 allocs/op
Последние два это полный цикл: создать WaitGroup, запустить горутину, дождаться её. Почти всё время здесь уходит на саму горутину и планировщик, доля WaitGroup около процента. wg.Go чуть дороже рукописного варианта: замыкание передаётся аргументом, отсюда лишние 8 байт. Оптимизировать тут нечего.
Теги: