сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Горутины и каналы

WaitGroup и Once в Go

Дождаться и сделать один раз

Проверил на 200 прогонах. Когда wg.Add(1) стоит внутри запускаемой горутины, Wait проскакивает раньше времени в 162 случаях из 200. Когда перед go - ноль из 200. Спрашивают ровно это, плюс что будет при копировании и сколько раз выполнится тело Once при пятидесяти одновременных вызовах.

Стержень: WaitGroup - счётчик, и Add обязан отработать до запуска горутины; Once даёт ровно одно выполнение и заставляет остальных его дождаться.

// Формулировки: «где ставить wg.Add(1)?», «что при передаче WaitGroup по значению?», «сколько раз выполнится once.Do?»

Порядок вызовов и цена ошибки

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

Отсюда правило. Add вызывают в вызывающей горутине перед оператором go. Если Add выполняется уже внутри горутины, планировщик вправе её пока не запускать: Wait видит ноль и уходит дальше, а работа даже не началась. Мои 162 случая из 200 показывают, что это не редкая экзотика, а обычный исход.

// Done ставят через defer первой строкой горутины. Тогда счётчик уменьшится и при раннем возврате, и при панике - иначе одна упавшая горутина подвесит Wait навсегда.

Add до go
счётчик растёт до запуска горутины, иначе гонка
defer wg.Done()
уменьшение счётчика на любом выходе

Копия со своим счётчиком

Функция func worker(wg sync.WaitGroup) собирается молча, без единого предупреждения компилятора. А внутри неё wg.Done() уменьшает счётчик копии: оригинал об этом не узнает, и Wait висит вечно.

go vet это видит и печатает: worker passes lock by value: sync.WaitGroup contains sync.noCopy. Тип noCopy внутри - пустая структура-маркер, вставленная туда специально, чтобы анализатор мог такое поймать. Сам компилятор копирование не запрещает, поэтому проверка живёт в vet, а не в сборке.

// Лечится передачей указателя или, что чаще удобнее, замыканием переменной в горутине - тогда копия не возникает вообще.

noCopy
маркер внутри структуры: копировать нельзя
go vet
анализатор, который ловит то, что компилятор пропускает

Once: один раз, и все ждут

Опыт: 50 горутин одновременно зовут once.Do, а тело инициализации спит 300 мс. Тело выполнилось один раз. И вернулись все пятьдесят через 301 мс - самая быстрая и самая медленная с точностью до миллисекунды.

Вторая половина этой гарантии как раз и есть то, о чём забывают. Лишние вызовы Once мимо не пропускает: он держит остальных, пока первый не закончил. Поэтому после возврата из Do результат гарантированно готов, и проверять его отдельно не нужно.

// С Go 1.21 есть sync.OnceFunc и sync.OnceValue: возвращают готовую обёртку, и объявлять переменную Once рядом с функцией больше не приходится.

sync.Once
одно выполнение, остальные ждут его завершения
OnceValue
обёртка 1.21: одно вычисление, общий результат

Где эти двое кончаются

Once не умеет повторять. Три вызова Do на одном экземпляре дали одно выполнение, и это верно даже если внутри всё упало: неудачную инициализацию той же Once не переиграть. Под ретраи нужен другой механизм.

WaitGroup умеет только «дождаться всех». Ошибки она не собирает, соседей не останавливает, параллелизм не ограничивает: тысяча задач честно запустит тысячу горутин. Как только задачи начинают возвращать ошибки, берут errgroup - он запоминает первую и отменяет общий контекст, а SetLimit ставит потолок одновременных задач.

// Это же и ответ на вопрос «а как ограничить»: семафор на буферизованном канале или errgroup.SetLimit. Не «запустим все и посмотрим».

errgroup
ожидание группы, первая ошибка, отмена контекста
SetLimit
потолок одновременно работающих задач

Как отвечать: «Где вызывать wg.Add и что будет при копировании WaitGroup?»

Add вызывают в вызывающей горутине до оператора go, а не первой строкой внутри запускаемой. Причина в гонке: если Add выполнится уже внутри горутины, планировщик может её ещё не запустить, Wait увидит нулевой счётчик и пройдёт дальше - программа решит, что всё сделано, хотя работа не начиналась. Это не теоретический риск, я проверял: на двухстах прогонах Wait проскакивал в ста шестидесяти двух случаях. С Add перед go - ноль из двухсот. Done ставлю через defer первой строкой в горутине, чтобы он отработал и при раннем возврате, и при панике. Про копирование: WaitGroup это структура со счётчиком, и копия получает собственный счётчик. Передал по значению, вызвал там Done - оригинал не узнает, Wait висит навсегда. Компилятор такой код соберёт молча, а go vet предупредит: passes lock by value, sync.WaitGroup contains sync.noCopy. Поэтому передаю указателем или замыкаю переменную в горутине. А когда задачам нужно возвращать ошибки, беру errgroup: он и ждёт всех, и отменяет общий контекст по первой ошибке, и через SetLimit ограничивает параллелизм.

