open atlas
↑ К треку
Go с нуля до senior GO · 10 · 01

errgroup как структурная конкурентность: горутины в регионе, отмена по первой ошибке и SetLimit

errgroup ограничивает горутины регионом: Go запускает потомков, Wait ждёт всех и возвращает первую ошибку, WithContext отменяет остальных кооперативно — каждый потомок обязан следить за ctx.Done. SetLimit даёт ограниченный параллелизм; у WaitGroup нет ни ошибок, ни отмены.

GO Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Чёрная пятница, 14:02. Сервис чекаута на каждый заказ разветвляется в четыре downstream-вызова — склад, прайсинг, антифрод, лояльность — под errgroup. В 14:02 антифрод-провайдер начал отклонять запросы с чистой ошибкой за 80 мс. Быстрый отказ должен был стать дешёвым сценарием. Вместо этого p99 неудачных чекаутов вырос до 28 секунд, а число горутин утроилось — потому что один из соседей, клиент прайсинга, собирал запрос с context.Background(). errgroup сделал ровно то, что обещает: первая ошибка отменила групповой контекст через 80 мс. Но отмена в Go — это сигнал, а не убийство. Wait блокируется, пока не вернётся каждый потомок, а вызов прайсинга не слушал, и каждый неудачный чекаут тащил за собой полный 28-секундный клиентский таймаут. Фикс — одна строка, http.NewRequestWithContext(ctx, ...), а урок пережил инцидент: errgroup даёт вам регион, и регион ровно настолько плотен, насколько кооперативен его самый глухой участник.

Регион для горутин

Когда вы разветвляетесь без региона, вы доверяете, что каждая горутина вернётся сама — а один глухой сосед в 2 часа ночи докажет обратное. Голый go — это бесструктурность: у горутины нет точки соединения, её ошибке некуда деться, а время жизни никак не связано с функцией, которая её породила. Структурная конкурентность переворачивает это — конкурентная работа ограничена лексическим регионом, и регион не завершается, пока не завершился каждый потомок. В Go идиома — errgroup:

func loadOrder(ctx context.Context, id string) (*Order, error) {
	g, ctx := errgroup.WithContext(ctx) // затенённый ctx: отменяется первой ошибкой

	var inv Inventory
	var price Pricing
	g.Go(func() error { return fetchInventory(ctx, id, &inv) })
	g.Go(func() error { return fetchPricing(ctx, id, &price) })
	g.Go(func() error { return checkFraud(ctx, id) })

	if err := g.Wait(); err != nil {
		return nil, err // первая ошибка; групповой ctx уже отменён
	}
	return assemble(inv, price), nil
}

Регион определяют три гарантии. Wait возвращается, только когда вернулся каждый колбэк g.Go — ни один потомок не переживёт функцию. Ошибка из Waitпервая ненулевая из всех. А с WithContext групповой контекст отменяется, как только этот первый неудачный колбэк вернулся — соседи получают сигнал остановки без вашей проводки. Рядом едет тихая гарантия: Wait — это ребро happens-before, поэтому паттерн выше — каждый потомок пишет в свою переменную, родитель читает их только после Wait — свободен от гонок без единого мьютекса.

Отмена — сигнал, а не убийство

Вот тонкость, за которую Hook заплатил 28 секундами. errgroup отменяет контекст после того, как неудачный колбэк вернул ошибку — а отмена контекста сама по себе не делает ничего. Ни одна горутина не прерывается, ни одно соединение не закрывается, пока кто-то не следит за ctx.Done(). Соседи в полёте бегут дальше, пока сами не заметят отмену, и отсюда правило против утечек для всего юнита: у каждого тела g.Go должен быть путь возврата через ctx. Передавайте групповой ctx в каждый блокирующий вызов — http.NewRequestWithContext, db.QueryContext, и каждую операцию с каналом оборачивайте в select с веткой ctx.Done(). Потомок, который четыре секунды считает хеш в плотном цикле, так же глух: ему положено проверять ctx.Err() между чанками. Компилятор не подсветит соседа, игнорирующего ctx; подсветит ваш p99.

Викторина

Бегут три потомка g.Go; антифрод возвращает ошибку через 80 мс, пока два других в середине запроса. Что errgroup делает в этот момент?

Первая ошибка побеждает — проектируйте политику отказа

Внутри группа держит один слот ошибки под sync.Once: первый ненулевой возврат сохраняется, каждая последующая ошибка отбрасывается. Этот дефолт кодирует политику всё-или-ничего — идеально для «собери страницу из трёх обязательных вызовов», неверно для «обогати 50 записей, сохрани удавшееся». Для best-effort-разветвления не возвращайте ошибки из колбэков вовсе — записывайте их:

errs := make([]error, len(parts))
var g errgroup.Group // без WithContext: ничто не должно отменять соседей
for i, p := range parts {
	g.Go(func() error {
		errs[i] = process(p) // запись в свой слот; непересекающиеся индексы не требуют мьютекса
		return nil           // никогда не валим группу
	})
}
_ = g.Wait()
return errors.Join(errs...) // nil, только если удалась каждая часть

