Перейти к содержимому
Теория · Горутины и каналы · урок 2 из 13

sync.WaitGroup

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

Счётчик горутин

В Go такая доска — тип sync.WaitGroup из пакета sync. Это счётчик горутин, которые ещё не закончили работу:

GO
package main

import "fmt"
import "sync"

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        fmt.Println("hello") // hello
    }()
    wg.Wait()
    fmt.Println("main") // main
}

Давайте разберём пример по шагам:

  1. wg.Add(1) записывает на доску один заказ: счётчик становится 1.
  2. go func() { … }() запускает горутину, и она печатает hello.
  3. На выходе из горутины defer wg.Done() вычитает единицу.
  4. Тем временем wg.Wait() в main ждёт, пока счётчик не дойдёт до нуля.
  5. Только после этого main печатает main.

Поэтому программа печатает обе строки при каждом запуске, и hello всегда идёт первой.

Программа импортирует пакеты двумя строками import: это то же самое, что одна строка со скобками.

Add, Done и Wait

У WaitGroup три метода. Нулевое значение WaitGroup готово к работе: счётчик в нём равен нулю.

Add(n)

Метод Add(n) увеличивает счётчик на n.

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

Done()

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

Если вызвать Done больше раз, чем прибавил Add, случится паника sync: negative WaitGroup counter. Отрицательного числа заказов не бывает.

Wait()

Метод Wait() ждёт, пока счётчик не станет равен нулю.

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

Результаты в общем срезе

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

GO
var wg sync.WaitGroup
squares := make([]int, 3)
for i := range 3 {
    wg.Add(1)
    go func() {
        defer wg.Done()
        squares[i] = i * i
    }()
}
wg.Wait()
fmt.Println(squares) // [0 1 4]

Разберём, почему результат всегда один:

  • каждая горутина кладёт своё блюдо на свою полку: squares[0], squares[1] или squares[2];
  • две горутины никогда не пишут в один элемент, поэтому порядок их работы не важен;
  • после Wait все три полки заполнены, и программа печатает [0 1 4].

Метод Go

Начиная с Go 1.25 у WaitGroup есть метод Go. Вызов wg.Go(f) делает три шага:

  1. Увеличивает счётчик на единицу.
  2. Запускает f в новой горутине.
  3. Вызывает Done, когда f вернётся.

Цикл выше становится короче:

GO
var wg sync.WaitGroup
squares := make([]int, 3)
for i := range 3 {
    wg.Go(func() {
        squares[i] = i * i
    })
}
wg.Wait()
fmt.Println(squares) // [0 1 4]

Перепутать порядок Add и go здесь нельзя: метод делает и то и другое сам.

Частая ошибка

WaitGroup передали в функцию по значению:

GO
func work(wg sync.WaitGroup) { // ✗
    defer wg.Done()
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go work(wg)
    wg.Wait()
}
TEXT
fatal error: all goroutines are asleep - deadlock!

Счётчик в main так и не доходит до нуля:

  1. Функция получила копию доски.
  2. work уменьшает счётчик своей копии, а счётчик в main остаётся равным единице.
  3. Wait ждёт вечно, и, когда заблокированы все горутины, среда выполнения останавливает программу.

WaitGroup никогда не копируют: функция принимает wg *sync.WaitGroup, а вызов передаёт &wg.

Главное

  • WaitGroup — счётчик незаконченных горутин: Add(n) прибавляет, Done() вычитает единицу, Wait() ждёт нуля.
  • Add вызывают до оператора go; wg.Go(f) делает Add, go и Done сам.
  • Done чаще, чем Add, вызывает панику sync: negative WaitGroup counter.
  • WaitGroup не копируют: в функцию передают указатель &wg.

Подпишись, чтобы пройти курс

Курс бесплатный. Войди через Telegram и подпишись на канал — откроются все уроки.

Войти другим способом