Модуль: 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)#

  1. nil-канал → блок навсегда (gopark).
  2. Берётся lock.
  3. Есть ждущий получатель (recvq непуст): значение копируется напрямую в стек получателя (sendsendDirect), минуя буфер. Получатель помечается готовым (goready). Это оптимизация: одно копирование.
  4. Есть место в буфере: копируем в buf[sendx], двигаем sendx, qcount++.
  5. Иначе: отправитель оборачивается в sudog, кладётся в sendq, паркуется. Разбудит его получатель.
  6. Закрыт → паника send on closed channel.

Приём (chanrecv)#

  1. nil-канал → блок навсегда.
  2. Закрыт и буфер пуст → возвращает нулевое значение, ok=false, не блокирует.
  3. Есть ждущий отправитель: для небуферизованного — копия напрямую от него; для буферизованного — берём из головы буфера, а ждущего отправителя дописываем в хвост (сохраняя FIFO).
  4. Есть данные в буфере → берём, recvx++, qcount--.
  5. Иначе → паркуемся в 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 и идиоматические способы их исключить архитектурно.