Сильный ответ: правило Add объяснено через конкретную гонку и подтверждено числом, а не подано как ритуал. Названо поведение при копировании вместе с реакцией vet и показан переход к errgroup - то есть человек знает границы применимости инструмента.

На чём валят

  • Вызывают wg.Add внутри горутины. На моём замере Wait проскочил в 162 прогонах из 200.
  • Передают WaitGroup по значению: Done уходит в копию, Wait висит вечно.
  • Забывают defer у Done, и одна паника в горутине подвешивает всю группу.
  • Ждут, что вторая горутина проскочит once.Do, не дождавшись первой. Все пятьдесят у меня ждали 300 мс.
  • Берут WaitGroup там, где нужны ошибки и отмена. Это работа errgroup.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 8, остальные разбираются в тренажёре.

  1. #go_waitgroup_once1 / 5
    Что делает sync.WaitGroup?
    A)Ограничивает число одновременно работающих горутин заданным лимитом
    B)Собирает результаты горутин в один срез в порядке их запуска
    C)Отменяет все горутины группы, если одна из них вернула ошибку
    D)Ждёт завершения группы горутин (счётчик Add/Done/Wait)
    показать ответ и разбор
    +D)Ждёт завершения группы горутин (счётчик Add/Done/Wait)

    // разбор: WaitGroup — барьер ожидания: Add(n) увеличивает счётчик на число горутин, каждая по завершении зовёт Done() (−1), а Wait() блокирует, пока счётчик не станет 0. Классический способ дождаться пачку фоновых задач. Это не пул (число горутин не ограничивает) и не сборщик результатов — только синхронизация завершения.

  2. #go_waitgroup_once2 / 5
    Где правильно вызвать wg.Add(1) при запуске горутины?
    A)До запуска горутины (go ...), в вызывающей горутине
    B)Внутри самой горутины, первой строкой её тела
    C)Место не важно — лишь бы Done вызвался столько же раз, сколько Add
    D)Внутри горутины, но обязательно после первой операции ввода-вывода
    показать ответ и разбор
    +A)До запуска горутины (go ...), в вызывающей горутине

    // разбор: Add надо звать ДО go f() из родительской горутины. Если сделать Add(1) внутри самой горутины, возможна гонка: Wait() успеет увидеть счётчик 0 и вернуться раньше, чем горутина стартует и добавится, — часть задач «потеряется». Инвариант: Add до старта, Done через defer в горутине. Детектор -race такую ошибку ловит.

  3. #go_waitgroup_once3 / 5
    Пятьдесят горутин зовут once.Do(f) с одним sync.Once. Сколько раз выполнится f?
    A)Пятьдесят — по разу на каждую горутину
    B)Ноль, если ни одна не успела первой захватить Once
    C)Ровно один раз; остальные вызовы дождутся конца f и вернутся
    D)Один раз на каждое ядро процессора — по числу параллельных потоков планировщика
    показать ответ и разбор
    +C)Ровно один раз; остальные вызовы дождутся конца f и вернутся

    // разбор: sync.Once гарантирует, что переданная функция выполнится ровно один раз за всё время жизни Once, сколько бы горутин ни звали Do конкурентно. Первый вызов исполняет f, остальные блокируются до её завершения и затем возвращаются, ничего не делая. Классика для ленивой инициализации синглтона/конфига. Спот-чек: n=1.

  4. #go_waitgroup_once4 / 5
    sync.WaitGroup передали в функцию по значению, и там вызвали Done. Что будет?
    A)Всё сработает: WaitGroup внутри содержит указатель на общее состояние
    B)Функция уменьшит счётчик копии, а Wait в вызывающем коде зависнет
    C)Компилятор откажется собирать код с копированием WaitGroup
    D)Done в копии вызовет панику из-за отрицательного счётчика
    показать ответ и разбор
    +B)Функция уменьшит счётчик копии, а Wait в вызывающем коде зависнет

    // разбор: WaitGroup — структура со счётчиком внутри, и копия получает собственный счётчик: Done в ней на оригинал не влияет, а Wait ждёт вечно. Компилятор такой код собирает, но go vet ругается на копирование sync.noCopy. Передавать WaitGroup нужно указателем — либо, что чаще удобнее, замыкать её в горутине и не передавать вовсе.

  5. #go_waitgroup_once5 / 5
    Что произойдёт, если одна из горутин не вызовет wg.Done()?
    A)Wait подождёт секунду и продолжит выполнение
    B)Счётчик обнулится сам при завершении горутины
    C)Wait зависнет навсегда: счётчик не дойдёт до нуля
    D)Программа упадёт с ошибкой о незакрытой группе
    показать ответ и разбор
    +C)Wait зависнет навсегда: счётчик не дойдёт до нуля

    // разбор: Wait стоит, пока счётчик не станет нулём, а таймаута у него нет — забытый Done превращается в вечное ожидание, и это одна из самых частых причин зависших программ на Go. Страховка проста: Done ставят через defer первой строкой горутины, тогда он сработает и при панике, и при раннем возврате.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.