Горутина запускается одной строкой, и на этой простоте всё заканчивается. Кто завершит горутину, когда её результат уже никому не нужен? Кто подождёт пачку параллельных запросов перед сборкой ответа? Что случится с обработкой, если клиент закрыл соединение посреди вычислений? В Go на эти вопросы отвечают два штатных инструмента: sync.WaitGroup ждёт финала, контекст разносит сигнал отмены по всей цепочке вызовов. Попытка обойтись без них сводится к снам на фиксированное время и надежде на расписание процессора, а расписание редко входит в такие договорённости.

Типичные поломки конкурентного кода без явной синхронизации

Запуск горутины без плана её завершения выглядит невинно ровно до первого дежурства по продакшену. Список типичных финалов предсказуем:

  1. главный поток завершается раньше горутин, и работа исчезает вместе с процессом;
  2. синхронизация сном либо пропускает гонку, либо тормозит там, где данных мало;
  3. одновременная запись в общий слайс из нескольких горутин теряет элементы;
  4. горутина навсегда повисает на чтении канала, в который никто не пишет;
  5. количество горутин растёт без предела, и под нагрузкой процесс упирается в память.

Код с тремя из этих проблем помещается в несколько строк:

var results []string
for _, u := range urls {
	go func(u string) {
		data := fetch(u)
		results = append(results, data) // запись из горутины без блокировки
	}(u)
}
time.Sleep(2 * time.Second) // "синхронизация"

Сон на две секунды то укладывается в реальное время запросов, то нет: сегодня три адреса, завтра триста, и половина ответов просто не попадает в слайс. Одновременные вызовы append из разных горутин иногда проходят бесследно, иногда теряют элементы, а детектор гонок go test -race показывает суть без обсуждений. Лечится всё теми же двумя инструментами: счётчиком ожидания и контекстом отмены.

Утечки вычисляются без магии: штатная функция runtime.NumGoroutine показывает количество живых горутин, и тест сравнивает цифры до и после прогона:

func TestNoLeaks(t *testing.T) {
	before := runtime.NumGoroutine()
	RunPipeline(context.Background(), jobs)
	after := runtime.NumGoroutine()
	if after > before {
		t.Fatalf("утечка горутин: было %d, стало %d", before, after)
	}
}

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

Дисциплина sync.WaitGroup от счётчика Add до ожидания Wait

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

var wg sync.WaitGroup
for _, u := range urls {
	wg.Add(1)
	go func(u string) {
		defer wg.Done()
		data, err := fetch(ctx, u)
		if err != nil {
			log.Printf("fetch %s: %v", u, err)
			return
		}
		mu.Lock()
		results = append(results, data)
		mu.Unlock()
	}(u)
}
wg.Wait()

С версии Go 1.25 шаблон сжался до одного вызова: метод Go сам прибавляет счётчик, запускает горутину и вычитает по завершении:

var wg sync.WaitGroup
for _, u := range urls {
	wg.Go(func() {
		if err := fetch(ctx, u); err != nil {
			log.Printf("fetch %s: %v", u, err)
		}
	})
}
wg.Wait()

Пара тонкостей из той же коробки. Значение переменной цикла с версии Go 1.22 создаётся заново на каждой итерации, поэтому захват u в замыкание больше не требует локальной копии. А скопированное по значению ожидание ловится анализатором: go vet с проверкой copylocks ругнётся на структуру со счётчиком в составе другой структуры, а с Go 1.25 штатный анализатор waitgroup находит и вызов Add внутри самой горутины, с сообщением WaitGroup.Add called from inside new goroutine. Ошибка, о которой раньше узнавали на код ревью, теперь видна локально.

Контекст как способ вовремя остановить горутину а не только передать значения

Контекст решает вторую половину задачи: не ждать финала вслепую, а сообщить всей цепочке, что работа потеряла смысл. Канал Done закрывается при отмене, Err возвращает причину, а значения передаются строго по делу:

ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()

select {
case <-ctx.Done():
	return ctx.Err() // context deadline exceeded
case out := <-worker:
	return out
}

