13. Каналы под капотом. hchan, sudog, sendq, recvq
В завершении лекции 12. Как создается и завершается goroutine мы привели крылатый афоризм Роба Пайка: «Не общайтесь, разделяя память; разделяйте память, общаясь» (наследие формальной модели CSP Тони Хоара). Главным инженерным инструментом этого общения в языке Go выступают Каналы (Channels).
Среди разработчиков, недавно освоивших Go, вокруг каналов нередко складывается мистический ореол. Возникает иллюзия, будто канал — это некая безакцептная (lock-free) квантовая телепортация данных на уровне процессорных шин, которая по определению быстрее, надежнее и современнее «старомодных» мьютексов.
Для инженера Senior-уровня критически важно развеять эту романтическую дымку. Канал в Go — это обыкновенная потокобезопасная очередь в памяти, защищенная классическим мьютексом, но тесно спаянная с планировщиком задач рантайма.
Заглянем в файл src/runtime/chan.go и разберем анатомию канала на уровне физических структур данных.
1. Структура hchan
Когда программист пишет ch := make(chan int, 3), компилятор транслирует эту инструкцию в вызов рантайм-функции runtime.makechan, которая аллоцирует в оперативной памяти кучи (Heap) заголовочную структуру hchan (Header of Channel).
В текущей реализации компилятора Go (gc) вызов make(chan ...) всегда аллоцирует структуру hchan в куче через mallocgc, даже если канал создан внутри локальной функции и не передается наружу (Escape Analysis компилятора пока не оптимизирует размещение каналов на стеке). Причина очевидна: канал фундаментально спроектирован для разделения данных между независимыми горутинами, у каждой из которых свой собственный изолированный стек.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
HCHAN["Структура hchan (в Heap)"]:::system --> LOCK["lock: runtime.mutex<br>Защита всех операций"]
HCHAN --> BUF["buf: кольцевой буфер<br>Непрерывный массив элементов"]
HCHAN --> SENDQ["sendq: waitq<br>Очередь спящих писателей"]
HCHAN --> RECVQ["recvq: waitq<br>Очередь спящих читателей"]
SENDQ --> SUDOG1["sudog: обертка над спящей G"]
RECVQ --> SUDOG2["sudog: обертка над спящей G"]
Структура hchan включает в себя ключевые поля:
qcount: текущее количество элементов, фактически находящихся в буфере прямо сейчас;dataqsiz: общая вместимость буфера (размер capacity, заданный вторым аргументомmake);buf: указатель на непрерывный кольцевой буфер (ring buffer) в памяти. Для небуферизованных каналов (make(chan int)) буфер отсутствует, и указатель равенnil;elemsizeиelemtype: размер в байтах и указатель на метаданные типа элемента (необходимо для безопасного побайтового копированияmemmove);sendx: индекс в кольцевом буфере, куда будет записан следующий поступающий элемент;recvx: индекс в кольцевом буфере, откуда будет прочитан следующий элемент;recvqиsendq: очереди ожидания (wait queues). Это двусвязные списки спящих горутин, заблокированных на чтении или записи;lock: встроенный системный спин-мьютекс (runtime.mutex), который захватывается при любой операции с каналом (чтение, запись, закрытие, проверка длины).
2. Что такое sudog?
В очередях ожидания sendq и recvq находятся не «сырые» структуры g (горутины), а специальные связующие узлы — sudog.
Зачем рантайму потребовался этот промежуточный объект?
Горутина G — это неделимый субъект исполнения. В реальной программе одна горутина может одновременно ожидать данных из нескольких разных каналов (например, внутри конструкции select). Если бы мы помещали саму структуру g в двусвязный список одного канала, ее указатели связности next и prev оказались бы перезаписаны при попытке встать в очередь второго канала!
Поэтому структура sudog выступает «делегатом» или представителем горутины в очереди конкретного канала:
g: указатель на саму ожидающую горутину;elem: указатель на ячейку памяти (на стеке горутины), откуда нужно прочитать или куда нужно записать передаваемые данные;c: указатель на структуру каналаhchan, в очереди которого этотsudogзарегистрирован.
3. Анатомия записи (Send): ch <- data
Операция отправки данных в канал транслируется компилятором в вызов функции runtime.chansend.
За исключением быстрых неблокирующих проверок, рантайм первым делом захватывает внутренний мьютекс hchan.lock. Далее логика разделяется на три строгих сценария с выраженным сочувствием к машине (Mechanical Sympathy):
Сценарий 1: Читатель уже ждет (Direct Send)
Если мы отправляем данные в канал, а в очереди recvq уже томится заблокированный читатель (его sudog), рантайм применяет великолепную оптимизацию — Direct Send (прямая передача).
Вместо того чтобы копировать данные в промежуточный буфер buf, обновлять индексы, а затем будить читателя, чтобы тот скопировал данные из буфера к себе на стек, рантайм берет данные из стека писателя и копирует их напрямую в стек читателя:
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
SENDER["Горутина-писатель G1"]:::accent -->|"Захват мьютекса hchan.lock"| CHAN["Канал hchan"]
CHAN -->|"Проверка recvq"| IS_EMPTY{"Очередь recvq пуста?"}
IS_EMPTY -->|"Нет, читатель спит"| DIRECT["Прямая передача (Direct Send)"]
DIRECT -->|"Копирование памяти memmove<br>Из стека G1 в стек G2"| STACK_G2["Стек горутины G2"]
STACK_G2 -->|"Смена статуса G2<br>на _Grunnable"| WAKE["Вызов goready(G2)"]
Мы экономим целый цикл обращений к оперативной памяти и избегаем прогрева промежуточного буфера! После прямого копирования писатель вызывает процедуру goready(readerG), сообщая планировщику, что читатель готов к исполнению (_Grunnable) и должен вернуться в локальную очередь P.
Сценарий 2: Буфер свободен
Если читателей в очереди нет, но канал является буферизованным и в нем есть свободное место (qcount < dataqsiz):
- Писатель копирует данные из своего стека в массив кольцевого буфера по адресу смещения
sendx; - Индекс
sendxциклически инкрементируется:if sendx == dataqsiz { sendx = 0 }; - Счетчик элементов
qcountувеличивается на 1; - Мьютекс
lockосвобождается, и горутина-писатель продолжает выполнение без малейшей паузы.
Сценарий 3: Блокировка (Буфер полон или отсутствует)
Если канал небуферизован (а читателя нет) либо буфер полностью заполнен (qcount == dataqsiz), писатель не может продолжить работу:
- Рантайм аллоцирует или берет из кэша дескриптор
sudogдля текущей горутины; - Сохраняет в
sudog.elemадрес передаваемой переменной на своем стеке; - Вставляет
sudogв хвост двусвязного спискаsendq; - Вызывает процедуру
runtime.gopark: горутина освобождает потокM, переходит в статус_Gwaitingи засыпает до момента, пока будущий читатель не разбудит ее.
4. Анатомия чтения (Receive): data := <-ch
Операция чтения транслируется компилятором в функцию runtime.chanrecv. Она работает зеркально, но содержит важнейший тонкий нюанс:
Сценарий 1: Писатель уже ждет
Если мы зашли в чтение, а в очереди sendq находится спящий писатель:
- Если канал небуферизованный: Выполняется Direct Receive: данные копируются напрямую со стека спящего писателя на стек читателя, после чего писатель пробуждается через
goready. - Если канал буферизованный (и буфер полон): Напрямую скопировать данные со стека писателя читателю категорически нельзя, иначе нарушится фундаментальный FIFO-порядок (первым пришел — первым ушел)!
Рантайм поступает предельно элегантно:- Читатель забирает самые старые данные из головы буфера:
buf[recvx]; - Данные со стека спящего писателя копируются на освободившееся место в буфер:
buf[sendx]; - Оба индекса
recvxиsendxинкрементируются; - Писатель разблокируется через
goready.
- Читатель забирает самые старые данные из головы буфера:
Сценарий 2: В буфере есть данные (писателей нет)
Читатель копирует элемент из слота buf[recvx] в свою целевую переменную, очищает память в освобожденном слоте буфера (чтобы GC мог утилизировать объект, если элемент был указателем), инкрементирует recvx, уменьшает qcount, отпускает мьютекс и продолжает путь.
Сценарий 3: Блокировка (Данных нет)
Если данных нет и канал не закрыт:
Читатель формирует свой sudog, сохраняет в нем адрес ячейки для приема данных, встает в очередь recvq, отпускает мьютекс канала и вызывает gopark, погружаясь в сон.
5. Закрытие канала: close(ch)
При вызове встроенной функции close(ch) рантайм исполняет процедуру runtime.closechan:
- Захватывает мьютекс
hchan.lock; - Если канал уже закрыт (
closed != 0), паникует:panic: close of closed channel; - Выставляет флаг
closed = 1; - Проходит по всей очереди читателей
recvq: каждомуsudogв качестве значения записывается нулевое значение по умолчанию (zero value) его типа, после чего горутина будится (goready); - Проходит по очереди писателей
sendq: все заблокированные писатели немедленно пробуждаются, вызывая фатальную панику:panic: send on closed channel; - Освобождает мьютекс
lock.
Итог
- Канал (
hchan) — это не lock-free структура, а классическая потокобезопасная очередь в куче, защищенная встроенным спин-мьютексомlock. sudog— вспомогательный связующий объект, представляющий горутину в очередях ожидания (sendq,recvq) и позволяющий ей ждать события из нескольких каналов сразу.- Direct Send / Direct Receive: Если партнер по коммуникации уже заблокирован, рантайм копирует данные напрямую из стека в стек, минуя кольцевой буфер
buf. - В буферизованном канале при чтении в присутствии спящего писателя соблюдается строгий порядок FIFO: читатель берет старое значение из
buf[recvx], а значение писателя помещается на освободившееся местоbuf[sendx]. - Попытка записи или повторного закрытия закрытого канала приводит к немедленной панике, а операции с
nil-каналами ведут к вечной блокировке.
Мы досконально изучили поведение отдельного изолированного канала. Но в архитектуре современных распределенных микросервисов горутине постоянно требуется мультиплексировать события от нескольких каналов одновременно — реализовывать таймауты, отмену контекста и приоритетную маршрутизацию.
В следующей лекции мы детально разберем: 14. Select под капотом.