Модуль: Concurrency · Уровень: Middle+/Senior
TL;DR#
Небуферизованный канал (make(chan T)) — это rendezvous: отправка и приём происходят одновременно, давая строгую синхронизацию (happens-before). Буферизованный (make(chan T, n)) развязывает отправителя и получателя на n элементов, давая пропускную способность и backpressure при заполнении. Размер буфера — это про производительность и поток, не про корректность.
Теория#
Семантика синхронизации#
- Небуферизованный:
ch <- vзавершается только когда другая горутина выполнила<-ch. Это точка синхронизации — гарантия, что приём начался. Memory model: запись отправителя доch<-vвидна получателю после<-ch. - Буферизованный:
ch <- vзавершается, как только значение положено в буфер (если есть место), не дожидаясь получателя. Happens-before связывает k-ю отправку с (k+cap)-м приёмом (классическое правило из Go Memory Model для ограниченной семафорной семантики).
done := make(chan struct{}) // unbuffered = сигнал «готово»
go func() {
work()
done <- struct{}{} // дождётся, пока main прочитает → синхронизация
}()
<-doneГарантии Go Memory Model (дословно по смыслу)#
- Отправка в канал happens-before завершения соответствующего приёма.
- Закрытие канала happens-before приёма, вернувшего нулевое значение из-за закрытия.
- Для небуферизованного: приём happens-before завершения отправки (приём «начинается раньше»).
- k-я отправка в канал ёмкости C happens-before завершения (k+C)-го приёма.
Правило 3 уникально для небуферизованного — поэтому он сильнее как примитив синхронизации.
Backpressure#
Буферизованный канал — естественный механизм обратного давления: пока буфер не заполнен, отправители не блокируются; при заполнении — блокируются, замедляя продюсера до темпа консьюмера. Это защищает от неограниченного роста очереди и OOM. Размер буфера = допустимый «люфт» между скоростями.
Когда какой#
| Цель | Выбор |
|---|---|
| Гарантия «получатель принял» / сигнал | небуферизованный |
| Сброс пиков, сглаживание скорости | буферизованный с продуманным n |
| Семафор/ограничение параллелизма | буферизованный размером = лимит |
| Конвейер с известной батч-частью | буфер = размер батча |
| Завершение/оповещение многих | небуферизованный + close |
// семафор на 5 одновременных операций
sem := make(chan struct{}, 5)
for _, job := range jobs {
sem <- struct{}{} // занять слот (блок если 5 заняты)
go func(j Job) {
defer func() { <-sem }() // освободить
process(j)
}(job)
}Производительность#
- Небуферизованный требует rendezvous → больше парковок/пробуждений при несовпадении темпа.
- Маленький буфер (1) часто убирает лишнюю синхронизацию для паттерна «продюсер чуть впереди».
- Слишком большой буфер маскирует проблемы скорости, прячет backpressure и ест память — антипаттерн.
Подводные камни / gotchas#
- «Добавлю буфер побольше, чтобы не блокировалось» — антипаттерн: теряете backpressure, рискуете OOM и скрываете медленного консьюмера.
- Буфер ≠ гарантия обработки: значение в буфере может никогда не быть прочитано, если консьюмер умер.
len(ch)/cap(ch)информативны, но гонко-нестабильны: между проверкой и действием состояние меняется. Не строить на них логику.- Небуферизованный для fire-and-forget заблокирует отправителя, если получателя нет — частая причина дедлока/утечки.
- Буфер 1 как «mailbox»: полезен, но при нескольких отправителях нужна аккуратность.
- Размер буфера фиксирован на весь срок жизни канала — нельзя изменить.
Вопросы на собеседовании#
В: В чём принципиальная разница в синхронизации? О: Небуферизованный — rendezvous: отправка не завершится без одновременного приёма, давая двустороннюю синхронизацию (и приём happens-before завершения отправки). Буферизованный развязывает их: отправка завершается при наличии места, синхронизация слабее (k-я отправка happens-before (k+cap)-го приёма).
В: Когда выбрать небуферизованный? О: Когда нужна гарантия, что получатель принял (сигналы готовности, синхронные хэндшейки, оповещение через close), или строгий happens-before. Это самый сильный примитив синхронизации среди каналов.
В: Как канал даёт backpressure? О: Буферизованный блокирует отправителя при полном буфере, замедляя продюсера до темпа консьюмера и ограничивая память. Небуферизованный — крайний случай backpressure (буфер 0): продюсер всегда ждёт консьюмера.
В: Как сделать семафор на каналах?
О: make(chan struct{}, N): занять слот отправкой перед работой, освободить приёмом в defer. Блокировка при N занятых ограничивает параллелизм без мьютексов.
В: Почему большой буфер — плохо? О: Он скрывает дисбаланс скоростей, отключает раннее backpressure, увеличивает задержку реакции на проблему и расход памяти, может привести к OOM. Размер буфера надо обосновывать профилем нагрузки.
В: Можно ли по len(ch) принимать решения?
О: Только для метрик/диагностики. Значение мгновенно устаревает из-за конкурентного доступа; логика на нём содержит гонку TOCTOU.
В: Какой happens-before у закрытия? О: Закрытие канала happens-before приёма, который из-за закрытия вернул нулевое значение. Поэтому close — корректный способ broadcast-сигнала: данные, записанные до close, видны получателям.
В: Буфер какого размера для конвейера? О: Обычно небольшой (0–несколько) — чтобы сохранять backpressure между стадиями. Иногда размер = размер батча или = числу воркеров следующей стадии. Большие буферы между стадиями обычно прячут узкое место.
На что копают на senior+#
- Точные формулировки Go Memory Model для каналов и почему небуферизованный сильнее.
- Семафорный паттерн и его эквивалентность
golang.org/x/sync/semaphore(взвешенный семафор). - Поведение под нагрузкой: парковки/пробуждения, влияние
runnextдля unbuffered. - Проектирование backpressure через каналы vs явные ограничители, и где каналы не масштабируются (нужен weighted semaphore/rate limiter).
- Тонкости: буфер 1 как способ избежать лишней синхронизации в hot path и его взаимодействие со
select.