errgroup: группа горутин с ошибкой и отменой#
Модуль: Concurrency · Уровень: Senior
TL;DR#
golang.org/x/sync/errgroup — это WaitGroup, который умеет две недостающие вещи: возвращать первую ненулевую ошибку и отменять контекст остальных горутин при её появлении. errgroup.WithContext(ctx) возвращает группу и производный контекст, который отменяется, когда первая функция вернула ошибку или когда Wait завершился. SetLimit(n) ограничивает число одновременно работающих горутин, превращая группу в пул; TryGo пытается запустить без блокировки. Ключевое условие пользы: горутины обязаны сами слушать ctx.Done() — отмена не прерывает работающий код, она лишь сообщает о необходимости остановиться.
Простыми словами#
errgroup — это WaitGroup, который умеет возвращать ошибку и останавливать остальных.
Задача, ради которой он придуман, встречается постоянно: сходить параллельно в три сервиса и собрать ответ. С обычным WaitGroup придётся руками завести канал под ошибки, придумать, что делать со второй и третьей ошибкой, отдельно создать контекст с отменой и не забыть его дёрнуть. С errgroup это выглядит так:
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return fetchUser(ctx) })
g.Go(func() error { return fetchOrders(ctx) })
if err := g.Wait(); err != nil {
return err // первая случившаяся ошибка, остальные горутины уже получили сигнал отмены
}Важно понять, чего он не делает. Отмена контекста ничего не прерывает силой — горутина, которая крутит цикл и не смотрит в ctx.Done(), продолжит работать как ни в чём не бывало. Контекст только сообщает: «результат больше не нужен, сворачивайся».
Если ты с другого языка. Ближайший аналог — asyncio.TaskGroup из Python 3.11 или структурированная конкурентность из Kotlin. Отличие: в Go отменяемость не встроена в рантайм, поэтому каждая функция обязана явно проверять контекст. Компилятор об этом не напомнит.
Словарь этой статьи:
- производный контекст — контекст, созданный от родителя и отменяемый вместе с ним или по ошибке в группе;
SetLimit— ограничение числа одновременно работающих горутин;- структурированная конкурентность — принцип «горутина не переживает область, которая её породила».
Теория#
Чего не хватает WaitGroup#
var wg sync.WaitGroup
var mu sync.Mutex
var firstErr error
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
if err := fetch(u); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
}
}(url)
}
wg.Wait()Здесь три проблемы: ошибки собираются руками через мьютекс, при первой ошибке остальные продолжают работать впустую, и нет никакого способа их остановить. errgroup закрывает всё это.
Базовое использование#
import "golang.org/x/sync/errgroup"
func fetchAll(ctx context.Context, urls []string) error {
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
url := url // на Go < 1.22 обязательно
g.Go(func() error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("fetch %s: %w", url, err)
}
defer resp.Body.Close()
_, err = io.Copy(io.Discard, resp.Body)
return err
})
}
return g.Wait()
}Что здесь происходит:
g.Goзапускает горутину и берёт на себя учёт;- если любая функция вернула ошибку, производный
ctxотменяется, и все запросы, созданные черезNewRequestWithContext, немедленно прерываются; g.Wait()дожидается всех и возвращает первую ошибку (по времени возникновения, не по порядку запуска).
Обрати внимание на переприсваивание ctx := ...: производный контекст намеренно затеняет исходный, чтобы случайно не передать в горутину неотменяемый родительский.
Сбор результатов#
g.Go принимает функцию без результата, поэтому значения возвращают через заранее выделенные ячейки:
results := make([]Result, len(ids)) // каждая горутина пишет в свой индекс — гонки нет
g, ctx := errgroup.WithContext(ctx)
for i, id := range ids {
i, id := i, id
g.Go(func() error {
r, err := load(ctx, id)
if err != nil {
return err
}
results[i] = r
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}Запись в разные индексы одного слайса из разных горутин безопасна: элементы не пересекаются в памяти, а g.Wait() создаёт нужное отношение happens-before перед чтением. Менять сам слайс (append) при этом, разумеется, нельзя.
Ограничение параллелизма: SetLimit#
Без ограничения errgroup запустит столько горутин, сколько задач. На десяти тысячах URL это десять тысяч одновременных соединений — верный способ получить отказ от внешнего сервиса и исчерпать порты.
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // не больше 10 одновременно
for _, id := range ids {
id := id
g.Go(func() error { // блокируется, пока не освободится слот
return process(ctx, id)
})
}
return g.Wait()SetLimit превращает группу в пул воркеров без единой дополнительной строки. Два правила: вызывать его нужно до первого g.Go, и SetLimit(-1) снимает ограничение.
Неблокирующий вариант — TryGo: он возвращает false, если свободных слотов нет, и не запускает функцию.
Когда отменяется контекст#
Производный контекст из WithContext отменяется в двух случаях: когда первая функция вернула ненулевую ошибку, и когда Wait вернул управление. Второе важно для утечек:
func handler(ctx context.Context) error {
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return worker(ctx) })
return g.Wait() // после возврата ctx отменён — фоновые задачи не переживут функцию
}Это и есть структурированная конкурентность: горутины не разбегаются за пределы породившей их области.
Что отмена НЕ делает#
g.Go(func() error {
for i := 0; i < 1_000_000_000; i++ {
heavyComputation(i) // ctx никто не смотрит
}
return nil
})Такая горутина доработает до конца, чего бы там ни отменяли. Правильный вариант — регулярная проверка:
g.Go(func() error {
for i := 0; i < 1_000_000_000; i++ {
if i%1000 == 0 {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
heavyComputation(i)
}
return nil
})То же касается вызовов, не принимающих контекст: старые библиотеки, os/exec без CommandContext, драйверы без контекстных методов — всё это отменой не остановить.
Только первая ошибка#
Wait возвращает одну ошибку — ту, что случилась первой. Остальные теряются. Если нужны все (например, при валидации, где каждая задача проверяет свой кусок), собирай их самостоятельно:
var (
mu sync.Mutex
errs []error
)
g := new(errgroup.Group) // без WithContext: одна ошибка не должна отменять остальных
for _, check := range checks {
check := check
g.Go(func() error {
if err := check(); err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
return nil // всегда nil, чтобы группа не останавливалась
})
}
_ = g.Wait()
return errors.Join(errs...)errgroup против WaitGroup.Go (Go 1.25)#
В Go 1.25 у sync.WaitGroup появился метод Go, снимающий классическую ошибку с Add/Done:
var wg sync.WaitGroup
wg.Go(func() { work() })
wg.Wait()Это удобно, но решает только учёт горутин. Ошибок и отмены там по-прежнему нет, поэтому errgroup не устарел: WaitGroup.Go — для «просто дождаться», errgroup — для «дождаться, получить ошибку и остановить остальных».
Паника внутри группы#
errgroup не перехватывает панику: она распространится обычным образом и уронит процесс. Если задачи выполняют недоверенный или сложный код, восстановление пишут самостоятельно:
g.Go(func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
return risky(ctx)
})Подводные камни / gotchas#
- Забыть переприсвоить
ctx.g, gctx := errgroup.WithContext(ctx)и последующая передача в горутины исходногоctx— отмена работать не будет. - Горутина не слушает контекст. Отмена ничего не прерывает силой; без проверки
ctx.Done()группа лишь дождётся всех, ничего не сэкономив. SetLimitпослеGo. Вызов после запуска первой горутины паникует.- Теряются все ошибки, кроме первой. Для агрегации нужен свой сбор и
errors.Join. g.Goблокируется при исчерпанном лимите. Это ожидаемо, но вызовg.Goиз горутины, которую сама же группа и ждёт, приводит к дедлоку.- Паника не перехватывается и убивает процесс.
- Общий слайс через
append— гонка. Писать можно только в заранее выделенные разные индексы. errgroupне в стандартной библиотеке, а вgolang.org/x/sync— это отдельная зависимость (хоть и официальная).
Вопросы на собеседовании#
В: Чем errgroup лучше WaitGroup?
О: Он собирает ошибку от горутин и, в варианте WithContext, отменяет остальных при первой же ошибке. С WaitGroup это пришлось бы писать руками через канал ошибок, мьютекс и отдельный контекст с отменой.
В: Когда отменяется контекст из WithContext?
О: Когда первая переданная функция вернула ненулевую ошибку либо когда вернулся Wait. Второе гарантирует, что запущенные горутины не переживут вызывающую функцию.
В: Отменит ли errgroup горутину, которая занята тяжёлым вычислением?
О: Нет. Отмена — это только сигнал в канале ctx.Done(). Функция обязана его проверять сама, иначе доработает до конца. Прервать горутину извне в Go нельзя в принципе.
В: Как ограничить число одновременных горутин?
О: g.SetLimit(n) до первого g.Go. После этого g.Go блокируется, пока не освободится слот, а TryGo возвращает false вместо ожидания.
В: Что вернёт Wait, если ошиблись три горутины?
О: Первую по времени возникновения ошибку. Остальные будут потеряны — если нужны все, их собирают самостоятельно и объединяют через errors.Join.
В: Как безопасно собрать результаты из горутин группы?
О: Выделить слайс нужной длины заранее и писать каждой горутине в свой индекс. Это безопасно, потому что элементы не пересекаются, а Wait устанавливает happens-before перед чтением результатов.
В: Нужен ли errgroup после появления WaitGroup.Go в Go 1.25?
О: Да. WaitGroup.Go решает только проблему корректного учёта горутин; ошибок и отмены он не даёт.
На что копают на senior+#
- Понимание, что отмена в Go кооперативная: контекст — это сигнал, а не прерывание.
- Структурированная конкурентность: осознание, что после
Waitконтекст отменён и горутины не разбегаются. - Работа с параллелизмом на практике: почему безлимитный
errgroupна большом входе — авария, и как соотноситсяSetLimitс лимитами внешнего сервиса и пула соединений. - Аккуратность с результатами: почему запись по индексам безопасна, а
append— нет. - Follow-up: «Как отличить отмену по таймауту от отмены по ошибке соседа?» (
context.Cause), «Что делать с паникой в задаче?», «Как совместитьerrgroupс семафором на внешний API?».