Конкуренция в Go

Теория: sync.WaitGroup

Когда в программе запускается несколько горутин, у каждой свой жизненный цикл. Они могут работать дольше, чем main, ждать ввод-вывод, обрабатывать данные. Если главная горутина завершится раньше, рантайм мгновенно остановит весь процесс — даже если часть работы еще не закончена.

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

Зачем нужен WaitGroup

Без WaitGroup() остается грубый способ — ставить time.Sleep в надежде, что «за это время все успеет выполниться». Такой подход не масштабируется: одна горутина может отработать за миллисекунду, другая — за секунду, третья упереться в внешний сервис. В итоге либо программа завершается слишком рано, либо делает бессмысленные паузы.

WaitGroup() заменяет угадывание конкретным протоколом. У него есть внутренний счетчик активных горутин.

  • Перед запуском горутины счетчик увеличивается.
  • Когда горутина заканчивает работу, счетчик уменьшается.
  • В момент, когда счетчик станет равен нулю, ожидание можно считать завершенным.

Главная горутина вызывает wg.Wait() и блокируется до тех пор, пока все запущенные задачи не отметятся через Done(). Это гарантирует, что программа не оборвется, пока есть незаконченная работа.

Простейший пример:

package main

import (
	"fmt"
	"sync"
	"time"
)

// worker — единица работы, которую запускают в отдельной горутине
func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // сигнал "эта горутина закончила"

	fmt.Println("начало работы", id)
	time.Sleep(200 * time.Millisecond) // имитация нагрузки
	fmt.Println("завершение работы", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)         // увеличиваем счетчик ожидаемых горутин
		go worker(i, &wg) // запуск конкурентной задачи
	}

	wg.Wait() // блокируемся, пока все worker не вызовут Done()
	fmt.Println("все горутины завершены")
}

Здесь main не продолжит выполнение, пока три worker не отработают до конца. Сколько бы времени ни заняла каждая из них, WaitGroup() гарантирует корректный момент выхода.

Методы Add, Done, Wait

Поведение WaitGroup() целиком определяется тремя методами:

  • Add(delta int) — изменяет счетчик ожидаемых горутин;
  • Done() — сокращенная форма Add(-1);
  • Wait() — блокирует горутину до тех пор, пока счетчик не станет нулем.

Последовательность действий всегда одна и та же:

  1. Перед запуском горутины вызвать Add(1).
  2. Внутри горутины как можно раньше сделать defer wg.Done().
  3. После запуска всех горутин один раз вызвать wg.Wait() там, где нужно дождаться их завершения.

Разбор на небольшом примере:

package main

import (
	"fmt"
	"sync"
	"time"
)

func worker(id int, wg *sync.WaitGroup) {
	// гарантируем, что при любом завершении функции счетчик уменьшится
	defer wg.Done()

	fmt.Println("worker", id, "старт")
	time.Sleep(150 * time.Millisecond) // имитация работы
	fmt.Println("worker", id, "стоп")
}

func main() {
	var wg sync.WaitGroup

	// запускаем несколько независимых задач
	for id := 1; id <= 5; id++ {
		wg.Add(1) // объявляем: ожидается еще одна горутина
		go worker(id, &wg)
	}

	// здесь происходят другие действия, если нужно
	fmt.Println("main: ожидаем завершения всех worker")

	wg.Wait() // блокируемся до нуля в счетчике
	fmt.Println("main: все worker завершены, продолжаем")
}

Тонкий момент: Add() и Done() могут вызываться из разных горутин, но протокол должен оставаться согласованным. Add() увеличивает ожидаемое количество задач, Done() снижает его по факту выполнения, Wait() фиксирует точку, в которой нужно дождаться, пока счетчик обнулится.

Типичные ошибки использования WaitGroup

Интерфейс WaitGroup() простой, но ошибки все равно встречаются регулярно. Почти всегда проблема связана с неправильным управлением счетчиком.

1. Add вызывается внутри горутины

Неправильно:

// опасный вариант
go func() {
    wg.Add(1)      // слишком поздно
    defer wg.Done()
    // работа...
}()

На первый взгляд кажется, что все нормально: Add(1) вызывается до Done(), и defer не может сработать раньше. Но реальная проблема в другом.

Горутина может вообще не успеть запуститься.

Запуск через go — это только планирование. Основная горутина может мгновенно дойти до wg.Wait() и увидеть счетчик равным нулю. Она перестает ждать и выходит из функции, а рабочая горутина еще даже не начала выполняться — и ее Add(1) вызывается после Wait().

Это гонка между Add и Wait. Такое использование WaitGroup() считается некорректным, и поведение программы становится неопределенным.

Правильно:

wg.Add(1)
go func() {
    defer wg.Done()
    // работа...
}()

Здесь порядок гарантирован: сначала увеличили счетчик, потом запустили горутину, и Wait() точно дождется ее выполнения.

3. Параллельный Add и Wait

Спецификация позволяет вызывать Add() и Wait() конкурентно, но только если гарантируется, что Add() не увеличивает счетчик после того, как Wait() уже решило, что можно выйти. На практике это легко нарушить.

Безопасный шаблон проще:

  • все Add() выполняются в одном месте перед запуском горутин;
  • Wait() вызывается после этого и больше не пересекается по времени с Add().

Это устраняет класс проблем, когда одна часть программы еще добавляет работу, а другая уже решила, что все окончено.

4. Отсутствие Done при раннем выходе

Если внутри горутины есть несколько веток выхода, а Done() вызывается не через defer, одна из веток легко забудет уменьшить счетчик. В результате Wait() никогда не дождется нуля и повиснет.

Надежный шаблон:

func worker(wg *sync.WaitGroup) {
	defer wg.Done() // срабатывает при любом return

	// дальше можно спокойно использовать return в любых ветках
	if err := doWork(); err != nil {
		// при ошибке просто выходим
		return
	}

	// другая логика...
}

5. Смешивание WaitGroup() с закрытием каналов

Частый прием — закрывать канал после того, как все горутины-писатели завершились. Здесь важно не перепутать роли:

  • горутины только пишут в канал и вызывают Done();
  • одна отдельная горутина или main ждет wg.Wait() и только после этого закрывает канал.
go func() {
	// ждем, пока все писатели вызовут Done()
	wg.Wait()
	// после этого новых записей не будет, канал можно закрыть
	close(results)
}()

Если закрыть канал раньше, чем все писатели закончат работу, при первой попытке записи произойдет panic: send on closed channel.

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

Как только в коде появляется go, рядом почти всегда должен появиться либо WaitGroup(), либо контекст и сигнальные каналы. Это признак управляемой конкуренции: каждая горутина не только запускается, но и имеет понятный момент, когда ее ждут и отпускают.

И еще одна ошибка — работа с копией wg.

Если воркер будет, например, так, то Done() будет вызываться у копии и Wait() для исходной переменной никогда не достигнет нуля:

func worker(id int, wg sync.WaitGroup) {
	// гарантируем, что при любом завершении функции счетчик уменьшится
	defer wg.Done()

	fmt.Println("worker", id, "старт")
	time.Sleep(150 * time.Millisecond) // имитация работы
	fmt.Println("worker", id, "стоп")
}

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845