Объясните, почему положительный Add у WaitGroup выполняют до запуска горутины при параллельном вызове Wait?

Объясните, почему положительный Add у WaitGroup выполняют до запуска горутины при параллельном вызове Wait?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Положительный вызов Add должен произойти до запуска горутины и до потенциального вызова Wait, чтобы счётчик задач уже учитывал эту горутину. Иначе Wait может увидеть нулевой счётчик, немедленно завершиться, а работа ещё не начнётся или не будет завершена.

Исторический контекст

В конкурентных программах часто требуется дождаться завершения группы независимых горутин. sync.WaitGroup предоставляет простой счётчик: запуск работы увеличивает его, завершение уменьшает, а Wait блокируется, пока значение не станет нулём.

Такой подход отделяет ожидание завершения от передачи данных через каналы. Каналы подходят для обмена значениями и сигналами, а WaitGroup — для координации жизненного цикла задач.

Постановка проблемы

Если вызов Add(1) находится внутри запускаемой горутины, планировщик может сначала предоставить выполнение вызывающей горутине. Она вызовет Wait, увидит счётчик равным нулю и сразу продолжит работу.

В результате вызывающий код может закрыть результаты, освободить ресурс или завершить этап обработки до того, как рабочая горутина успеет зарегистрироваться и выполнить задачу. Это логическая ошибка синхронизации; она может проявляться непредсказуемо в зависимости от планирования.

Подробное решение

Сначала вызывающий код увеличивает счётчик, затем запускает горутину. Рабочая горутина обязательно вызывает Done, обычно через defer, чтобы уменьшение счётчика произошло и при обычном выходе из функции.

package main import ( "fmt" "sync" ) func main() { var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() fmt.Println("работа завершена") }() wg.Wait() }

Здесь Wait не может завершиться до регистрации задачи: к моменту его вызова счётчик уже положителен. После выполнения Done счётчик становится нулём, и ожидание разблокируется.

Положительный Add при нулевом счётчике должен выполняться до Wait. Положительные добавления при уже ненулевом счётчике допустимы, но их жизненный цикл должен быть организован так, чтобы счётчик не успел стать нулём до регистрации новых задач.

WaitGroup не передаёт результаты, не отменяет работу и не ограничивает число горутин. Для отмены применяют, например, context.Context, для передачи данных — каналы, а для ограничения параллелизма — семафор или пул работников.

Нельзя копировать используемый WaitGroup: копии имеют независимые внутренние состояния и нарушают синхронизацию. Также лишний Done приводит к отрицательному счётчику и панике.

Ситуация из практики

HTTP-сервис запускает несколько горутин для параллельного чтения данных из разных источников и должен отправить ответ только после завершения всех чтений. Вариант с вызовом Add внутри каждой горутины проще визуально, но создаёт окно, в котором обработчик может вызвать Wait раньше регистрации задач.

Вариант с отдельным каналом завершения для каждого источника позволяет явно передавать сигналы, но требует дополнительного объединения сигналов и легко приводит к забытым чтениям или закрытиям каналов. Вариант с последовательным выполнением устраняет проблему синхронизации, но теряет параллелизм.

Выбранный вариант — один WaitGroup: обработчик заранее вызывает Add с количеством источников, затем запускает горутины, а каждая из них вызывает Done через defer. После Wait результаты безопасно собираются, потому что все зарегистрированные работы завершены; при этом отмена запроса отдельно передаётся через контекст.

Что кандидаты часто упускают

  1. Можно ли вызывать Add внутри горутины, если WaitGroup уже имеет положительный счётчик?

    Технически положительное добавление допустимо, пока счётчик гарантированно остаётся положительным и ожидающий код ещё не может увидеть завершение всей группы. Например, родительская задача может удерживать одну единицу счётчика, пока регистрирует дочерние задачи.

    Однако такой шаблон сложнее доказать и поддерживать. Если счётчик успеет стать нулём одновременно с добавлением, нарушается требование синхронизации; поэтому для обычного запуска фиксированной группы задач Add лучше выполнять до создания горутин.

  2. Почему Done обычно помещают в defer сразу после входа в горутину?

    Так уменьшается риск забыть уменьшить счётчик при раннем возврате из функции. Все пути обычного завершения проходят через отложенный вызов, поэтому Wait не останется заблокированным из-за нового условного выхода.

    Это не превращает произвольную ошибку в успешное завершение: результат ошибки нужно передать отдельно, например через канал или общий механизм ошибок. Если горутина завершится с неперехваченным panic, программа всё равно может аварийно завершиться, хотя отложенные вызовы выполняются во время раскрутки стека.

  3. Заменяет ли WaitGroup закрытие канала с результатами?

    Нет. WaitGroup сообщает только о завершении зарегистрированных задач и не содержит данных о результатах. Если потребитель читает канал до закрытия, отдельная горутина обычно ждёт Wait, а затем закрывает канал.

    Закрывать канал должен тот компонент, который владеет его отправляющей стороной и знает, что новых отправок больше не будет. WaitGroup помогает определить этот момент, но сам по себе не предотвращает отправку в уже закрытый канал и не обеспечивает отмену зависших работников.