Модуль: Concurrency · Уровень: Middle+/Senior
TL;DR#
WaitGroup — счётчик незавершённой работы: Add(n) увеличивает, Done уменьшает, Wait блокирует, пока счётчик не дойдёт до нуля. Главное правило: Add вызывать до запуска горутины (в той же горутине, что и Wait), а не внутри неё, иначе возможна гонка между Wait и стартом. Go 1.25 добавил WaitGroup.Go, который атомарно делает Add(1) + запуск + Done, устраняя самую частую ошибку.
Теория#
Контракт#
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1) // ДО go
go func(t Task) {
defer wg.Done() // гарантированно даже при панике
process(t)
}(task)
}
wg.Wait() // блокируемся, пока счётчик != 0Add(delta)— атомарно прибавляет delta (может быть отрицательным) к счётчику.Done()— этоAdd(-1).Wait()— блокирует, пока счётчик не станет 0. Если уже 0 — возвращается сразу.- Если счётчик становится отрицательным → паника
sync: negative WaitGroup counter.
Устройство под капотом#
До Go 1.20 WaitGroup хранил счётчик и число ожидающих в одном 64-битном слове (с трюками выравнивания для 32-бит платформ). С Go 1.25 (рефакторинг под WaitGroup.Go) внутреннее представление изменилось, но логически — это:
type WaitGroup struct {
state atomic.Uint64 // старшие 32 бита: counter, младшие 32: waiters
sema uint32 // семафор для парковки ожидающих в Wait
}Addатомарно меняет старшую половину (counter).- Когда counter доходит до 0 и есть waiters>0,
Addбудит всех ожидающих черезruntime_Semreleaseпоsema. Waitатомарно инкрементит waiters и паркуется на семафоре (runtime_Semacquire).
Гонка возникает, если Add для следующей порции делается после того, как counter уже упал до 0 и Wait проснулся — поведение становится неопределённым.
Go 1.25: WaitGroup.Go#
var wg sync.WaitGroup
for _, task := range tasks {
wg.Go(func() { // Add(1) + go + Done() внутри
process(task) // loopvar в 1.22+ безопасен
})
}
wg.Wait()Go инкапсулирует Add(1), запуск горутины и defer Done(). Это убирает целый класс ошибок: забытый Done, Add в неправильном месте, рассинхрон Add/Done. Совмещён с loopvar semantics (Go 1.22, каждая итерация — своя переменная), поэтому замыкание над task безопасно.
Переиспользование#
WaitGroup можно использовать повторно: после того как Wait вернулся (counter=0), можно снова делать Add. Но нельзя вызывать Add с положительным delta одновременно с активным Wait, который ещё может ждать предыдущую волну. Безопасный паттерн — волнами: Add → go → Wait, потом следующая волна.
Подводные камни / gotchas#
Add(1)внутри горутины — классическая гонка:// НЕПРАВИЛЬНО for _, t := range tasks { go func(t Task) { wg.Add(1) // может выполниться ПОСЛЕ wg.Wait() defer wg.Done() process(t) }(t) } wg.Wait() // может увидеть счётчик 0 и выйти раньше времениWaitможет вернуться до того, как горутины успеют сделать Add → программа решит, что всё готово, хотя работа не началась.-raceобычно это ловит.- Забыли
Done→Waitвисит вечно (deadlock/leak). Всегдаdefer wg.Done()первой строкой. - Лишний
Doneили Add с большим отрицательным delta → паника negative counter. - Копирование WaitGroup после использования запрещено (
copylocks/go vet). Передавайте по указателю. - Сбор результатов: WaitGroup только синхронизирует завершение, не передаёт данные. Для результатов нужен канал/слайс с индексами/
errgroup. Запись разных горутин в разные индексы слайса безопасна без мьютекса (разные ячейки), ноappendиз нескольких горутин — гонка. - WaitGroup не отменяет работу. Для отмены нужен
context. Для «дождаться + собрать ошибку + отменить остальных» используйтеerrgroup.Group. Waitбез последующего использования результата — не гарантирует видимость записей, сделанных в горутинах? Гарантирует:Done(release) happens-before возвратWait(acquire), поэтому записи до Done видны после Wait.
Вопросы на собеседовании#
В: Почему Add нужно вызывать до go, а не внутри горутины?
О: Wait смотрит на счётчик. Если Add делается внутри горутины, планировщик может ещё не запустить её к моменту Wait — счётчик будет 0, Wait вернётся преждевременно. Это гонка, ловится -race. Add должен выполниться в горутине, вызывающей Wait, до старта рабочих.
В: Что происходит при отрицательном счётчике?
О: Паника sync: negative WaitGroup counter. Бывает при лишнем Done или несбалансированных Add/Done. Паника осознанная — это симптом сломанной синхронизации.
В: Гарантирует ли WaitGroup видимость памяти?
О: Да. Done (как Add) образует release, возврат из Wait — acquire. Записи, сделанные горутиной до Done, видны коду после Wait (happens-before). Поэтому можно безопасно читать результаты, записанные горутинами, после Wait.
В: Можно ли переиспользовать WaitGroup?
О: Да, после возврата Wait (счётчик 0) можно снова Add. Но нельзя добавлять положительный delta параллельно с Wait, который ещё ждёт текущую волну. Работайте волнами Add→Wait.
В: Что даёт WaitGroup.Go в Go 1.25?
О: Атомарно делает Add(1), запускает переданную функцию в горутине и гарантирует Done через defer. Устраняет частые ошибки: забытый Done, Add не в том месте. Хорошо сочетается с per-iteration loopvar (1.22).
В: Как собрать результаты/ошибки горутин?
О: WaitGroup не передаёт данные. Пишите в предвыделенный слайс по индексам (разные ячейки — без гонки) или в канал. Для ошибок и отмены — golang.org/x/sync/errgroup.
В: В чём разница WaitGroup и закрытия канала для сигнала завершения?
О: WaitGroup — «дождаться N завершений». Закрытие канала — «broadcast одного события всем читателям». WaitGroup считает работу, канал сигнализирует. Часто комбинируются: горутины ждут <-done, основной ждёт wg.Wait().
В: Почему нельзя копировать WaitGroup?
О: Внутри атомарное слово состояния и семафор. Копия — это второй независимый счётчик; синхронизация рассинхронизируется. go vet ловит копирование.
На что копают на senior+#
- Внутреннее представление: упаковка counter+waiters в одно атомарное слово, выравнивание для 32-бит ARM/x86 (исторический
state1 [3]uint32). - Happens-before гарантии WaitGroup и где они в memory model Go.
- Почему
WaitGroup.Goпоявился именно с loopvar-семантикой и как он взаимодействует с panic propagation. - Сравнение с
errgroup: лимит параллелизма (SetLimit), первый ошибочный результат, отмена контекста. - Гонка Add-после-Wait: как именно она детектируется в
-race(отслеживание состояния WaitGroup рантаймом в race-режиме).