1. Чому «просто запустити три горутини» недостатньо
Коли ви починаєте писати конкурентний код, перша емоція — захват. «О, я зараз запущу 3 горутини, і все буде швидко!» — і це правда… перші 15 хвилин. Потім зʼясовується, що вам потрібна не лише паралельність, а й керованість: якщо одна підзадача впала з помилкою, решта мають коректно зупинитися, а не продовжувати палити CPU і тримати зʼєднання «з принципу».
Типовий сценарій у реальному застосунку — і в нашому навчальному «таск‑трекері» — виглядає так: одна користувацька операція складається з кількох незалежних кроків. Наприклад, для команди tasks sync ми одночасно читаємо задачі з файлу та підтягуємо оновлення з API. У нас одразу виникають три запитання: як дочекатися всіх, як повернути помилку і як зупинити інших, якщо помилка вже сталася.
Щоб побачити проблему, почнемо з наївного коду. Він запускає дві горутини й чекає на них, але «помилки якось губляться», а скасування через контекст не змушує всіх зупинитися.
package main
import (
"context"
"sync"
)
func naive(ctx context.Context) error {
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); _ = ctx /* робота A */ }()
go func() { defer wg.Done(); _ = ctx /* робота B */ }()
wg.Wait()
return nil
}
Формально це «працює», але практично не розвʼязує головного: якщо одна частина провалилася, інша про це не дізнається. І ще важливіше: якщо зверху операцію скасовано через таймаут або користувачем, підроботи мають це поважати — інакше ви отримаєте витоки горутин.
2. Ідея errgroup: «група горутин як одна операція»
Тепер підводимо головну думку лекції. У прикладному Go дуже часто потрібно обʼєднати кілька горутин в одну логічну операцію: поки виконується запит, працюють N підзадач; якщо запит скасовано — усі згортаються; якщо одна підзадача впала — вважаємо всю операцію невдалою.
Саме це й називають ідеєю errgroup: існує певна сутність «група», яка:
- запускає горутини,
- чекає на їх завершення,
- повертає помилку, зазвичай першу,
- і часто вміє скасовувати спільний контекст, щоб зупинити решту.
У Go це зазвичай асоціюють із пакетом golang.org/x/sync/errgroup, але сьогодні нам важливий не імпорт, а механізм: як ця штука поводиться і як зібрати її «вручну», використовуючи вже знайомі інструменти.
Чому тут майже завжди фігурує context? Тому що саме Context дає нам стандартний спосіб сказати всім горутинам: «Операцію завершено, розходимося». Канал Done() — це сигнал скасування, а Err() пояснює причину.
Щоб це не виглядало магією, тримайте маленьку схему того, що ми хочемо отримати:
flowchart TD
A[Вхід: ctx та список підзадач] --> B[Створюємо дочірній ctx і cancel]
B --> C[Запускаємо N горутин]
C --> D{Хтось повернув помилку?}
D -- ні --> E[Усі завершилися успішно]
D -- так --> F[Зберігаємо першу помилку]
F --> G["Викликаємо cancel()"]
G --> H[Чекаємо завершення всіх]
H --> I[Повертаємо помилку]
Зверніть увагу на важливу дисципліну: cancel() викликає той, хто його створив. Це узгоджується із самою моделлю context: функція, яка отримала ctx, не має самовільно скасовувати чужий контекст. Якщо їй потрібне керування, вона створює дочірній контекст і володіє його cancel.
3. Ручний errgroup: WaitGroup + канал помилок + cancel
Зараз ми зберемо мінімальний «ручний errgroup» без зовнішніх пакетів. Нам знадобляться:
- sync.WaitGroup, щоб дочекатися всіх горутин;
- errCh — канал помилок, щоб безпечно передати помилку назовні;
- context.WithCancel, щоб мати спільний «стоп‑сигнал» для всіх учасників.
І тут одразу зʼявляється тонкий момент: як передати помилку так, щоб не зависнути. Якщо канал помилок небуферизований, а читач ще не готовий, надсилання заблокує горутину — і ви отримаєте зависання замість скасування. У класичних конкурентних патернах Go часто використовують буфер 1 і/або неблокувальне надсилання через select { case ch <- v: default: }.
У матеріалах і прикладах про таймаути та конкурентні патерни в Go часто показують неблокувальне надсилання через select як спосіб не залишати горутину висіти. На практиці, однак, буфер теж важливий: він гарантує, що перша помилка справді дійде.
Зберемо функцію «перша помилка перемагає».
package main
import (
"context"
"sync"
)
func RunFirstError(ctx context.Context, fns []func(context.Context) error) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
errCh := make(chan error, 1)
var wg sync.WaitGroup
wg.Add(len(fns))
for _, fn := range fns {
fn := fn
go func() {
defer wg.Done()
if err := fn(ctx); err != nil {
select {
case errCh <- err:
cancel()
default:
}
}
}()
}
wg.Wait()
close(errCh)
return <-errCh
}
Давайте розберемо, що тут відбувається, ніби ми відлагоджуємо це з ліхтариком.
Канал errCh має буфер 1. Це означає: перша помилка точно зможе записатися, навіть якщо RunFirstError ще не дійшов до читання. Далі ми використовуємо select із default, щоб будь-яка наступна помилка не блокувала горутину, бо нас уже цікавить перша.
Після успішного запису першої помилки ми викликаємо cancel(). Це закриває ctx.Done() і дає шанс усім іншим учасникам швидко вийти, якщо вони написані ввічливо. А «ввічливість» тут дуже конкретна: всередині довгих очікувань і циклів потрібно слухати <-ctx.Done() і повертати ctx.Err().
4. Підзадачі, які зупиняються за ctx.Done()
Дуже легко написати горутину, яка не вміє зупинятися. Наприклад, вона робить time.Sleep(10 * time.Second) або чекає на читання з каналу, і скасування контексту її зовсім не цікавить. У підсумку ви викликаєте cancel(), а ваші горутини продовжують жити, як кіт, який зробив вигляд, що не чує вас.
Тому підзадача зазвичай будується навколо select: або робота завершилася, або контекст скасовано. Це той самий принцип, який використовують у патернах таймаутів: у вас є кілька подій, і ви обираєте, яка настане першою.
Мініприклад кроку, який можна перервати:
package main
import (
"context"
"time"
)
func Step(ctx context.Context, d time.Duration) error {
select {
case <-time.After(d):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
Тут немає жодної магії. ctx.Done() — канал‑сигнал; коли його закрито, читання з нього миттєво проходить, і ми повертаємо причину через ctx.Err(). Саме Err() пояснює, чи скасували операцію вручну (context.Canceled), чи сплив дедлайн (context.DeadlineExceeded).
5. Стратегія: зібрати всі помилки
Іноді «перша помилка» — ідеальна стратегія: наприклад, під час запиту до кількох реплік бази даних нам достатньо одного успішного результату, а перша помилка лише сигналізує: «ця гілка впала, але інші ще можуть встигнути».
Але буває й навпаки: ви робите валідацію великого імпорту або пакетну обробку й хочете зібрати всі проблеми, щоб показати користувачу повний звіт, а не «першу‑ліпшу». У нашому курсі для цього вже є інструмент — errors.Join, який обʼєднує кілька помилок в одну. Ми не будемо заглиблюватися в його устрій; нам важливо вміти застосовувати його на практиці.
Щоб зібрати всі помилки, є важливий принцип: не записуйте до спільного []error з кількох горутин напряму, бо це гонка даних. Варіант для новачка простий: нехай кожна горутина надсилає свою помилку в буферизований канал, а збирання відбувається в одному місці після Wait().
package main
import (
"context"
"errors"
"sync"
)
func RunAllErrors(ctx context.Context, fns []func(context.Context) error) error {
errCh := make(chan error, len(fns))
var wg sync.WaitGroup
wg.Add(len(fns))
for _, fn := range fns {
fn := fn
go func() {
defer wg.Done()
errCh <- fn(ctx) // може бути nil — відфільтруємо пізніше
}()
}
wg.Wait()
close(errCh)
var errs []error
for err := range errCh {
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
Тут стратегія інша: ми не скасовуємо інших на першій помилці, бо хочемо зібрати все до кінця, а канал робимо розміром len(fns), щоб надсилання ніколи не блокувало. Це, звісно, витрачає памʼять пропорційно кількості задач, але для помірної кількості горутин це нормально й дуже читабельно.
6. Практика: вбудовуємо в застосунок
Паралельне завантаження даних для «дашборда задач»
Тепер найприємніше: приклад, який не живе у вакуумі. Уявімо, що в нашому навчальному застосунку задач (таск‑трекері) є операція «показати дашборд»: вона хоче одночасно отримати список задач і статистику про них. Ми спеціально зробимо вигляд, що джерела незалежні: задачі читаємо зі сховища, а статистику — рахуємо або отримуємо окремо. Так, у реальності ви могли б порахувати статистику після читання задач, але тут важлива сама форма задачі — дві паралельні підоперації.
Почнемо з простих типів.
package main
type Task struct {
ID int
Text string
Done bool
}
type Stats struct {
Total int
Done int
}
Далі накидаємо інтерфейс сховища, який уже відповідає звичному стилю Go: ctx першим параметром. Це підтримує наскрізну передачу скасування зверху вниз.
package main
import "context"
type TaskStorage interface {
List(ctx context.Context) ([]Task, error)
Stats(ctx context.Context) (Stats, error)
}
Тепер функція рівня застосунку: отримати обидві частини паралельно й повернути одну агреговану структуру.
package main
type Dashboard struct {
Tasks []Task
Stats Stats
}
А тепер найважливіше: реалізація через наш RunFirstError. Ми хочемо, щоб якщо впала хоча б одна гілка, друга зупинилася, а ми повернули помилку.
package main
import "context"
func LoadDashboard(ctx context.Context, st TaskStorage) (Dashboard, error) {
var d Dashboard
err := RunFirstError(ctx, []func(context.Context) error{
func(ctx context.Context) error {
tasks, err := st.List(ctx)
d.Tasks = tasks
return err
},
func(ctx context.Context) error {
stats, err := st.Stats(ctx)
d.Stats = stats
return err
},
})
return d, err
}
Тут є тонкість, про яку корисно чесно сказати: ми записуємо результати (d.Tasks, d.Stats) із різних горутин в одну структуру d. На практиці це може бути гонкою даних, якщо ці записи відбуваються одночасно. Щоб зробити приклад безпечним, зазвичай використовують або окремі змінні та запис у них, а потім збирання після Wait(), або захищають запис мʼютексом, або роблять канали результатів.
Тож давайте виправимо це по‑дорослому, але без зайвої складності: заведемо дві окремі змінні й не будемо писати в d конкурентно.
package main
import "context"
func LoadDashboard(ctx context.Context, st TaskStorage) (Dashboard, error) {
var tasks []Task
var stats Stats
err := RunFirstError(ctx, []func(context.Context) error{
func(ctx context.Context) error { t, e := st.List(ctx); tasks = t; return e },
func(ctx context.Context) error { s, e := st.Stats(ctx); stats = s; return e },
})
return Dashboard{Tasks: tasks, Stats: stats}, err
}
Цей варіант уже зрозуміліший: кожну змінну записує лише своя горутина, а результати ми збираємо після завершення групи. Найсуворіший варіант — писати результати в канали, а присвоювати їх уже після завершення групи; але це роздує приклад. У навчальній лекції важливіше зрозуміти механіку «помилка/скасування/очікування».
Помилки: контекст і обгортання
Коли ми збираємо кілька підоперацій в одну, помилки стають частиною контракту: сторона, що викликає, хоче зрозуміти, що саме впало. У Go типовий шлях — додавати контекст через fmt.Errorf("operation: %w", err) і зберігати можливість перевірки через errors.Is/errors.As, коли це доречно.
Для наших підзадач це означає: якщо st.List(ctx) повернув помилку, її корисно загорнути як «list tasks: …», а якщо st.Stats(ctx) впав — як «load stats: …». Тоді верхній рівень — CLI або HTTP‑межа — зможе коректно залогувати деталі, а користувачу показати коротке повідомлення.
Мініприклад обгортання всередині підзадачі:
package main
import (
"context"
"fmt"
)
func listWithContext(ctx context.Context, st TaskStorage) ([]Task, error) {
tasks, err := st.List(ctx)
if err != nil {
return nil, fmt.Errorf("list tasks: %w", err)
}
return tasks, nil
}
Ця звичка особливо цінна в конкурентному коді: коли щось падає паралельно, без контексту в помилці ви будете дивитися на лог як на записку: «Я зламався, вгадай де».
7. Типові помилки
Помилка №1: WaitGroup.Add роблять усередині горутини.
Це виглядає невинно: «Я ж усе одно додаю 1». Але Wait() може виконатися раніше, ніж горутина встигне зробити Add(1), і тоді очікування завершиться передчасно. Просте правило дисципліни таке: спочатку Add, потім запуск go func(){...}. Це не бюрократія, а захист від рідкісних і підступних часових гонок.
Помилка №2: канал помилок небуферизований, а читання відбувається «колись потім».
Якщо горутина намагається надіслати помилку в небуферизований канал, але в цей момент ніхто не читає, вона блокується. У конкурентному коді це особливо небезпечно: ви хотіли «спіймати помилку й скасувати», а отримали горутину, яка зависла на надсиланні помилки й не дала завершитися Wait(). Буфер 1 для першої помилки — майже стандарт. І неблокувальне надсилання через select із default — типовий спосіб не дати другій помилці повісити систему.
Помилка №3: закривають errCh із робочої горутини.
Закриття каналу — відповідальність власника, який знає, що нових записів більше не буде. Якщо закривати канал із воркерів, легко отримати паніку «send on closed channel», бо інший воркер ще намагається надіслати помилку. Набагато надійніше: wg.Wait() в одному місці, потім close(errCh) там само.
Помилка №4: скасування (cancel()) забули викликати або викликали не там.
Якщо ви створюєте ctx, cancel := context.WithCancel(parent), то cancel належить тому, хто його створив. Якщо його не викликати або не зробити defer cancel(), ви ризикуєте тримати ресурси довше, ніж потрібно. Якщо ж ви викликаєте cancel() усередині глибинної функції, яка не є власником, ви ламаєте контракт часу життя: верхній шар може раптово побачити скасований контекст без видимої причини. У моделі context це спеціально розділено: той, хто отримує сигнал скасування, зазвичай не повинен бути тим, хто його надсилає.
Помилка №5: підзадачі не перевіряють ctx.Done() і не повертають ctx.Err().
У результаті скасування ніби передали, але нічого не зупинилося. Якщо підзадача робить очікування, I/O або цикл, їй потрібні точки виходу. Ідеальний мінімум — select між «робота завершилася» і «контекст скасовано», як у прикладі зі Step. А повертати варто саме ctx.Err(), щоб причина була стандартною, а той, хто викликає, міг розрізнити скасування та дедлайн.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