Генераторы семейства WithCancel, WithTimeout и WithDeadline выстраиваются в цепочку от запроса к нижним слоям, и отмена верхнего звена каскадом гасит всех потребителей внизу. В HTTP обработчиках половина работы сделана до вас: r.Context() сервер отменяет сам при отключении клиента, и долгий запрос можно прервать без корректур на верхнем уровне.

Гигиена контекста короткая: первый параметр функции с именем ctx, никакого хранения в полях структур, никакого nil вместо заглушки, только context.Background или context.TODO. Значения передаются через собственный тип ключа, а не через строку, иначе любой пакет по соседству перезапишет чужое значение:

type userIDKey struct{}

func ContextWithUserID(ctx context.Context, id int64) context.Context {
	return context.WithValue(ctx, userIDKey{}, id)
}

func UserID(ctx context.Context) (int64, bool) {
	id, ok := ctx.Value(userIDKey{}).(int64)
	return id, ok
}

Контекст не контейнер опциональных аргументов и не способ обойти сигнатуру функции. Это канал управления временем жизни, и работает он ровно до тех пор, пока его используют только так.

Отмена доезжает и до чужого кода: методы QueryContext и ExecContext у database/sql, методы HTTP клиента, клиенты кэшей принимают ctx первым параметром, и сигнал закрывает соединение в самом низу без догадок в бизнес коде:

row := db.QueryRowContext(ctx, "SELECT nickname FROM users WHERE id = $1", id)

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

Пул воркеров на связке WaitGroup и контекста с плавной остановкой

Рабочая связка выглядит так: фиксированное число воркеров читает канал задач, контекст гасит их по требованию, закрытие канала означает конец смены, WaitGroup гарантирует, что в функцию никто не вернётся раньше финала:

func RunPool(ctx context.Context, jobs <-chan Job, workers int) error {
	var wg sync.WaitGroup
	errCh := make(chan error, workers)

	for i := 0; i < workers; i++ {
		wg.Go(func() {
			for {
				select {
				case <-ctx.Done():
					return
				case job, ok := <-jobs:
					if !ok {
						return
					}
					if err := job.Run(ctx); err != nil {
						errCh <- err
						return
					}
				}
			}
		})
	}

	wg.Wait()
	close(errCh)
	select {
	case err := <-errCh:
		return err
	default:
		return nil
	}
}

Буфер у канала ошибок равен числу воркеров, поэтому отправка в errCh не блокирует никого. Неблокирующее чтение после Wait возвращает первую ошибку или подтверждает чистое завершение. Три правила выхода: продюсер закрывает канал задач, надзиратель отменяет контекст, пул не переживает свой возврат. Внутри каждой задачи ctx передаётся дальше в базы и HTTP клиенты, поэтому отмена на верхнем уровне доезжает до самого нижнего соединения без опроса флагов руками.

Ожидание с лимитом собирается из тех же деталей: WaitGroup в отдельной горутине закрывает канал по факту завершения, а select сверяет финал с внешней отменой:

done := make(chan struct{})
go func() {
	defer close(done)
	wg.Wait()
}()
select {
case <-done:
	// всё завершилось
case <-ctx.Done():
	return ctx.Err()
}

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

Причина отмены через WithCancelCause и функция Cause начиная с Go 1.21

До версии 1.21 причина отмены оставалась тайной: Err возвращал либо context.Canceled, либо context.DeadlineExceeded, и разница между "пользователь закрыл вкладку" и "пропал доступ к базе" исчезала. Обновление закрыло вопрос целым набором: WithCancelCause, WithTimeoutCause, WithDeadlineCause, функция Cause, а рядом AfterFunc и WithoutCancel:

ctx, cancel := context.WithCancelCause(context.Background())
defer cancel(nil)

upstream := errors.New("upstream unavailable")
go func() {
	cancel(upstream)
}()

fmt.Println(ctx.Err())           // context canceled
fmt.Println(context.Cause(ctx)) // upstream unavailable

Функция Cause достаёт именно ту ошибку, с которой отменили цепочку, а не общий маркер отмены. AfterFunc регистрирует функцию, которая выполнится после отмены, и возвращает останов для отказа от подписки:

