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?».