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, остальные разбираются в тренажёре.
- Что делает sync.WaitGroup?A)Ограничивает число одновременно работающих горутин заданным лимитомB)Собирает результаты горутин в один срез в порядке их запускаC)Отменяет все горутины группы, если одна из них вернула ошибкуD)Ждёт завершения группы горутин (счётчик Add/Done/Wait)
показать ответ и разбор
+D)Ждёт завершения группы горутин (счётчик Add/Done/Wait)// разбор: WaitGroup — барьер ожидания: Add(n) увеличивает счётчик на число горутин, каждая по завершении зовёт Done() (−1), а Wait() блокирует, пока счётчик не станет 0. Классический способ дождаться пачку фоновых задач. Это не пул (число горутин не ограничивает) и не сборщик результатов — только синхронизация завершения.
- Где правильно вызвать wg.Add(1) при запуске горутины?A)До запуска горутины (go ...), в вызывающей горутинеB)Внутри самой горутины, первой строкой её телаC)Место не важно — лишь бы Done вызвался столько же раз, сколько AddD)Внутри горутины, но обязательно после первой операции ввода-вывода
показать ответ и разбор
+A)До запуска горутины (go ...), в вызывающей горутине// разбор: Add надо звать ДО go f() из родительской горутины. Если сделать Add(1) внутри самой горутины, возможна гонка: Wait() успеет увидеть счётчик 0 и вернуться раньше, чем горутина стартует и добавится, — часть задач «потеряется». Инвариант: Add до старта, Done через defer в горутине. Детектор -race такую ошибку ловит.
- Пятьдесят горутин зовут once.Do(f) с одним sync.Once. Сколько раз выполнится f?A)Пятьдесят — по разу на каждую горутинуB)Ноль, если ни одна не успела первой захватить OnceC)Ровно один раз; остальные вызовы дождутся конца f и вернутсяD)Один раз на каждое ядро процессора — по числу параллельных потоков планировщика
показать ответ и разбор
+C)Ровно один раз; остальные вызовы дождутся конца f и вернутся// разбор: sync.Once гарантирует, что переданная функция выполнится ровно один раз за всё время жизни Once, сколько бы горутин ни звали Do конкурентно. Первый вызов исполняет f, остальные блокируются до её завершения и затем возвращаются, ничего не делая. Классика для ленивой инициализации синглтона/конфига. Спот-чек: n=1.
- sync.WaitGroup передали в функцию по значению, и там вызвали Done. Что будет?A)Всё сработает: WaitGroup внутри содержит указатель на общее состояниеB)Функция уменьшит счётчик копии, а Wait в вызывающем коде зависнетC)Компилятор откажется собирать код с копированием WaitGroupD)Done в копии вызовет панику из-за отрицательного счётчика
показать ответ и разбор
+B)Функция уменьшит счётчик копии, а Wait в вызывающем коде зависнет// разбор: WaitGroup — структура со счётчиком внутри, и копия получает собственный счётчик: Done в ней на оригинал не влияет, а Wait ждёт вечно. Компилятор такой код собирает, но go vet ругается на копирование sync.noCopy. Передавать WaitGroup нужно указателем — либо, что чаще удобнее, замыкать её в горутине и не передавать вовсе.
- Что произойдёт, если одна из горутин не вызовет wg.Done()?A)Wait подождёт секунду и продолжит выполнениеB)Счётчик обнулится сам при завершении горутиныC)Wait зависнет навсегда: счётчик не дойдёт до нуляD)Программа упадёт с ошибкой о незакрытой группе
показать ответ и разбор
+C)Wait зависнет навсегда: счётчик не дойдёт до нуля// разбор: Wait стоит, пока счётчик не станет нулём, а таймаута у него нет — забытый Done превращается в вечное ожидание, и это одна из самых частых причин зависших программ на Go. Страховка проста: Done ставят через defer первой строкой горутины, тогда он сработает и при панике, и при раннем возврате.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.