Горутина запускается одной строкой, и на этой простоте всё заканчивается. Кто завершит горутину, когда её результат уже никому не нужен? Кто подождёт пачку параллельных запросов перед сборкой ответа? Что случится с обработкой, если клиент закрыл соединение посреди вычислений? В Go на эти вопросы отвечают два штатных инструмента: sync.WaitGroup ждёт финала, контекст разносит сигнал отмены по всей цепочке вызовов. Попытка обойтись без них сводится к снам на фиксированное время и надежде на расписание процессора, а расписание редко входит в такие договорённости.
Типичные поломки конкурентного кода без явной синхронизации
Запуск горутины без плана её завершения выглядит невинно ровно до первого дежурства по продакшену. Список типичных финалов предсказуем:
- главный поток завершается раньше горутин, и работа исчезает вместе с процессом;
- синхронизация сном либо пропускает гонку, либо тормозит там, где данных мало;
- одновременная запись в общий слайс из нескольких горутин теряет элементы;
- горутина навсегда повисает на чтении канала, в который никто не пишет;
- количество горутин растёт без предела, и под нагрузкой процесс упирается в память.
Код с тремя из этих проблем помещается в несколько строк:
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 отвечает на вопрос, когда всё закончится, контекст объясняет, зачем продолжать, а вместе они закрывают и запуск, и ожидание, и отмену, и сбор ошибок. Сон на фиксированное время проигрывает обоим по всем статьям: он либо короче реальной работы, либо дольше необходимого. Явная синхронизация стоит нескольких строк шаблона, зато поведение сервиса перестаёт зависеть от распланированных мгновений, а каждая горутина получает владельца, срок жизни и честную причину завершения.