10.2 hchan: внутреннее устройство канала
10.1 рассмотрел каналы в языке с позиции CSP: канал — это явный передаточный
механизм для сообщений, объединяющий «коммуникацию» и «синхронизацию» в единое целое. Данный
раздел разбирает его изнутри. В рантайме канал представлен структурой hchan, весь секрет
которой сводится к единственной блокировке, кольцевому буферу и двум очередям ожидания.
Структура невелика, однако каждое поле существует по конкретной проектной причине. Стоит уяснить
эти немногие элементы — и вся последующая логика отправки, получения и select
(10.3–10.6 Модель памяти и эволюция в сторону lock-free)
окажется лишь «перемещением данных, парковкой и пробуждением горутин поверх этой единственной
картины».
Следуя установившейся манере книги при объяснении структур данных рантайма, приведённая ниже
структура является упрощённой схемой: в ней сохранены только проектно значимые поля,
а в комментариях поясняется, зачем каждое из них существует. Для полного определения сравните
с runtime/chan.go и runtime/runtime2.go.
10.2.1 Три инварианта: сначала зададим вопрос «почему»
Прежде чем разбирать поля по одному, выпишем три инварианта, изложенных в комментарии в начале
chan.go. Они выражают проектный замысел всей структуры и объясняют «почему именно так» лучше
любого описания полей:
- Хотя бы одна из очередей —
c.sendqилиc.recvq— пуста (единственное исключение: одна горутина заблокирована черезselectодновременно на стороне получения и отправки небуферизованного канала); - для буферизованного канала
c.qcount > 0означает, чтоc.recvqпуста; - для буферизованного канала
c.qcount < c.dataqsizозначает, чтоc.sendqпуста.
На обычном языке все три сводятся к одному утверждению: если в буфере ещё есть данные, ни один получатель не должен ждать; если в буфере ещё есть место, ни один отправитель не должен ждать. Ситуация, при которой непустой (или неполный) буфер соседствует с ожидающей стороной, означает, что сделка, которая могла быть совершена напрямую, совершена не была, — это ошибка. Вся реализация отправки/получения (10.3) делает ровно одно: постоянно поддерживает эти три инварианта, совершая сделку напрямую, когда это возможно, и лишь тогда уходя в очередь ожидания, когда прямая сделка невозможна. Читайте приведённые ниже поля, держа в голове этот замысел, и они перестанут быть сухим перечнем.
10.2.2 Упрощённая схема hchan
|
|
Поля делятся примерно на три группы. Пять полей — qcount, dataqsiz, buf, sendx,
recvx — вместе описывают кольцевой буфер (10.2.3); recvq и sendq — две очереди
ожидания (10.2.4); closed фиксирует состояние закрытия, а elemsize и elemtype —
метаданные для перемещения элементов. При этом dataqsiz никогда не изменяется после создания,
поэтому рантайм может читать его без блокировки, чтобы быстро определить, является ли канал
буферизованным (на этом основаны вспомогательные функции вроде full). Что касается elemtype,
он используется не только для копирования элементов по значению и активации барьеров записи, но
и передаётся аллокатору при создании для разметки buf сведениями о том, «какие позиции в этой
памяти содержат указатели». К этому мы вернёмся в 10.2.5. (В go1.26 в hchan также присутствуют
два поля — timer и bubble, — обслуживающие таймеры time и testing/synctest
соответственно. Они не относятся к основной теме данного раздела и в схему не включены.)
10.2.3 Кольцевой буфер: sendx и recvx — курсоры головы и хвоста
Буфер буферизованного канала представляет собой кольцевой буфер. buf указывает на
непрерывный участок памяти, способный вместить dataqsiz элементов; sendx и recvx — два
курсора: отправитель записывает элемент по позиции sendx и продвигает курсор вперёд,
получатель читает элемент по позиции recvx и также продвигает курсор, причём каждый из них
сбрасывается в 0, дойдя до конца. qcount фиксирует текущее число элементов, поэтому
«буфер полон» — это qcount == dataqsiz, «буфер пуст» — qcount == 0. Адрес i-го слота
вычисляется простой арифметикой указателей:
|
|
Почему кольцо, а не линейная очередь? Потому что отправка и получение в канале строго FIFO:
что отправлено первым — получено первым. В линейном массиве после каждого извлечения из головы
остаётся «дыра»: нужно либо сдвигать элементы, либо тратить память впустую; кольцевой буфер
позволяет каждому из концов двигаться по кругу, и постановка в очередь, и извлечение из неё
имеют сложность — инкремент курсора с переносом, без перемещения единого байта. На
рисунке ниже показан канал ёмкостью 6, хранящий 3 элемента; recvx указывает на элемент,
поставленный в очередь первым — он будет прочитан следующим; sendx указывает на следующий
пустой слот:
flowchart LR
subgraph BUF["buf: кольцевой буфер (dataqsiz = 6, qcount = 3)"]
direction LR
S0["слот 0<br/>пусто"]
S1["слот 1<br/>пусто"]
S2["слот 2<br/>пусто"]
S3["слот 3<br/>v0"]
S4["слот 4<br/>v1"]
S5["слот 5<br/>v2"]
end
SENDX["sendx = 0<br/>следующая позиция записи"] -.-> S0
RECVX["recvx = 3<br/>следующая позиция чтения"] -.-> S3
S5 -.->|"переход к началу"| S0
При отправке: записать по sendx, затем sendx = (sendx+1) % dataqsiz, затем qcount++;
при получении: прочитать по recvx, затем recvx = (recvx+1) % dataqsiz, затем qcount--.
Рантайм не вычисляет остаток от деления, а записывает это как
if sendx == dataqsiz { sendx = 0 }, экономя на делении. Возвращаясь к инвариантам из 10.2.1:
пока qcount находится между и dataqsiz, буфер ни полон, ни пуст, и отправка, и
получение завершаются на месте, а обе очереди ожидания в этот момент неизбежно пусты.
10.2.4 Очереди ожидания: FIFO-список из sudog
Когда буфер не может помочь — например, при отправке в полный канал, получении из пустого
канала или при работе с небуферизованным каналом, — текущая горутина должна «запарковаться»
и ждать появления кого-либо на другой стороне. Куда она паркуется? В recvq или sendq.
Тип обеих очередей — waitq, простой двусвязный список, хранящий голову и хвост:
|
|
Узлы списка — это sudog. Чтобы разобраться в каналах, без sudog не обойтись: он представляет
одну горутину, заблокированную на некотором канале, вместе с элементом, который она хочет
отправить или получить. Упрощённая схема:
|
|
Почему бы не поставить *g непосредственно в очередь, а не оборачивать её в sudog? Потому что
отношение «горутина заблокирована на канале» не является взаимно однозначным: одна и та же
горутина может быть одновременно запаркована на нескольких каналах внутри одного оператора
select, а один канал может иметь в очереди несколько горутин. sudog — это носитель «данного
конкретного экземпляра ожидания (горутина, канал)», и потому горутина может удерживать
несколько sudog одновременно. sudog не является исключительной принадлежностью каналов:
семафоры пакета sync (11.x) используют ту же структуру для постановки в
очередь, а ряд полей sudog, выходящих за рамки isSelect и success, предназначен для
семафоров и опущен в схеме. Рантайм переиспользует sudog через кеш на уровне P, избегая
частых аллокаций.
Поле elem заслуживает отдельного упоминания: оно указывает на «элемент для данной конкретной
отправки или получения», и эта память нередко находится прямо на стеке горутины. Это основа
одной из ключевых оптимизаций каналов: когда отправитель обнаруживает получателя, уже ожидающего
в recvq, он может скопировать данные непосредственно в elem на стеке получателя, минуя
промежуточный кольцевой буфер (подробности — в 10.3). Этим же объясняется,
почему поля sudog должны быть защищены блокировкой канала: и пробуждение другой стороны, и
запись в её стек должны выполняться при удержании блокировки.
Сама очередь строго FIFO. Постановка в очередь выполняется в хвост, извлечение — из головы:
|
|
Первым запаркованный — первым пробуждается; это гарантирует справедливость канальных операций:
ни один ожидающий не окажется в конце очереди бессрочно. Инвариант «хотя бы одна очередь пуста»
из 10.2.1 также получает здесь интуитивное объяснение: если в буфере есть свободный слот или
данные, вновь прибывший отправитель или получатель завершает сделку на месте и никогда не
доходит до шага постановки в очередь, — поэтому обе очереди никогда не бывают непусты
одновременно (за исключением особого случая двойной парковки в select).
10.2.5 makechan: одна аллокация и оптимизация noscan
make(chan T, n) транслируется компилятором в вызов makechan(t, n). Его ключевая задача —
вычислить необходимый буферу объём памяти, запросить её из кучи и заполнить метаданные. Канал
всегда аллоцируется в куче и освобождается GC; именно поэтому отсутствие явного вызова close
не приводит к утечке памяти (закрытие и освобождение — две разные операции). Примечательно,
что функция разбивается на три стратегии аллокации в зависимости от типа элемента:
|
|
Граница между тремя ветками определяется вопросом «нужно ли GC сканировать этот буфер»:
mem == 0охватывает сразу два случая: небуферизованный канал (size == 0) и элемент нулевого размера (chan struct{}, традиционно применяемый как чистый сигнал). Ни тому ни другомуbufне нужен; аллоцируется толькоhchan, аbufуказывает на заглушку, служащую исключительно адресом синхронизации для детектора гонок (-race).!elem.Pointers(), элемент не содержит указателей:hchanиbufвырезаются в одном вызовеmallocgc, причёмbufрасполагается сразу заhchan(c.buf = add(c, hchanSize)). Одна аллокация вместо двух — и, что важнее, передаваемый тип равенnil, поэтому весь блок памяти помечается как noscan, и GC полностью пропускает этот буфер при сканировании. Для высокочастотных каналов с элементами типаint,byteи подобных это ощутимая экономия.- Элемент содержит указатели:
bufможет быть аллоцирован только отдельно, иelemtypeдолжен быть передан при аллокации. Причина — обратная сторона предыдущего случая: GC обязан сканировать указатели в этом буфере, иначе объекты, на которые ссылаются ещё не извлечённые элементы, были бы ошибочно освобождены. Передачаelemtypeаллокатору позволяет ему зарегистрировать для этой памяти битовую карту «какие слова являются указателями». Здесьhchanиbufпринадлежат двум отдельным блокам памяти и не могут быть объединены.
Таким образом, makechan — не просто «аллоцировать кусок памяти»; функция работает в тесной
связке с GC: там, где можно, применяет noscan, а там, где сканирование необходимо, честно
расставляет метки. Это та же логика, что и различие аллокатора между объектами с указателями и
без в 12 Аллокация памяти; канал — лишь один из его пользователей.
10.2.6 Единственная блокировка и обеспечиваемая ею семантика синхронизации
lock mutex в конце hchan — краеугольный камень синхронизации всей структуры. Он защищает
не только все поля самого hchan, но и ряд полей внутри sudog, запаркованных на данном
канале, на что явно указывает комментарий в исходном коде. Получение, отправка, закрытие,
select — каждая операция, затрагивающая это состояние, начинается с lock(&c.lock) и
заканчивается unlock. Именно потому, что эта блокировка пронизывает весь процесс, отправка
по каналу и соответствующее получение устанавливают отношение happens-before в модели памяти
(11.9), делая канал одновременно средством коммуникации и средством
синхронизации.
Блокировка привносит и тонкое ограничение: удерживая c.lock, нельзя изменять состояние
другой горутины (в особенности нельзя вызывать goready для её пробуждения). Причина в том,
что пробуждение может инициировать сжатие стека, а сжатие стека, в свою очередь, пытается
захватить ту же самую блокировку — и это перекрёстное ожидание приводит к дедлоку. Именно здесь
коренится повторяющийся в реализации отправки/получения паттерн «сначала извлечь sudog,
удерживая блокировку, затем вызвать goready после её освобождения»
(10.6 Модель памяти и эволюция в сторону lock-free разбирает это подробнее).
Защита всего канала единственной крупной блокировкой — намеренный компромисс: реализация
проста, а её корректность легко доказуема, ценой сериализации конкурентных отправок и получений
на одном канале через эту блокировку. Сообщество и авторы рантайма рассматривали lock-free-канал
ещё на раннем этапе: около 2014 года Дмитрий Вьюков представил экспериментальный дизайн
lock-free-канала, который в итоге не был принят, поскольку «рост сложности явно перевешивал
получаемую выгоду». По сей день стандартный канал остаётся этим простым сочетанием блокировки
и кольцевого буфера. Как только вы ясно видите эту блокировку, вся детальная механика отправки,
получения и select (10.3–10.6 Модель памяти и эволюция в сторону
lock-free) оказывается лишь «перемещением данных, парковкой и пробуждением
внутри защищённой критической секции, согласно инвариантам из 10.2.1».
Дополнительные материалы
- Авторы Go. runtime/chan.go (
hchan,waitq,makechan,chanbuf, включая комментарий с инвариантами в начале файла). https://github.com/golang/go/blob/master/src/runtime/chan.go - Авторы Go. runtime/runtime2.go (определение
sudogи документация его полей). https://github.com/golang/go/blob/master/src/runtime/runtime2.go - Russ Cox. Go Data Structures. 2009. https://research.swtch.com/godata
- Dmitry Vyukov. Go channels on steroids (дизайн lock-free-канала и непринятый эксперимент). 2014. https://docs.google.com/document/d/1yIAYmbvL3JxOKOjuCyon7JhW4cSv1wy5hC0ApeGMV9s
- C. A. R. Hoare. Communicating Sequential Processes. Communications of the ACM, 21(8), 1978. https://doi.org/10.1145/359576.359585
- Эта книга: 10.1 Каналы и инженерная реализация CSP, 10.3 Отправка, получение и прямая передача, 11.9 Модель согласованности памяти, 12 Аллокация памяти.