Конкурентность в Go: горутины, каналы и select на практике
Горутины дёшевы — стартовый стек занимает около 2–8 КБ, и запустить сто тысяч штук на ноутбуке реально. Но дешевизна запуска не означает, что конкурентный код пишется сам собой: утечки горутин, блокирующие каналы и забытый default в select ломают продакшен не реже, чем гонки данных.

Зачем вообще отдельная модель конкурентности
В большинстве языков конкурентность строится на потоках ОС: каждый поток — это мегабайт стека и переключение контекста через ядро. Go пошёл другим путём: пользовательские горутины мультиплексируются на пул системных потоков рантаймом (модель M:N). Планировщик Go (work-stealing, кооперативный с Go 1.14+ и асинхронным вытеснением) решает, какую горутину поставить на какой поток, без системного вызова. Результат — переключение стоит десятки наносекунд вместо микросекунд, а стартовый стек горутины начинается с нескольких килобайт и растёт по мере необходимости.
Каналы и select — это не синтаксический сахар, а примитивы синхронизации, встроенные в язык. Они заменяют значительную часть мьютексов и условных переменных, но не все. Понимание, где канал уместен, а где проще взять sync.Mutex, — навык, который отличает рабочий конкурентный код от красивого, но ломающегося.
Горутины: как запускать и не терять
Запуск
Горутина создаётся ключевым словом go перед вызовом функции. Никакого пула горутин «из коробки» нет — рантайм сам решает, сколько системных потоков задействовать (по умолчанию GOMAXPROCS равен числу доступных CPU).
// Go 1.22+
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := range 10 {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("горутина", i)
}()
}
wg.Wait() // ждём завершения всех горутин
}
Начиная с Go 1.22 переменная цикла i в for i := range 10 создаётся заново на каждой итерации, поэтому старая проблема захвата замыканием одной и той же переменной здесь не возникает. В версиях до 1.22 нужно было передавать i аргументом в анонимную функцию.
Главная ошибка: утечка горутин
Горутина живёт, пока не вернётся. Если она заблокирована на чтении из канала, в который никто не напишет, — она останется в памяти навсегда. Рантайм не убивает горутины извне. Типичный сценарий:
// Утечка: канал никто не закроет и не напишет в него
func leaky() {
ch := make(chan int)
go func() {
val := <-ch // заблокирована навсегда
fmt.Println(val)
}()
// функция вернулась, горутина осталась
}
Правило: у каждой горутины должен быть способ завершиться. Обычно это context.Context с отменой или закрытие канала.
Каналы: буферизованные и нет
Канал — типизированная очередь с блокировкой. Без буфера (make(chan T)) отправитель блокируется, пока получатель не прочитает. С буфером (make(chan T, N)) отправитель блокируется только когда буфер полон.
| Свойство | Небуферизованный | Буферизованный (размер N) |
|---|---|---|
| Отправка блокирует | Пока кто-то не прочитает | Пока буфер полон |
| Чтение блокирует | Пока кто-то не отправит | Пока буфер пуст |
| Гарантия синхронности | Да — рандеву | Нет — отправитель может убежать вперёд |
| Типичное применение | Сигналы, передача владения | Пул задач, ограничение параллельности |
Закрытие канала
Закрывает канал только отправитель. Чтение из закрытого канала немедленно возвращает нулевое значение типа. Повторное закрытие — паника. Если несколько горутин пишут в один канал, закрывать его удобнее через sync.WaitGroup и отдельную горутину-координатор:
// Go 1.22+
func fanIn(sources ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, src := range sources {
wg.Add(1)
go func() {
defer wg.Done()
for v := range src {
out <- v
}
}()
}
go func() {
wg.Wait()
close(out) // безопасно: все писатели завершились
}()
return out
}
select: мультиплексор каналов
select ждёт первую готовую операцию из нескольких каналов. Если готовы несколько — выбирает случайно (это не баг, а осознанное решение дизайнеров языка для предотвращения голодания). Если ни одна не готова и есть default — выполняет default без блокировки.
func worker(ctx context.Context, jobs <-chan Job, results chan<- Result) {
for {
select {
case <-ctx.Done():
return // корректное завершение по отмене контекста
case job, ok := <-jobs:
if !ok {
return // канал закрыт, задач больше нет
}
results <- process(job)
}
}
}
Таймауты через select
Классический паттерн — ограничение времени ожидания без внешних библиотек:
select {
case res := <-longOperation:
fmt.Println("результат:", res)
case <-time.After(3 * time.Second):
fmt.Println("таймаут")
}
Осторожно: time.After в цикле создаёт новый таймер на каждой итерации, и старые не освобождаются до срабатывания. В горячем цикле используйте time.NewTimer с ручным Reset.
Не общайтесь через разделяемую память — разделяйте память через общение. Этот принцип из документации Go звучит как лозунг, но на практике означает конкретное архитектурное решение: вместо мьютекса вокруг структуры данных передавайте саму структуру (или указатель на неё) через канал, и в каждый момент времени с ней работает ровно одна горутина.
Где подход ломается
Каналы — не универсальный ответ. Вот конкретные случаи, когда они мешают:
- Счётчик, обновляемый тысячами горутин. Канал с единственным читателем-аккумулятором работает, но
atomic.Int64быстрее на порядок и проще. - Кеш с частым чтением и редкой записью.
sync.RWMutexилиsync.Map(для специфических паттернов доступа) дадут лучшую пропускную способность, чем канал-сериализатор. - Fan-out на тысячи получателей. Один канал с тысячей читателей — каждое сообщение попадает только к одному. Для широковещания нужна своя рассылка по слайсу каналов или
sync.Cond. - Обратное давление (backpressure). Буферизованный канал даёт примитивное ограничение, но не даёт метрик заполненности и приоритетов. Для серьёзной очереди задач нужна дополнительная логика поверх канала или внешний брокер.
Паттерн: ограничение параллельности через семафорный канал
Если запускать по горутине на каждый входящий запрос, при пиковой нагрузке можно исчерпать файловые дескрипторы или подключения к базе. Буферизованный канал работает как семафор:
// Не более 10 одновременных обработчиков
sem := make(chan struct{}, 10)
for _, task := range tasks {
sem <- struct{}{} // займём слот; заблокируемся, если все 10 заняты
go func(t Task) {
defer func() { <-sem }() // освободим слот
handle(t)
}(task)
}
Альтернатива — пакет golang.org/x/sync/semaphore с поддержкой контекста и весов. Выбор зависит от того, нужна ли вам отмена ожидания по контексту.
Детектор гонок
Go поставляется с встроенным детектором гонок данных. Запуск:
go test -race ./...
go run -race main.go
Детектор замедляет выполнение в 5–10 раз и увеличивает потребление памяти, поэтому в CI его обычно включают на прогонах тестов, а не в продакшене. Но пропускать его — рискованно: гонки данных в Go приводят к неопределённому поведению, и компилятор вправе генерировать любой результат, если гонка есть.
Сноска: Гонка данных (data race) — ситуация, когда две или более горутин одновременно обращаются к одной переменной, и хотя бы одна из них пишет. Это не то же самое, что «состояние гонки» (race condition): гонка данных — о памяти, race condition — о логике. Можно иметь race condition без data race (при корректной синхронизации, но неверном порядке действий) и наоборот.
Что делать дальше
- Запустите
go test -raceна текущем проекте — даже если «всё работает». Детектор часто находит гонки, которые пока не проявились. - Изучите пакет
golang.org/x/sync— тамerrgroup(группа горутин с общей ошибкой и контекстом),singleflight(дедупликация одинаковых запросов) иsemaphore. - Профилируйте горутины:
runtime.NumGoroutine()и endpoint/debug/pprof/goroutineвnet/http/pprofпокажут утечки до того, как они станут инцидентом. - Прочитайте статью «Go Concurrency Patterns» в официальном блоге Go — она по-прежнему актуальна, хотя написана для ранних версий; принципы не изменились.
Эмпатичный слушатель: Поддержка, снятие тревожности, дыхательные практики.
Спросить за 8 ₽Источники: Effective Go — Concurrency (официальная документация Go), The Go Memory Model (спецификация модели памяти Go)
Частые вопросы
Сколько горутин можно запустить одновременно?
Горутина зависла — как её остановить извне?
Когда использовать канал, а когда sync.Mutex?
Что будет, если записать в закрытый канал?
Флаг -race замедляет программу. Можно ли использовать его в продакшене?
select с default крутится в бесконечном цикле и грузит CPU — что делать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.