Опирается на правила: GO-7.1GO-7.6, GO-7.X1, GO-7.X2 из Go Style Guide → раздел 7. Конкурентность и горутины.

Важно знать

  • Каждая горутина, запущенная сервисом, обязана завершиться при shutdown — через отмену ctx или close(stopCh).
  • go func() без механизма ожидания завершения — goroutine leak (GO-7.X1).
  • sync.Mutex / sync.RWMutex — для защиты разделяемого состояния; sync.Map — только при high-contention read-heavy нагрузке.
  • Каналы предпочтительнее мьютексов, когда нужно передать владение данными между горутинами.
  • golang.org/x/sync/errgroup заменяет ручной sync.WaitGroup при fan-out с обработкой ошибок.
  • golang.org/x/sync/semaphore реализует булкхед — ограничение числа параллельных IO-вызовов.
  • go test -race — обязателен в CI; локально — минимум на integration-тестах.
  • В закрытый канал нельзя писать — panic; закрывает только отправитель, не получатель (GO-7.X2).

Конкурентность в Go — не просто синтаксис go func(). Это явный контракт: запустил горутину — обязан контролировать её жизненный цикл. Утечка горутины при production-нагрузке проявляется как медленный рост памяти без явной причины, а race condition при параллельных запросах — как нестабильный баг, который не воспроизводится в тестах без -race.

Управление жизненным циклом горутины

GO-7.1: горутина, запущенная сервисом, завершается при shutdown.

Самый простой паттерн — отмена через ctx:

func (s *OrderProcessor) Start(ctx context.Context) {
    go func() {
        for {
            select {
            case <-ctx.Done():
                return
            case order := <-s.queue:
                s.process(order)
            }
        }
    }()
}

Когда горутина не слушает канал (например, polling с интервалом), проверяй ctx.Err() в цикле явно (GO-6.5):

func (s *ProductSyncService) runPoller(ctx context.Context) {
    ticker := time.NewTicker(s.interval)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            if err := s.sync(ctx); err != nil {
                s.log.Error("sync failed", slog.String("err", err.Error()))
            }
        }
    }
}

Альтернатива — явный stop-канал, если горутина запускается вне context-дерева (редко, но бывает в тестах или утилитах):

stopCh := make(chan struct{})
go func() {
    defer close(stopCh)
    for {
        select {
        case <-stopCh:
            return
        // ...
        }
    }
}()

Защита разделяемого состояния

GO-7.2: sync.Mutex / sync.RWMutex для разделяемого состояния.

Типичный сценарий — кэш с конкурентным чтением и редкой записью:

type ProductCache struct {
    mu    sync.RWMutex
    items map[string]*Product
}

func (c *ProductCache) Get(id string) (*Product, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    p, ok := c.items[id]
    return p, ok
}

func (c *ProductCache) Set(id string, p *Product) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[id] = p
}

sync.Map уместен только при доказанном high-contention сценарии — когда ключи пишутся один раз, а читаются многократно и конкуренция высокая. В большинстве случаев обычная map под RWMutex понятнее и достаточно эффективна.

Каналы для передачи владения

GO-7.3: каналы предпочтительнее мьютексов, когда данные передаются от одной горутины к другой.

Паттерн pipeline — заказ создаётся в одной горутине, обогащается в другой:

func enrichOrders(ctx context.Context, in <-chan *Order) <-chan *Order {
    out := make(chan *Order)
    go func() {
        defer close(out)
        for order := range in {
            enriched := enrich(order)
            select {
            case out <- enriched:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

Владелец канала — тот, кто его создаёт и закрывает. Получатель никогда не закрывает входящий канал (GO-7.X2).

Fan-out с errgroup

GO-7.4: golang.org/x/sync/errgroup вместо ручного sync.WaitGroup при fan-out.

Загрузка деталей по нескольким заказам параллельно:

func (s *OrderService) LoadDetails(ctx context.Context, ids []string) ([]*OrderDetail, error) {
    g, ctx := errgroup.WithContext(ctx)
    results := make([]*OrderDetail, len(ids))

    for i, id := range ids {
        i, id := i, id
        g.Go(func() error {
            detail, err := s.repo.FindDetail(ctx, id)
            if err != nil {
                return fmt.Errorf("load detail %s: %w", id, err)
            }
            results[i] = detail
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return results, nil
}

errgroup.WithContext создаёт дочерний контекст, который отменяется при первой ошибке любой из горутин — остальные завершаются через ctx.Done().

Руководствуйся правилом GO-4.3: не более одного уровня вложенности горутин без явного ожидания. errgroup делает ожидание явным через g.Wait().

Булкхед через semaphore

GO-7.5: golang.org/x/sync/semaphore для ограничения параллельных IO-вызовов.

Без ограничения сотня параллельных запросов к внешнему платёжному шлюзу Sber перегрузит его или исчерпает пул соединений:

type PaymentGateway struct {
    sem *semaphore.Weighted
    // ...
}

func NewPaymentGateway(maxConcurrent int64) *PaymentGateway {
    return &PaymentGateway{
        sem: semaphore.NewWeighted(maxConcurrent),
    }
}

func (g *PaymentGateway) Charge(ctx context.Context, req *ChargeRequest) (*ChargeResult, error) {
    if err := g.sem.Acquire(ctx, 1); err != nil {
        return nil, fmt.Errorf("acquire semaphore: %w", err)
    }
    defer g.sem.Release(1)

    return g.doCharge(ctx, req)
}

Acquire блокируется до получения слота или отмены контекста — запросы, пришедшие сверх лимита, встают в очередь, а не отказывают сразу. Если нужно отказывать немедленно, используй TryAcquire.

Детектор гонок в CI

GO-7.6, GO-10.7: go test -race обязателен.

Race detector в Go встроен в toolchain — не требует внешних инструментов. Включается флагом -race и инструментирует все обращения к памяти из горутин:

go test -race ./...

В CI достаточно одного прогона с -race. Локально — запускай хотя бы для integration-тестов с реальными горутинами. Юнит-тесты без разделяемого состояния можно и без него, но проще включить везде.

Классический баг, который ловит detector — захват переменной цикла в горутину (GO-4.X2):

for i := 0; i < 10; i++ {
    i := i
    go func() {
        process(i)
    }()
}

В Go 1.22+ переменные range-цикла (for _, v := range slice) создаются заново на каждой итерации, и старый баг с захватом уходит. Но for i := 0; i < n; i++ по-прежнему требует явного копирования.

Что запрещено

АнтипаттернПравилоЧто взамен
go func() { ... }() без ожидания завершенияGO-7.X1errgroup.Go или явный sync.WaitGroup
for { select { ... } } без case <-ctx.Done()GO-4.X1добавить ветку завершения по контексту
Запись в канал после close(ch)GO-7.X2закрывает только отправитель; получатель читает до drain
Ручной sync.WaitGroup при fan-out с ошибкамиGO-7.4golang.org/x/sync/errgroup
sync.Map для обычного кэшаGO-7.2обычная map под sync.RWMutex
Захват переменной for i := 0; ... в горутинуGO-4.X2i := i перед go func()
Отсутствие go test -race в CIGO-7.6добавить флаг в pipeline

Куда дальше

  • context.Context — context.Context как инструмент управления отменой и таймаутами IO-вызовов.
  • Управляющие структуры — guard clause, уровень вложенности, defer для ресурсов.
  • golangci-lint — настройка staticcheck, gocritic и других линтеров в CI.
  • Resilience → булкхед и circuit breaker — паттерны защиты от перегрузки на уровне архитектуры.