Модуль: Concurrency · Уровень: Senior
TL;DR#
Канал — типизированная очередь с синхронизацией, представленная структурой runtime.hchan. Отправка/приём блокируют горутины через парковку (gopark); рантайм поддерживает очереди ожидающих отправителей/получателей и кольцевой буфер. Закрытый канал отдаёт оставшиеся значения, затем нулевые с ok=false; отправка в закрытый — паника; операции с nil-каналом блокируются навсегда.
Теория#
Структура hchan#
// src/runtime/chan.go (упрощённо)
type hchan struct {
qcount uint // элементов в буфере сейчас
dataqsiz uint // размер буфера (cap)
buf unsafe.Pointer // кольцевой буфер (только для буферизованных)
elemsize uint16
closed uint32
elemtype *_type
sendx uint // индекс записи в буфер
recvx uint // индекс чтения из буфера
recvq waitq // очередь ожидающих получателей (sudog)
sendq waitq // очередь ожидающих отправителей (sudog)
lock mutex // защищает ВСЕ поля
}Каждый блокирующийся актёр оборачивается в sudog (G + указатель на элемент). lock — это рантаймовый мьютекс (не sync.Mutex), под ним выполняются все операции с каналом.
Отправка (chansend)#
- nil-канал → блок навсегда (
gopark). - Берётся
lock. - Есть ждущий получатель (
recvqнепуст): значение копируется напрямую в стек получателя (send→sendDirect), минуя буфер. Получатель помечается готовым (goready). Это оптимизация: одно копирование. - Есть место в буфере: копируем в
buf[sendx], двигаемsendx,qcount++. - Иначе: отправитель оборачивается в
sudog, кладётся вsendq, паркуется. Разбудит его получатель. - Закрыт → паника
send on closed channel.
Приём (chanrecv)#
- nil-канал → блок навсегда.
- Закрыт и буфер пуст → возвращает нулевое значение,
ok=false, не блокирует. - Есть ждущий отправитель: для небуферизованного — копия напрямую от него; для буферизованного — берём из головы буфера, а ждущего отправителя дописываем в хвост (сохраняя FIFO).
- Есть данные в буфере → берём,
recvx++,qcount--. - Иначе → паркуемся в
recvq.
Закрытие (closechan)#
close(nil)→ паника.closeуже закрытого → паника.- Иначе: выставляет
closed=1, будит всех вrecvq(отдадут оставшиеся данные, затем нули) и всех вsendq(они получат панику). Поэтому закрывать должен только отправитель и только один.
Семантика чтения/записи#
| Операция | nil-канал | открытый пустой | закрытый |
|---|---|---|---|
<-ch (recv) | блок навсегда | блок | нулевое значение, ok=false |
ch <- v (send) | блок навсегда | блок (если нет места) | паника |
close(ch) | паника | ok | паника (double close) |
v, ok := <-ch
// ok == false ⇒ канал закрыт И опустошён
for v := range ch { ... } // выходит, когда канал закрыт и пустНаправленные каналы#
func producer(out chan<- int) { out <- 1 } // только отправка
func consumer(in <-chan int) { <-in } // только приёмНаправление — статическая проверка компилятора, в рантайме тот же hchan. Двунаправленный неявно конвертируется в направленный, обратно — нельзя. close допустим только на chan и chan<-, не на <-chan.
Паттерны владения#
- Один владелец-отправитель: тот, кто создал канал, тот пишет и закрывает. Получатели только читают. Закрытие — сигнал «данных больше не будет».
- Возврат
<-chanиз конструктора инкапсулирует владение: вызывающий не может ни писать, ни закрывать. - Не закрывать со стороны получателя и не закрывать с несколькими отправителями напрямую — нужен координатор (
sync.Onceна закрытие, или отдельный done-канал).
Подводные камни / gotchas#
- send on closed channel — паника, частый источник падений при гонке закрытия и отправки.
- double close — паника. Защита:
sync.Onceлибо архитектура с единственным владельцем. - range по незакрытому каналу → дедлок/утечка: цикл ждёт вечно.
- nil-канал в
selectотключает кейс (полезно), но прямой<-nilChвешает горутину навсегда — частая причина утечек. - Утечка отправителя: получатель ушёл, а отправитель навсегда заблокирован в
sendq. Используйтеselectсctx.Done(). - Буфер не гарантирует доставку: значения в буфере закрытого канала ещё читаются, но если получателей нет — теряются после сборки канала.
- Закрытие — это broadcast: удобный способ оповестить много получателей сразу (паттерн done-канала).
Вопросы на собеседовании#
В: Что внутри у канала?
О: Структура hchan: кольцевой буфер (buf, sendx, recvx, qcount), флаг closed, тип элемента, две очереди ожидающих (sendq/recvq из sudog) и рантайм-мьютекс lock, под которым выполняются все операции.
В: Что происходит при отправке, если есть ждущий получатель?
О: Значение копируется напрямую в стек получателя, минуя буфер (sendDirect), и получатель помечается готовым. Это экономит одно копирование и обеспечивает rendezvous для небуферизованного канала.
В: Чтение из закрытого канала?
О: Если в буфере остались данные — они отдаются по очереди. После опустошения — нулевое значение типа и ok=false, без блокировки. Поэтому for range корректно завершается на закрытом канале.
В: Отправка в закрытый и nil канал?
О: В закрытый — паника send on closed channel. В nil — блокировка навсегда (как и приём из nil). Это используют, чтобы динамически отключать кейсы в select, обнуляя канал.
В: Кто должен закрывать канал?
О: Отправитель-владелец, и только один раз. Получатели не закрывают. При нескольких отправителях нужен координатор (отдельный done-канал или sync.Once), иначе риск double close и send on closed.
В: Как избежать утечки заблокированного отправителя?
О: Оборачивать отправку в select с case <-ctx.Done() или done-каналом, чтобы при уходе получателя отправитель мог выйти. Либо гарантировать, что получатель всегда дочитывает.
В: Чем направленные каналы полезны?
О: Они выражают и принуждают контракт владения на уровне типов: функция, принимающая <-chan, не может писать или закрывать. Это ловит ошибки компиляцией и документирует роль.
В: Сохраняется ли FIFO при смешении буфера и ждущих отправителей?
О: Да. При приёме из буферизованного канала с непустым sendq рантайм отдаёт голову буфера, а значение ждущего отправителя дописывает в хвост буфера, сохраняя порядок.
На что копают на senior+#
- Роль
sudogи пулинг этих структур (p.sudogcache). - Почему
lock— рантайм-мьютекс, и как это взаимодействует с GC/преемпцией (на парковке безопасно). sendDirect/recvDirectи запись write-barrier при прямом копировании между стеками.- Поведение
selectповерхchansend/chanrecv(двухфазный протокол с блокировкой нескольких каналов в порядке адресов во избежание дедлока). - Гонки close vs send и идиоматические способы их исключить архитектурно.