Каналы Go под нагрузкой: что ломается первым
Профиль показал 30% времени на runtime.chansend и mutex-контеншн вокруг канала. Разбираем, как устроен канал внутри, почему буфер не спасает и где паттерн с одним каналом упирается в потолок.

Типичная картина: сервис на Go 1.22 держал 5000 rps, добавили ещё один воркер-пул через общий канал — и на 8000 rps профиль в go tool pprof показал, что 28% CPU-времени уходит в runtime.chansend и runtime.lock2. Канал — не бесплатная труба. Внутри это структура с мьютексом, кольцевым буфером и двумя очередями ожидания, и под нагрузкой первым ломается именно мьютекс, а не буфер, как многие думают.
Разбираем механизм на уровне runtime, смотрим, что происходит при отправке в буферизированный и небуферизированный канал, и где паттерн «один канал на всех» перестаёт масштабироваться. Инвалидация goroutine leak и graceful shutdown — отдельная тема, здесь только производительность передачи.
Что такое канал на уровне runtime
За каналом стоит структура hchan (файл runtime/chan.go в исходниках Go). Её ключевые поля важны для понимания стоимости:
buf— указатель на кольцевой буфер элементов; существует только у буферизированного канала;qcount,dataqsiz— сколько элементов в буфере сейчас и его размер;sendx,recvx— индексы записи и чтения в кольце;recvq,sendq— очереди заблокированных goroutine, ждущих приёма и отправки;lock— мьютекс, который защищает всю структуру целиком.
Последний пункт — корень всех проблем масштабирования. Любая операция с каналом — send, recv, len, cap в конкурентном контексте — берёт один и тот же lock. Канал не lock-free. Это осознанный выбор авторов runtime: простой мьютекс предсказуемее и в большинстве сценариев быстрее сложных lock-free-конструкций. Но под высокой конкуренцией он становится точкой сериализации.
Буфер канала уменьшает число блокировок, но не устраняет мьютекс. Если у вас 200 goroutine долбят в один канал, они конкурируют за один lock независимо от того, буфер там на 1 элемент или на 100000.
Что происходит при отправке
Логика chansend примерно такая (схематично, порядок проверок по исходникам runtime):
- Быстрая проверка без блокировки: если канал не закрыт и заведомо не готов принять — можно быстро уйти в select с default. Иначе берётся
lock. - Есть ли ждущий получатель в
recvq? Если да — элемент копируется напрямую в стек получателя, минуя буфер, получатель размечается готовым к запуску. Это самый дешёвый путь. - Нет получателя, но в буфере есть место? Элемент копируется в
bufпо индексуsendx,qcount++, мьютекс отпускается. Тоже дёшево. - Нет ни того, ни другого? Текущая goroutine оборачивается в
sudog, ставится вsendqи паркуется черезgopark. Это дорого: планировщик снимает goroutine с потока.
Ключевой вывод: небуферизированный канал всегда идёт либо по пути 2 (есть получатель), либо по пути 4 (парковка). Промежуточного дешёвого буфера у него нет. Поэтому небуферизированный канал под нагрузкой без готового получателя генерирует лавину парковок и пробуждений goroutine.
Замеры: что и как мерить
Точные цифры зависят от железа, числа P (GOMAXPROCS) и характера полезной нагрузки, поэтому приводить абсолютные наносекунды бессмысленно — намеряете своё. Мерить нужно так:
// bench_test.go, Go 1.22
// go test -bench=. -benchmem -cpu=1,4,8 -benchtime=3s
package chanbench
import "testing"
// Отправка в буферизированный канал с готовым получателем
func BenchmarkChanBuffered(b *testing.B) {
ch := make(chan int, 64)
done := make(chan struct{})
go func() {
for range ch {
}
close(done)
}()
b.ResetTimer()
for i := 0; i < b.N; i++ {
ch <- i
}
close(ch)
<-done
}
// Небуферизированный канал: каждая отправка ждёт получателя
func BenchmarkChanUnbuffered(b *testing.B) {
ch := make(chan int)
done := make(chan struct{})
go func() {
for range ch {
}
close(done)
}()
b.ResetTimer()
for i := 0; i < b.N; i++ {
ch <- i
}
close(ch)
<-done
}
Запуск с -cpu=1,4,8 — самое важное. На одном ядре разницы между буферами вы почти не увидите: конкуренции за lock нет. Разница вылезает, когда P растёт и несколько потоков реально дерутся за мьютекс. Чтобы увидеть контеншн явно, собирайте mutex-профиль:
// в начале программы или в тесте
import "runtime"
func init() {
runtime.SetMutexProfileFraction(5) // сэмплировать 1 из 5 событий контеншена
}
// снять профиль:
// go test -bench=BenchmarkChanBuffered -mutexprofile=mutex.out
// go tool pprof mutex.out
Если в топе pprof висит runtime.chansend / runtime.chanrecv с большой долей — вы упёрлись в hchan.lock, а не в бизнес-логику.
Где ломается «один канал на всех»
Классический fan-out: один producer шлёт задачи в один канал, N воркеров читают. Пока задач немного, работает идеально. Проблемы начинаются, когда либо producer'ов много, либо воркеров десятки и задачи короткие.
Симптом: рост latency при росте числа воркеров вместо ускорения. Причина: короткая задача означает, что воркер быстро возвращается к каналу за следующей, и все воркеры одновременно борются за lock. Мьютекс сериализует их, реальная параллельность падает. Вы добавили ядра, а получили конкуренцию.
Правило большого пальца: чем короче обработка одного элемента относительно стоимости передачи через канал, тем раньше канал станет бутылочным горлышком. Канал хорош для передачи крупных единиц работы, плох как высокочастотная очередь мелких сообщений.
Что с этим делать
| Подход | Что выигрываете | Чем платите |
|---|---|---|
| Увеличить буфер одного канала | Меньше парковок при всплесках | Мьютекс тот же; не спасает от контеншена, съедает память |
Батчинг: слать срез []Task вместо Task | Одна блокировка на N элементов вместо N блокировок | Сложнее код, растёт latency первого элемента в батче |
| Sharding: N каналов, воркер закреплён за своим | Каждый канал — свой lock, контеншн распределён | Нужна балансировка по каналам, возможен перекос |
| Заменить на lock-free-очередь (sync/atomic, sharded ring) | Нет мьютекса в горячем пути | Много кода, легко получить тонкие баги; select уже не применить |
На практике батчинг и шардинг закрывают 90% случаев. Батчинг радикально меняет арифметику: если вы клали 1000 элементов поштучно — это 1000 захватов lock; если пачками по 100 — всего 10. Стоимость самой передачи падает на порядок, обработка при этом остаётся параллельной.
Граничные случаи, где интуиция подводит
- Закрытый канал. Чтение из закрытого канала возвращается мгновенно с нулевым значением и
ok == false— под мьютексом, но без парковки. Отправка в закрытый канал — паника. В горячем select это ловушка: закрытый канал вcaseстановится всегда-готовым и начинает крутить цикл вхолостую на 100% CPU. nil-канал. Отправка и чтение блокируются навсегда. В select это используют как приём отключения ветки, но случайныйnilвместо канала даёт вечный дедлок без ошибки.- Крупные элементы. Канал копирует значение целиком при каждой передаче. Канал
chan [4096]byteкопирует 4 КБ на каждый send. Передавайте указатели или мелкие структуры, а не большие массивы по значению. - select с несколькими готовыми case. Выбор псевдослучаен, не по порядку. Нельзя рассчитывать на приоритет ветки — под нагрузкой это ведёт к неожиданному распределению.
Что делать дальше
Не оптимизируйте вслепую. Соберите mutex-профиль (SetMutexProfileFraction + -mutexprofile) и block-профиль (SetBlockProfileRate) на нагрузке, близкой к боевой, и посмотрите реальную долю chansend/chanrecv в топе. Если её там нет — канал не ваша проблема, ищите дальше.
Если контеншн подтвердился: сначала попробуйте батчинг — он проще шардинга и почти всегда даёт больший выигрыш. Мерьте latency и throughput с -cpu, равным числу боевых ядер, а не на одном. И держите в голове главное: канал — это про корректную синхронизацию и читаемость, а не про максимальную пропускную способность. Когда упираетесь в предел, обычно правильнее пересмотреть гранулярность работы, чем воевать с runtime.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Go runtime source: chan.go, runtime package: SetMutexProfileFraction
Частые вопросы
Помогает ли увеличение буфера от контеншена мьютекса?
А если у меня Go 1.20 или другая версия — механизм тот же?
Что быстрее — буферизированный или небуферизированный канал?
Профиль показывает chansend в топе, а батчинг не помог. Что не так?
Стоит ли заменять каналы на lock-free-очередь в продакшене?
Почему select с закрытым каналом грузит CPU на 100%?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.