context.Context
context.Context— стандартный механизм передачи cancellation, deadline и request-scoped values через цепочки вызовов и горутин. Без context невозможно написать корректный HTTP-сервис, gRPC-клиент или graceful shutdown. На собеседовании про context спросят обязательно: про четыре метода интерфейса, про разницуWithCancel/WithTimeout/WithDeadline, про anti-patternWithValueдля параметров, про propagation в HTTP/SQL/gRPC, про leak без cancel().
Содержание
Заголовок раздела «Содержание»- Зачем нужен context
- Базовое API
- Под капотом
- Использование в практике
- Тонкие моменты / Gotchas
- Best practices
- Типичные вопросы на собесе
- Practice
- Источники
1. Зачем нужен context
Заголовок раздела «1. Зачем нужен context»Представьте HTTP-сервис: пришёл запрос, запустилось 10 горутин (запросы в БД, в кеш, в внешние API). Клиент закрыл соединение через 500 ms. Что должно произойти?
- Все 10 горутин должны остановиться (зачем тратить ресурсы на ответ, который никто не прочтёт?).
- Открытые соединения с БД — освобождены.
- Память — освобождена.
Без context.Context это невозможно реализовать идиоматично. Нужен механизм:
- Сигнала “пора отменять” — распространяющийся по всему дереву вызовов.
- Deadline — “не дольше N ms”.
- Request-scoped значений —
request_id,user_id,trace_idдля логов.
Context решает всё это в одном интерфейсе.
2. Базовое API
Заголовок раздела «2. Базовое API»2.1. Интерфейс
Заголовок раздела «2.1. Интерфейс»type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key any) any}Четыре метода:
- Deadline() — возвращает время дедлайна (или
ok=false, если без дедлайна). - Done() — возвращает канал, закрывающийся, когда context отменён. (
close→ broadcast всем listeners.) - Err() —
nil, если ещё активен;context.Canceledилиcontext.DeadlineExceeded— после отмены. - Value(key) — request-scoped значение по ключу.
2.2. Корневые контексты
Заголовок раздела «2.2. Корневые контексты»ctx := context.Background() // корневой, никогда не отменяетсяctx := context.TODO() // "пока не знаю, какой нужен"- Background — для
main, init, top-level handlers. Стандартный корень. - TODO — семантически “нужно подумать, что сюда положить”. Используется как placeholder в legacy-коде, когда добавляют context, но ещё не пробросили из caller-а. Linter может предупреждать о TODO.
Технически Background() и TODO() идентичны.
2.3. WithCancel
Заголовок раздела «2.3. WithCancel»ctx, cancel := context.WithCancel(parent)defer cancel() // ВАЖНО!
go func() { select { case <-ctx.Done(): return // ctx.Err() = context.Canceled case <-time.After(time.Hour): // ... }}()
cancel() // явная отмена: закрывает ctx.Done()cancel() — функция, отменяющая context. Идемпотентна (можно вызывать многократно). Обязательно вызвать (через defer), иначе утечка ресурсов (timer-ов внутри, дочерних контекстов).
2.4. WithTimeout
Заголовок раздела «2.4. WithTimeout»ctx, cancel := context.WithTimeout(parent, 5*time.Second)defer cancel()
// ctx отменится автоматически через 5 секунд// (или раньше, если parent отменится).WithTimeout(parent, d) = WithDeadline(parent, time.Now().Add(d)).
2.5. WithDeadline
Заголовок раздела «2.5. WithDeadline»deadline := time.Now().Add(30 * time.Second)ctx, cancel := context.WithDeadline(parent, deadline)defer cancel()То же, что timeout, но указываете абсолютное время.
⚠️ Если deadline раньше parent’s deadline — наш ctx отменится раньше. Если позже — игнорируется (parent ограничивает).
2.6. WithValue
Заголовок раздела «2.6. WithValue»type ctxKey struct{} // приватный тип ключа
ctx := context.WithValue(parent, ctxKey{}, "user-123")v := ctx.Value(ctxKey{}).(string) // "user-123"⚠️ Anti-pattern для передачи обычных параметров функции! Используйте WithValue только для:
- request-scoped values (request ID, user info, trace ID).
- Метаданных, “просвечивающих” сквозь много слоёв (нельзя протащить аргументом).
НЕ кладите в context:
- Бизнес-параметры (имя пользователя для логина).
- Зависимости (database connection).
- Optional flags функции.
2.7. WithoutCancel (Go 1.21+)
Заголовок раздела «2.7. WithoutCancel (Go 1.21+)»detached := context.WithoutCancel(ctx)Возвращает context, который не отменяется, когда parent отменён. Сохраняет только values parent-а. Use case: фоновая задача, которую нужно дописать даже после ответа клиенту.
func handler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() writeResponse(ctx, w)
// Запускаем фоновую задачу — она НЕ должна отмениться, // когда client закроет соединение. bgCtx := context.WithoutCancel(ctx) go saveAnalytics(bgCtx)}2.8. WithDeadlineCause / WithCancelCause (Go 1.20+)
Заголовок раздела «2.8. WithDeadlineCause / WithCancelCause (Go 1.20+)»ctx, cancel := context.WithCancelCause(parent)cancel(errors.New("user logged out"))
// context.Cause(ctx) вернёт "user logged out"// ctx.Err() всё равно вернёт context.CanceledПозволяет указать причину отмены, доступную через context.Cause(ctx). Полезно для диагностики (логи, метрики).
2.9. AfterFunc (Go 1.21+)
Заголовок раздела «2.9. AfterFunc (Go 1.21+)»stop := context.AfterFunc(ctx, func() { log.Println("ctx canceled, cleanup")})// stop() отменяет регистрацию (если хочется)Регистрирует функцию-callback, которая вызовется, когда ctx отменится. Полезно для cleanup-логики (вместо отдельной горутины с <-ctx.Done()).
3. Под капотом
Заголовок раздела «3. Под капотом»3.1. Иерархия контекстов
Заголовок раздела «3.1. Иерархия контекстов»Контексты образуют дерево. Корень — Background(). Каждый WithCancel/WithTimeout/... создаёт дочерний context. Отмена parent → отмена всех children (рекурсивно).
Background └── WithTimeout(30s) [server handler] ├── WithCancel [DB query 1] ├── WithCancel [DB query 2] └── WithTimeout(5s) [external API] └── WithCancel [retry attempt]Если “server handler” отменится — отменяются все потомки. Если “external API” отменится — потомок “retry attempt” тоже.
3.2. Структура cancelCtx
Заголовок раздела «3.2. Структура cancelCtx»type cancelCtx struct { Context // wrapped parent mu sync.Mutex done atomic.Value // chan struct{}, lazily created children map[canceler]struct{} // set of children err error // = ctx.Err() cause error // = context.Cause()}Когда вызывается cancel:
- Берётся
mu. - Ставится
err = Canceled(или DeadlineExceeded). - Закрывается канал
done(broadcast: все, кто<-ctx.Done(), проснутся). - Рекурсивно вызывается
cancelу всех children. - Отвязывается от parent (parent больше не держит ссылку → GC).
- Release
mu.
3.3. timerCtx
Заголовок раздела «3.3. timerCtx»WithTimeout — это cancelCtx + time.Timer:
type timerCtx struct { cancelCtx timer *time.Timer deadline time.Time}При создании запускается time.AfterFunc(deadline-now, cancel). Если cancel вызван вручную раньше — таймер останавливается.
⚠️ Если не вызвать cancel(), таймер останется в heap до своего срабатывания, удерживая context и его children. Это утечка (короткая, но всё же).
3.4. valueCtx
Заголовок раздела «3.4. valueCtx»type valueCtx struct { Context key, val any}
func (c *valueCtx) Value(key any) any { if c.key == key { return c.val } return c.Context.Value(key) // рекурсия вверх по дереву}Каждый WithValue оборачивает parent. Value(k) ищет снизу вверх:
ctx5 (key=A) -> ctx4 (key=B) -> ctx3 (key=C) -> Backgroundctx5.Value(C) пройдёт всю цепочку. Поэтому много WithValue = медленный Value() (O(depth)).
⚠️ Не используйте WithValue в hot path для частого Value().
3.5. emptyCtx (Background/TODO)
Заголовок раздела «3.5. emptyCtx (Background/TODO)»type emptyCtx int// Все методы возвращают nil/zero.
var background = new(emptyCtx)var todo = new(emptyCtx)
func Background() Context { return background }func TODO() Context { return todo }Это константные объекты, не отменяются, не имеют values. Никогда не передавайте nil вместо context — это panic в любой имплементации.
3.6. propagation
Заголовок раздела «3.6. propagation»Отмена распространяется через закрытие канала и рекурсивный обход children:
// псевдокодfunc (c *cancelCtx) cancel(removeFromParent bool, err, cause error) { c.mu.Lock() c.err = err c.cause = cause close(c.done.Load().(chan struct{})) // ← broadcast for child := range c.children { child.cancel(false, err, cause) // ← рекурсия } c.children = nil c.mu.Unlock() if removeFromParent { removeChild(c.Context, c) }}После cancel — все горутины, ждущие на <-ctx.Done(), сразу разблокируются.
4. Использование в практике
Заголовок раздела «4. Использование в практике»4.1. HTTP-сервер
Заголовок раздела «4.1. HTTP-сервер»http.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // отменяется, когда клиент закроет соединение result, err := fetchData(ctx) if err != nil { if ctx.Err() != nil { return // клиент ушёл, ничего не пишем } http.Error(w, err.Error(), 500) return } json.NewEncoder(w).Encode(result)})В r.Context():
- Отменяется, когда client закрывает TCP-соединение.
- Отменяется, когда server делает shutdown.
- Содержит values, добавленные middleware.
4.2. HTTP-клиент
Заголовок раздела «4.2. HTTP-клиент»ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)resp, err := http.DefaultClient.Do(req)if err != nil { if errors.Is(err, context.DeadlineExceeded) { // таймаут } return err}defer resp.Body.Close()http.NewRequestWithContext привязывает context к запросу — отмена прервёт TCP-соединение.
4.3. database/sql
Заголовок раздела «4.3. database/sql»ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)defer cancel()
rows, err := db.QueryContext(ctx, "SELECT * FROM users WHERE id=?", id)if err != nil { return err}defer rows.Close()Если таймаут истёк — драйвер отменит запрос на стороне БД (если БД поддерживает; PostgreSQL — да).
4.4. gRPC
Заголовок раздела «4.4. gRPC»ctx, cancel := context.WithTimeout(context.Background(), time.Second)defer cancel()
resp, err := client.SayHello(ctx, &pb.HelloRequest{Name: "world"})gRPC автоматически сериализует deadline в HTTP/2 заголовок grpc-timeout и передаёт на сервер. Сервер видит дедлайн в ctx.
4.5. Graceful shutdown
Заголовок раздела «4.5. Graceful shutdown»func main() { srv := &http.Server{Addr: ":8080", Handler: mux}
go func() { if err := srv.ListenAndServe(); err != http.ErrServerClosed { log.Fatal(err) } }()
// ждём SIGINT/SIGTERM stop := make(chan os.Signal, 1) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) <-stop
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel()
if err := srv.Shutdown(ctx); err != nil { log.Println("shutdown error:", err) }}Shutdown(ctx) отказывает в новых connection-ах, ждёт окончания текущих, отменяет их через ctx по таймауту.
4.6. Логирование с request ID
Заголовок раздела «4.6. Логирование с request ID»type ctxKey stringconst reqIDKey ctxKey = "request_id"
func withReqID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := uuid.New().String() ctx := context.WithValue(r.Context(), reqIDKey, id) next.ServeHTTP(w, r.WithContext(ctx)) })}
func getReqID(ctx context.Context) string { id, _ := ctx.Value(reqIDKey).(string) return id}
func handler(w http.ResponseWriter, r *http.Request) { log.Printf("[%s] processing", getReqID(r.Context()))}4.7. Pipeline горутин
Заголовок раздела «4.7. Pipeline горутин»func process(ctx context.Context, items []Item) error { g, ctx := errgroup.WithContext(ctx)
for _, item := range items { item := item g.Go(func() error { return handleItem(ctx, item) }) } return g.Wait()}errgroup + context — стандартный паттерн для параллельной обработки с отменой.
5. Тонкие моменты / Gotchas
Заголовок раздела «5. Тонкие моменты / Gotchas»5.1. Забыли cancel()
Заголовок раздела «5.1. Забыли cancel()»ctx, _ := context.WithTimeout(parent, time.Second)// забыли defer cancel()⚠️ Утечка ресурсов:
- timer внутри ctx остаётся жить до своего срабатывания (1 sec).
- ctx удерживается в
childrenparent-а — не GC-ится. - Если в ctx есть горутины — они работают до тайаута, а могли бы быть остановлены раньше.
go vet ловит этот случай (-vet=lostcancel).
5.2. nil context
Заголовок раздела «5.2. nil context»fetchData(nil) // ⚠️ panic в любой операции с ctxНикогда не передавайте nil. Если нет контекста — используйте context.Background() или context.TODO().
5.3. Context в struct
Заголовок раздела «5.3. Context в struct»type Server struct { ctx context.Context // ⚠️ не идиоматично!}Официальная рекомендация — не класть context в struct. Передавайте как первый аргумент:
func (s *Server) Process(ctx context.Context, req Request) Response { ... }Исключение: request-scoped структуры (живущие на один запрос), где context — часть state-а запроса:
type RequestProcessor struct { ctx context.Context request *http.Request}Но даже в этом случае контекст обычно прячут от пользователя API.
5.4. WithValue коллизии
Заголовок раздела «5.4. WithValue коллизии»Если использовать string как ключ — коллизия с другими пакетами:
ctx = context.WithValue(ctx, "user", u)// внутри другого пакета:ctx = context.WithValue(ctx, "user", differentU) // override!Фикс: приватный тип:
type userKey struct{}
ctx = context.WithValue(ctx, userKey{}, u)// в другом пакете userKey недоступен → нет коллизии5.5. Context.Value performance
Заголовок раздела «5.5. Context.Value performance»v := ctx.Value(key) // O(depth) — обход дерева снизу вверхЕсли делаете это много раз в hot path — кешируйте локально:
user := getUserFromCtx(ctx) // один разfor _, item := range items { process(user, item)}5.6. Cancel НЕ ждёт горутин
Заголовок раздела «5.6. Cancel НЕ ждёт горутин»ctx, cancel := context.WithCancel(parent)go worker(ctx)cancel() // worker уже МОЖЕТ ЕЩЁ работать, cancel не блокируетcancel() — это сигнал. Чтобы дождаться окончания горутины — sync.WaitGroup или возврат канала.
5.7. Не используйте context для контроля логики
Заголовок раздела «5.7. Не используйте context для контроля логики»func bad(ctx context.Context) { if ctx.Value("admin") == true { // ⚠️ бизнес-логика через ctx doAdminStuff() }}Это anti-pattern. Бизнес-параметры передавайте как аргументы:
func good(ctx context.Context, isAdmin bool) { if isAdmin { doAdminStuff() }}5.8. ctx.Err() vs ctx.Done()
Заголовок раздела «5.8. ctx.Err() vs ctx.Done()»select {case <-ctx.Done(): return ctx.Err() // безопасно вернуть errdefault:}
// vs
if ctx.Err() != nil { return ctx.Err() // тоже ок, но без блокировки}После <-ctx.Done() метод ctx.Err() гарантированно вернёт Canceled или DeadlineExceeded.
5.9. WithValue не для опций функции
Заголовок раздела «5.9. WithValue не для опций функции»// ⚠️ Anti-pattern:ctx = context.WithValue(ctx, "limit", 100)ctx = context.WithValue(ctx, "offset", 200)queryUsers(ctx)
// ✅ Идиома:queryUsers(ctx, QueryOpts{Limit: 100, Offset: 200})5.10. Race на cancel + Done
Заголовок раздела «5.10. Race на cancel + Done»ctx, cancel := context.WithCancel(parent)go func() { cancel() // race? нет — атомарно с close(done)}()<-ctx.Done() // безопасноcancel() и <-ctx.Done() синхронизированы через close(done). Не race.
6. Best practices
Заголовок раздела «6. Best practices»6.1. context — первый аргумент
Заголовок раздела «6.1. context — первый аргумент»// ✅func Process(ctx context.Context, items []Item) error { ... }
// ❌func Process(items []Item, ctx context.Context) error { ... }Линтер revive поймает.
6.2. Никогда не nil
Заголовок раздела «6.2. Никогда не nil»// ❌Process(nil, items)
// ✅Process(context.Background(), items)6.3. defer cancel()
Заголовок раздела «6.3. defer cancel()»ctx, cancel := context.WithTimeout(parent, time.Second)defer cancel()Всегда. Даже если ctx отменится сам по таймауту — лишний cancel не сделает плохо (идемпотентно).
6.4. Передавайте, не сохраняйте
Заголовок раздела «6.4. Передавайте, не сохраняйте»Context — request-scoped, недолгоживущий. Не кладите в long-living struct.
6.5. Не блокируйте только на ctx.Done()
Заголовок раздела «6.5. Не блокируйте только на ctx.Done()»// ❌ Если ctx никогда не отменяется — leak.<-ctx.Done()
// ✅select {case <-ctx.Done(): returncase msg := <-input: handle(msg)}Но в main или top-level handler <-ctx.Done() — это норма (явное ожидание сигнала).
6.6. Передавайте ctx в долгие операции
Заголовок раздела «6.6. Передавайте ctx в долгие операции»// ❌rows, _ := db.Query("...") // без ctx
// ✅rows, _ := db.QueryContext(ctx, "...")Аналогично для HTTP клиента (NewRequestWithContext).
6.7. Типизированные WithValue
Заголовок раздела «6.7. Типизированные WithValue»type ctxKey struct{ name string }var userKey = ctxKey{"user"}
func WithUser(ctx context.Context, u *User) context.Context { return context.WithValue(ctx, userKey, u)}
func UserFrom(ctx context.Context) (*User, bool) { u, ok := ctx.Value(userKey).(*User) return u, ok}Скрывайте WithValue/Value за функциями. Это делает API чище и type-safe.
6.8. errors.Is для проверки
Заголовок раздела «6.8. errors.Is для проверки»if errors.Is(err, context.Canceled) { ... }if errors.Is(err, context.DeadlineExceeded) { ... }Не сравнивайте err == context.Canceled — может быть обёртка через fmt.Errorf("%w", err).
7. Типичные вопросы на собесе
Заголовок раздела «7. Типичные вопросы на собесе»-
Что такое context.Context? Какие у него методы? Интерфейс с 4 методами: Deadline, Done, Err, Value.
-
Чем отличается Background от TODO? Технически одинаковы. Background — стандартный корень. TODO — placeholder, когда не решили, какой нужен.
-
Что делает WithCancel? Возвращает child-context и функцию cancel. cancel закрывает Done канал.
-
Чем WithTimeout отличается от WithDeadline? Timeout — длительность от now. Deadline — абсолютное время. WithTimeout(d) = WithDeadline(now+d).
-
Зачем вызывать cancel(), если ctx сам отменится по таймауту? Освободить ресурсы (timer, child entries в parent) раньше. Иначе утечка до срабатывания таймера.
-
Что произойдёт, если не вызвать cancel? Утечка: timer не остановится, ctx останется в children parent-а, GC не соберёт.
-
Когда использовать WithValue? Только для request-scoped значений (request ID, user, trace ID). НЕ для бизнес-параметров.
-
Какой тип использовать для ключа WithValue? Приватный тип в вашем пакете (например,
type ctxKey struct{}) для избежания коллизий. -
Можно ли передать nil вместо context? Нет, panic. Используйте
context.Background()илиcontext.TODO(). -
Что вернёт ctx.Err() до cancel? nil.
-
Что вернёт ctx.Err() после cancel? context.Canceled или context.DeadlineExceeded.
-
Распространяется ли отмена parent на children? Да, рекурсивно. Все children закрывают свой Done канал.
-
Распространяется ли отмена child на parent? Нет. Cancel дочернего не отменяет parent.
-
Почему context — первый аргумент? Конвенция, делает API единообразным, линтеры это проверяют.
-
Можно ли хранить context в struct? Официально — нет. Исключение: request-scoped структуры. Лучше передавать аргументом.
-
Что такое context.WithoutCancel? Go 1.21+. Возвращает ctx с values от parent, но не отменяется при отмене parent.
-
Что такое context.Cause? Возвращает причину отмены, переданную в WithCancelCause/WithDeadlineCause. ctx.Err() возвращает обобщённую ошибку, Cause — конкретную.
-
Как происходит propagation cancel в дереве контекстов? cancel() закрывает Done канал и рекурсивно вызывает cancel у всех children, отвязывается от parent.
-
Как работает Value() — за какое время? O(depth) — обход дерева снизу вверх до Background.
-
Чем context.AfterFunc отличается от <-ctx.Done() в горутине? AfterFunc регистрирует callback без отдельной горутины — экономнее. Доступен с Go 1.21.
8. Practice
Заголовок раздела «8. Practice»Задача 1: HTTP с таймаутом
Заголовок раздела «Задача 1: HTTP с таймаутом»Напишите функцию fetch(ctx, url) (string, error), которая делает HTTP GET, возвращает body. Должна корректно отменяться при ctx.Done().
func fetch(ctx context.Context, url string) (string, error) { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return "", err } resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) return string(data), err}
// Использование:ctx, cancel := context.WithTimeout(context.Background(), time.Second)defer cancel()body, err := fetch(ctx, "https://example.com")Задача 2: Workers с cancel
Заголовок раздела «Задача 2: Workers с cancel»Напишите worker pool из 3 горутин, обрабатывающий jobs из канала. Должен корректно завершиться при <-ctx.Done().
Задача 3: WithValue типизированно
Заголовок раздела «Задача 3: WithValue типизированно»Реализуйте WithUserID(ctx, id) / UserIDFrom(ctx) (string, bool) с приватным типом ключа.
Задача 4: Graceful shutdown
Заголовок раздела «Задача 4: Graceful shutdown»Напишите HTTP server, который при SIGINT начинает graceful shutdown с таймаутом 10 секунд. Все handler-ы должны видеть отменённый ctx.
Задача 5: Cascade timeouts
Заголовок раздела «Задача 5: Cascade timeouts»ctxA, _ := context.WithTimeout(context.Background(), 10*time.Second)ctxB, _ := context.WithTimeout(ctxA, 30*time.Second) // ⚠️ что произойдёт?Ответ: ctxB отменится через 10 секунд (вместе с parent), а не через 30. Дочерний не может пережить родителя.
Задача 6: errgroup
Заголовок раздела «Задача 6: errgroup»Используя golang.org/x/sync/errgroup, реализуйте параллельную обработку списка URL-ов. Если один упал — отменить все остальные.
g, ctx := errgroup.WithContext(parent)for _, u := range urls { u := u g.Go(func() error { return process(ctx, u) })}if err := g.Wait(); err != nil { // отменены все, err — первая ошибка}Задача 7: Найти leak
Заголовок раздела «Задача 7: Найти leak»func startWorker() { ctx, _ := context.WithTimeout(context.Background(), time.Hour) go func() { for { select { case <-ctx.Done(): return case <-time.After(time.Second): work() } } }()}Проблема: cancel не вызывается. Через час ctx отменится, горутина завершится. До этого момента — таймер удерживается. Если startWorker вызывается часто — память растёт.
Фикс: хранить cancel и вызывать при остановке worker-а.
9. Источники
Заголовок раздела «9. Источники»- context пакет — https://pkg.go.dev/context — официальная документация.
- Go Concurrency Patterns: Context — Sameer Ajmani, Go Blog: https://go.dev/blog/context
- context.WithoutCancel proposal — https://github.com/golang/go/issues/40221 (Go 1.21).
- context source — https://github.com/golang/go/blob/master/src/context/context.go — небольшой и читабельный.
- Context isn’t for cancellation — Dave Cheney: https://dave.cheney.net/2017/01/26/context-is-for-cancelation — критика и идеи.
- errgroup — https://pkg.go.dev/golang.org/x/sync/errgroup — must-have для параллельной обработки.