stop := context.AfterFunc(ctx, func() {
	// сбросить кэш, закрыть соединение, записать метрику
})
defer stop()

WithoutCancel пригодится на стыке запроса и фоновой доработки: значения и дедлайны из родительского контекста сохраняются, а сигнал отмены не проходит, поэтому финальная запись в лог не обрывается из-за отключившегося клиента.

Тот же механизм работает у часов: WithTimeoutCause и WithDeadlineCause прикладывают причину к срабатыванию дедлайна, и Cause вернёт именно её, а не сухой context.DeadlineExceeded:

ctx, cancel := context.WithTimeoutCause(parent, time.Second, ErrSlowUpstream)
defer cancel(nil)

Разница между медленным внешним сервисом и собственной перегрузкой сразу попадает в лог и в алерты, а не растворяется в общем маркере отмены.

Тонкие места таймеры гонки и детектор в тестах

Тайм-ауты в select исторически писали через time.After, и до версии 1.23 у этого была цена: таймер дожидался срабатывания, даже когда select давно выбрал другую ветку. Обновление Go 1.23 изменило поведение: невостребованный таймер собирается сборщиком мусора сразу, канал таймера стал небуферизованным, а Reset и Stop больше не оставляют устаревших значений. Для старых программ поведение сохраняется, а откат настраивается через GODEBUG asynctimerchan=1. В длинных циклах явный таймер остаётся честным способом, когда контроль должен быть виден в коде:

timer := time.NewTimer(work)
defer timer.Stop()
select {
case <-timer.C:
	// время вышло
case <-ctx.Done():
	return ctx.Err()
}

Ожидание переиспользовать можно, но по строгому циклу: одна волна горутин, один WaitGroup, один Wait. Вызов Add после Wait обнуляет смысл ожидания и открывает гонку между старым и новым циклом. Копирование структуры со счётчиком по значению превращает один счётчик в несколько несвязанных, анализатор copylocks ловит и это. Гонки данных в тестах ищет штатный детектор: go test -race ./... находит одновременный доступ к памяти без блокировки, причём показывает обе горутины и стек вызовов каждой. Лимит параллелизма там, где воркеры по числу задач неуместны, делается семафором из буферизованного канала на несколько строк:

sem := make(chan struct{}, 8)
var wg sync.WaitGroup
for _, u := range urls {
	wg.Go(func() {
		sem <- struct{}{}
		defer func() { <-sem }()
		fetch(ctx, u)
	})
}
wg.Wait()

Границы связки errgroup и семафоры когда стандартных средств мало

WaitGroup отвечает на вопрос "когда всё закончится", но не собирает ошибки и не гасит соседей при первой неудаче. Для семантики "первая ошибка отменяет остальные" есть расширение errgroup из модуля golang.org/x/sync, поверх тех же примитивов:

g, ctx := errgroup.WithContext(parent)
g.SetLimit(8)
for _, u := range urls {
	g.Go(func() error {
		return fetch(ctx, u)
	})
}
if err := g.Wait(); err != nil {
	return err
}

Метод SetLimit ограничивает одновременные запуски, WaitGroup спрятан внутрь, а контекст отменяется при первой ошибке без ручного вызова cancel. Взвешенные лимиты, когда одна задача весит три слота, а другая один, закрывает пакет semaphore из того же модуля golang.org/x/sync. Расширения не отменяют базовую механику: внутри errgroup работают тот же WaitGroup и тот же контекст, поэтому понимание связки не пропадает, а переносится на уровень выше.

Жизненный цикл горутины не заканчивается словом go, и в этом весь смысл дисциплины. WaitGroup отвечает на вопрос, когда всё закончится, контекст объясняет, зачем продолжать, а вместе они закрывают и запуск, и ожидание, и отмену, и сбор ошибок. Сон на фиксированное время проигрывает обоим по всем статьям: он либо короче реальной работы, либо дольше необходимого. Явная синхронизация стоит нескольких строк шаблона, зато поведение сервиса перестаёт зависеть от распланированных мгновений, а каждая горутина получает владельца, срок жизни и честную причину завершения.