Опирается на правила:
GO-7.1…GO-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.X1 | errgroup.Go или явный sync.WaitGroup |
for { select { ... } } без case <-ctx.Done() | GO-4.X1 | добавить ветку завершения по контексту |
Запись в канал после close(ch) | GO-7.X2 | закрывает только отправитель; получатель читает до drain |
Ручной sync.WaitGroup при fan-out с ошибками | GO-7.4 | golang.org/x/sync/errgroup |
sync.Map для обычного кэша | GO-7.2 | обычная map под sync.RWMutex |
Захват переменной for i := 0; ... в горутину | GO-4.X2 | i := i перед go func() |
Отсутствие go test -race в CI | GO-7.6 | добавить флаг в pipeline |
Куда дальше
- context.Context —
context.Contextкак инструмент управления отменой и таймаутами IO-вызовов. - Управляющие структуры — guard clause, уровень вложенности,
deferдля ресурсов. - golangci-lint — настройка
staticcheck,gocriticи других линтеров в CI. - Resilience → булкхед и circuit breaker — паттерны защиты от перегрузки на уровне архитектуры.