Модуль: 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()                  // блокируемся, пока счётчик != 0
  • Add(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 обычно это ловит.
  • Забыли DoneWait висит вечно (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-режиме).