Каналы в Go: скрытая стоимость и подводные камни
Незакрытый канал держит горутину живой, буфер прячет дедлок до пиковой нагрузки, а select с default незаметно превращает вашу очередь в busy-loop. Разбираем, где каналы стоят дороже, чем кажется.

Сервис на Go начал течь по памяти: за сутки под ровной нагрузкой RSS вырос с 180 МБ до 1,4 ГБ, число горутин по runtime.NumGoroutine() — с 400 до 60 тысяч. Причина оказалась не в утечке в привычном смысле: горутины блокировались на записи в канал, у которого больше не было читателя. Канал не собрался сборщиком мусора, потому что на него ссылались живые горутины, а горутины не завершались, потому что ждали канал. Классический цикл, который в Go выглядит безобидно и стоит дорого.
Разбираем не «как работают каналы» — про это есть модель памяти — а где они обходятся дороже, чем ждёшь: по памяти, по латентности и по времени отладки. Примеры для Go 1.22, поведение планировщика и каналов между 1.20–1.22 не менялось.
Стоимость самого канала
Канал — это не «бесплатный примитив синхронизации». За каждым make(chan T, n) стоит структура hchan в куче: кольцевой буфер на n элементов, два мьютекса-подобных состояния, две очереди ожидающих горутин (sendq и recvq). Небуферизированный канал тоже аллоцирует hchan, просто без буфера.
Практические следствия, которые видно на профиле:
- Отправка и приём проходят через блокировку внутри рантайма. При высокой конкуренции на один канал это точка сериализации — все горутины выстраиваются в очередь на одном мьютексе.
- Небуферизированный канал — это рандеву: отправитель блокируется, пока приёмник не заберёт значение. Каждая передача — минимум одно переключение горутин, а под нагрузкой возможно и снятие горутины с процессора планировщиком.
- Буфер не делает канал «асинхронным». Он лишь откладывает блокировку до момента, когда буфер заполнится. Канал на 1000 элементов под постоянной перегрузкой ведёт себя как небуферизированный, только жрёт память на буфер.
Канал решает задачу передачи владения и синхронизации. Если вам нужен просто счётчик или флаг —sync/atomicилиsync.Mutexобойдутся в разы дешевле и не потянут за собой горутину.
Подводный камень первый: горутина, повисшая на канале
Утечка горутин — самая частая скрытая цена каналов. Горутина, заблокированная на ch <- v или <-ch, которую никто не разблокирует, живёт вечно. Сборщик мусора её не тронет: она в состоянии waiting, но достижима планировщиком.
Типичный источник — паттерн «читаем первый ответ, остальные бросаем»:
// ПЛОХО: небуферизированный канал + ранний выход
func firstResult(urls []string) string {
ch := make(chan string) // буфера нет
for _, url := range urls {
go func(u string) {
ch <- fetch(u) // блокируется навсегда, если результат уже не читают
}(url)
}
return <-ch // забрали первый, про остальные горутины забыли
}После return в канал больше никто не читает. Все горутины, кроме первой успевшей, навсегда зависают на ch <- fetch(u). Каждая держит стек (минимум пара килобайт) и результат fetch. Под трафиком это и есть тот рост с 400 до 60 тысяч горутин.
Лечится буфером по числу отправителей — тогда запись никогда не блокируется:
// ХОРОШО: буфер вмещает все ответы, отправитель не блокируется
func firstResult(urls []string) string {
ch := make(chan string, len(urls)) // места хватит всем
for _, url := range urls {
go func(u string) {
ch <- fetch(u) // запись всегда проходит
}(url)
}
return <-ch
}Второй вариант — передавать context и завершать горутины по отмене. Но в примере с «первым результатом» буфер проще и надёжнее: не нужно прокидывать отмену в fetch.
Подводный камень второй: буфер прячет дедлок до пика
Буферизированный канал опасен тем, что маскирует проблему на тестах и малой нагрузке. Пока продюсер медленнее консьюмера, буфер не заполняется, и запись мгновенна. Логическая ошибка — например, консьюмер иногда не читает канал — не проявляется, пока буфер не переполнится. А переполняется он на проде в час пик.
Правило: буфер выбирают под конкретную причину, а не «чтобы было побыстрее». Осмысленные размеры буфера:
| Размер | Когда оправдан | Чем платишь |
|---|---|---|
| 0 (небуферизированный) | Нужна гарантия, что получатель принял значение | Переключение горутин на каждой передаче |
| 1 | Развязать продюсера и консьюмера на один шаг, сигнал «сделано» | Почти ничего, но и почти не помогает пропускной способности |
| N = число отправителей | Fan-in: собрать по одному результату с каждой горутины без блокировок | Память на N элементов |
| Большой (сотни–тысячи) | Сгладить пики, если консьюмер иногда отстаёт, но в среднем успевает | Память; латентность растёт незаметно, дедлок откладывается до заполнения |
Подводный камень третий: select с default как busy-loop
Конструкция select с веткой default не блокируется. Если её вызывать в цикле без паузы, вы получаете 100% одного ядра на пустое кручение:
// ПЛОХО: сжигает ядро, когда канал пуст
for {
select {
case msg := <-ch:
handle(msg)
default:
// канал пуст — сразу новый виток, CPU в потолок
}
}Симптом на проде: один worker стабильно ест целое ядро при нулевой полезной работе. В go tool pprof по CPU-профилю верхушку займёт сам select. Если неблокирующая проверка действительно нужна, добавляйте таймер или откажитесь от default в пользу блокирующего чтения. Блокирующий select без default снимает горутину с процессора — ядро свободно.
Подводный камень четвёртый: закрытие канала
Кто закрывает канал — источник половины паник в конкурентном коде. Правила рантайма жёсткие:
- Запись в закрытый канал —
panic: send on closed channel. - Повторный
close— тоже паника. - Чтение из закрытого канала возвращает нулевое значение типа и
ok == false, не блокируется. Отсюда мода наfor v := range ch, который завершится при закрытии.
Из этого следует правило владения: закрывает канал всегда отправитель, и только когда отправителей ровно один. Если отправителей несколько, координируйте закрытие через sync.WaitGroup — ждёте, пока все продюсеры договорят, и только потом закрываете:
// Несколько продюсеров, один потребитель
func merge(sources []<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, src := range sources {
wg.Add(1)
go func(s <-chan int) {
defer wg.Done()
for v := range s {
out <- v
}
}(src)
}
// отдельная горутина закрывает out, когда все продюсеры завершились
go func() {
wg.Wait()
close(out)
}()
return out
}Как поймать это в работающей системе
Каналы текут тихо, поэтому мерить нужно заранее, а не после инцидента.
- Экспортируйте
runtime.NumGoroutine()в метрики. Ровный график, который монотонно ползёт вверх, — почти всегда повисшие на канале горутины. - Включите
net/http/pprofи снимайте/debug/pprof/goroutine?debug=2— он покажет полные стеки всех горутин с указанием, на какой строке и на каком канале каждая заблокирована (chan send,chan receive). - Для гонок на этапе тестов гоняйте с
go test -race. Он не ловит утечки горутин, но ловит несогласованный доступ вокруг каналов. - Утечки горутин между тестами проверяет пакет
go.uber.org/goleak— вызовgoleak.VerifyNone(t)в конце теста упадёт, если после теста остались лишние горутины.
Начните с двух дешёвых шагов: метрика числа горутин на дашборде и pprof-эндпоинт за приватным портом. Дальше смотрите на график горутин под реальным пиком — именно там буфер, который казался достаточным, заполняется первым.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: The Go Memory Model, Effective Go — Concurrency
Частые вопросы
А если у меня Go 1.20 или 1.21, примеры сработают?
Буфер побольше ведь ускорит обмен — почему это плохо?
Как понять, что горутины текут именно из-за каналов, а не из-за чего-то ещё?
Не сработало: паника send on closed channel, хотя закрываю канал сам
Чем заменить канал, если мне нужен просто флаг или счётчик?
Опасно ли select с default в проде сам по себе?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.