Модуль: 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 (дословно по смыслу)#

  1. Отправка в канал happens-before завершения соответствующего приёма.
  2. Закрытие канала happens-before приёма, вернувшего нулевое значение из-за закрытия.
  3. Для небуферизованного: приём happens-before завершения отправки (приём «начинается раньше»).
  4. 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.