errors.Join отдаёт полную картину, которую группа выбросила бы. И даже в режиме всё-или-ничего оборачивайте ошибку каждого потомка идентичностью — fmt.Errorf("pricing: %w", err) — потому что единственная выжившая ошибка обязана хотя бы сказать, из которого из двенадцати вызовов она пришла. Выбирайте политику на каждое разветвление явно; молчаливый дефолт имеет привычку становиться случайным контрактом.

SetLimit: встроенный ограниченный параллелизм

Разветвление по 10 000 элементов не должно означать 10 000 одновременных горутин, долбящих один downstream. С появлением SetLimit граница — одна строка:

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // ставить до первого Go; смена при активных потомках — паника
for _, item := range items {
	g.Go(func() error { return convert(ctx, item) }) // блокируется, пока 8 в полёте
}
err := g.Wait()

Механизм важен: при достигнутом лимите g.Go блокирует вызывающего. Сам порождающий цикл становится производителем под backpressure — память ограничена лимитом, а не числом элементов. TryGo — неблокирующий вариант: вместо ожидания возвращает false, для схем «отказать вместо очереди». Это тот же выбор «блокировать или отказывать», на котором строится урок про backpressure.

Викторина

У группы SetLimit(8), и 8 потомков сейчас бегут. Цикл вызывает g.Go для девятого элемента. Что произойдёт?

Почему это работает

Почему Wait отказывается возвращаться раньше, даже когда ответ — первая ошибка — уже известен? Потому что ранний возврат — ровно то, из чего рождаются бесструктурные утечки: родитель идёт дальше, осиротевшие потомки всё ещё держат буферы, сокеты и ctx запроса, и никто их никогда не соединит. Структурная конкурентность меняет честность латентности на честность времени жизни: если Wait медленный — медленный потомок, и профиль укажет прямо на него. 28-секундный p99 из Hook не был расточительностью errgroup; это errgroup отказался прятать соседа, которого нельзя отменить.

WaitGroup, errgroup или каналы результатов

Прежде чем тянуться за каналом результатов, спросите: вам реально нужен каждый исход как данные, или достаточно первого отказа и чистого соединения? Три инструмента, одно решение. sync.WaitGroup — чистое соединение: ни ошибок, ни отмены — уместен, когда потомки действительно не могут отказать, и известная ловушка иначе (ошибки логируются в пустоту, а родитель рапортует успех). errgroup добавляет проброс первой ошибки, отмену соседей и лимит конкурентности — дефолт для разветвления с политикой отказа. Каналы результатов — тяжёлый вариант: каждый воркер шлёт типизированный результат или ошибку, потребитель итерирует и решает — необходимо, когда нужен каждый исход как данные, потоковый порядок или частичное потребление, ценой собственноручной хореографии закрытия и осушения (урок про пайплайны). Накладные расходы — честный шум: errgroup это WaitGroup плюс мьютекс, Once и опциональный канал-лимитер — наносекунды рядом с любым вызовом, ради которого стоит разветвляться.

Выбери лучший вариант

Вы разветвляетесь на 5 downstream-API на запрос; любой сбой должен отменить остальные, а вызывающий получает ровно одну ошибку. Какой инструмент конкурентности подходит лучше всего?

Вспомните перед уходом
  1. 01
    Что errgroup.WithContext даёт сверх голого WaitGroup и где именно отмена происходит — и не происходит?
  2. 02
    Опиши семантику первая-ошибка-побеждает и две политики отказа разветвления, между которыми она заставляет выбирать.
Итог

Теперь, когда Wait занимает намного больше, чем должен любой из потомков — ищите того, кто игнорирует ctx: именно там регион течёт. errgroup — рабочая форма структурной конкурентности в Go: g.Go запускает потомков, g.Wait соединяет каждого из них и возвращает первую ошибку, а WithContext проводит общий контекст, отменяемый в момент возврата первого неудачного колбэка. Дисциплина, делающая регион настоящим, — кооперативная отмена: отмена ctx сама по себе ничего не прерывает, поэтому каждому потомку нужен путь возврата через ctx — запросы через NewRequestWithContext, запросы к базе через QueryContext, операции с каналами внутри select с веткой ctx.Done, процессорные циклы с проверкой ctx.Err между чанками. Один глухой сосед растягивает Wait до его собственного таймаута, и это честное поведение — ранний возврат осиротил бы горутины, держащие буферы и сокеты. Семантика ошибок — первая побеждает за Once: поздние отбрасываются, поэтому политику отказа выбирают сознательно — всё-или-ничего с WithContext и обёрнутыми атрибутируемыми ошибками, либо best-effort без групповой отмены, с записью по слотам и errors.Join после Wait. SetLimit делает группу инструментом ограниченного параллелизма: на лимите g.Go блокирует сам порождающий цикл, ограничивая память лимитом, а не объёмом работы, с TryGo как вариантом быстрого отказа — и SetLimit ставится до первого Go, потому что изменение лимита живой группы паникует. Против альтернатив: WaitGroup — только соединение, каналы результатов покупают полные данные по каждому исходу ценой собственноручной хореографии закрытия и осушения. Каждое тело g.Go отвечает на один вопрос — или течёт: как эта горутина завершится, когда региону скажут остановиться?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.