Go under the hood
Go: Under the Hood

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// hchan: представление одного канала в рантайме (упрощённая схема)
type hchan struct {
    qcount   uint           // количество элементов в буфере в данный момент
    dataqsiz uint           // ёмкость кольцевого буфера (второй аргумент make)
    buf      unsafe.Pointer // указывает на непрерывный массив, способный вместить dataqsiz элементов
    elemsize uint16         // размер одного элемента (определяется из типа элемента; кешируется здесь во избежание повторных обращений)
    closed   uint32         // был ли вызван close
    elemtype *_type         // тип элемента: нужен для копирования элементов, барьеров записи и разметки buf для GC
    sendx    uint           // курсор записи кольцевого буфера (следующий слот для записи)
    recvx    uint           // курсор чтения кольцевого буфера (следующий слот для чтения)
    recvq    waitq          // очередь заблокированных получателей ( <-ch )
    sendq    waitq          // очередь заблокированных отправителей ( ch<- )

    lock mutex              // единственная блокировка, защищающая все перечисленные выше поля
}

Поля делятся примерно на три группы. Пять полей — 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-го слота вычисляется простой арифметикой указателей:

1
2
3
4
// chanbuf(c, i): получить адрес i-го слота в buf
func chanbuf(c *hchan, i uint) unsafe.Pointer {
    return add(c.buf, uintptr(i)*uintptr(c.elemsize))
}

Почему кольцо, а не линейная очередь? Потому что отправка и получение в канале строго FIFO: что отправлено первым — получено первым. В линейном массиве после каждого извлечения из головы остаётся «дыра»: нужно либо сдвигать элементы, либо тратить память впустую; кольцевой буфер позволяет каждому из концов двигаться по кругу, и постановка в очередь, и извлечение из неё имеют сложность O(1)O(1) — инкремент курсора с переносом, без перемещения единого байта. На рисунке ниже показан канал ёмкостью 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 находится между 00 и dataqsiz, буфер ни полон, ни пуст, и отправка, и получение завершаются на месте, а обе очереди ожидания в этот момент неизбежно пусты.

10.2.4 Очереди ожидания: FIFO-список из sudog

Когда буфер не может помочь — например, при отправке в полный канал, получении из пустого канала или при работе с небуферизованным каналом, — текущая горутина должна «запарковаться» и ждать появления кого-либо на другой стороне. Куда она паркуется? В recvq или sendq. Тип обеих очередей — waitq, простой двусвязный список, хранящий голову и хвост:

1
2
3
4
type waitq struct {
    first *sudog // голова: заблокирован раньше всех, пробуждается первым
    last  *sudog // хвост: заблокирован последним
}

Узлы списка — это sudog. Чтобы разобраться в каналах, без sudog не обойтись: он представляет одну горутину, заблокированную на некотором канале, вместе с элементом, который она хочет отправить или получить. Упрощённая схема:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// sudog: горутина, запаркованная на канале, + элемент для отправки/получения (упрощённая схема)
type sudog struct {
    g *g                 // горутина, которая была запаркована

    next *sudog          // следующий элемент в waitq
    prev *sudog          // предыдущий элемент в waitq

    elem maybeTraceablePtr // адрес элемента для отправки/получения; может указывать непосредственно на стек горутины g

    isSelect bool        // участвует ли g в select (для пробуждения требуется CAS-захват)
    success  bool        // причина пробуждения: true = успешная отправка/получение, false = канал закрыт
    c        maybeTraceableChan // канал, на котором заблокирована горутина
}

Почему бы не поставить *g непосредственно в очередь, а не оборачивать её в sudog? Потому что отношение «горутина заблокирована на канале» не является взаимно однозначным: одна и та же горутина может быть одновременно запаркована на нескольких каналах внутри одного оператора select, а один канал может иметь в очереди несколько горутин. sudog — это носитель «данного конкретного экземпляра ожидания (горутина, канал)», и потому горутина может удерживать несколько sudog одновременно. sudog не является исключительной принадлежностью каналов: семафоры пакета sync (11.x) используют ту же структуру для постановки в очередь, а ряд полей sudog, выходящих за рамки isSelect и success, предназначен для семафоров и опущен в схеме. Рантайм переиспользует sudog через кеш на уровне P, избегая частых аллокаций.

Поле elem заслуживает отдельного упоминания: оно указывает на «элемент для данной конкретной отправки или получения», и эта память нередко находится прямо на стеке горутины. Это основа одной из ключевых оптимизаций каналов: когда отправитель обнаруживает получателя, уже ожидающего в recvq, он может скопировать данные непосредственно в elem на стеке получателя, минуя промежуточный кольцевой буфер (подробности — в 10.3). Этим же объясняется, почему поля sudog должны быть защищены блокировкой канала: и пробуждение другой стороны, и запись в её стек должны выполняться при удержании блокировки.

Сама очередь строго FIFO. Постановка в очередь выполняется в хвост, извлечение — из головы:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func (q *waitq) enqueue(sgp *sudog) {
    sgp.next = nil
    x := q.last
    if x == nil {            // очередь пуста
        sgp.prev = nil
        q.first = sgp
        q.last = sgp
        return
    }
    sgp.prev = x             // добавить в хвост
    x.next = sgp
    q.last = sgp
}

func (q *waitq) dequeue() *sudog {
    // извлечь из головы; при конкуренции пробуждений в select уже пробуждённые узлы пропускаются — здесь опущено
    sgp := q.first
    // ... обновить first / last, вернуть sgp
    return sgp
}

Первым запаркованный — первым пробуждается; это гарантирует справедливость канальных операций: ни один ожидающий не окажется в конце очереди бессрочно. Инвариант «хотя бы одна очередь пуста» из 10.2.1 также получает здесь интуитивное объяснение: если в буфере есть свободный слот или данные, вновь прибывший отправитель или получатель завершает сделку на месте и никогда не доходит до шага постановки в очередь, — поэтому обе очереди никогда не бывают непусты одновременно (за исключением особого случая двойной парковки в select).

10.2.5 makechan: одна аллокация и оптимизация noscan

make(chan T, n) транслируется компилятором в вызов makechan(t, n). Его ключевая задача — вычислить необходимый буферу объём памяти, запросить её из кучи и заполнить метаданные. Канал всегда аллоцируется в куче и освобождается GC; именно поэтому отсутствие явного вызова close не приводит к утечке памяти (закрытие и освобождение — две разные операции). Примечательно, что функция разбивается на три стратегии аллокации в зависимости от типа элемента:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func makechan(t *chantype, size int) *hchan {
    elem := t.Elem
    mem, overflow := math.MulUintptr(elem.Size_, uintptr(size)) // суммарный размер буфера в байтах
    if overflow || mem > maxAlloc-hchanSize || size < 0 {
        panic(plainError("makechan: size out of range"))
    }

    var c *hchan
    switch {
    case mem == 0:
        // небуферизованный канал (size==0) или элемент нулевого размера (например, struct{}): buf не нужен
        c = (*hchan)(mallocgc(hchanSize, nil, true))
        c.buf = c.raceaddr()
    case !elem.Pointers():
        // элемент не содержит указателей: hchan и buf аллоцируются вместе одним блоком, весь блок — noscan
        c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
        c.buf = add(unsafe.Pointer(c), hchanSize)
    default:
        // элемент содержит указатели: buf аллоцируется отдельно с передачей elemtype, чтобы GC мог его сканировать
        c = new(hchan)
        c.buf = mallocgc(mem, elem, true)
    }

    c.elemsize = uint16(elem.Size_)
    c.elemtype = elem
    c.dataqsiz = uint(size)
    lockInit(&c.lock, lockRankHchan)
    return c
}

Граница между тремя ветками определяется вопросом «нужно ли 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».

Дополнительные материалы

  1. Авторы Go. runtime/chan.go (hchan, waitq, makechan, chanbuf, включая комментарий с инвариантами в начале файла). https://github.com/golang/go/blob/master/src/runtime/chan.go
  2. Авторы Go. runtime/runtime2.go (определение sudog и документация его полей). https://github.com/golang/go/blob/master/src/runtime/runtime2.go
  3. Russ Cox. Go Data Structures. 2009. https://research.swtch.com/godata
  4. Dmitry Vyukov. Go channels on steroids (дизайн lock-free-канала и непринятый эксперимент). 2014. https://docs.google.com/document/d/1yIAYmbvL3JxOKOjuCyon7JhW4cSv1wy5hC0ApeGMV9s
  5. C. A. R. Hoare. Communicating Sequential Processes. Communications of the ACM, 21(8), 1978. https://doi.org/10.1145/359576.359585
  6. Эта книга: 10.1 Каналы и инженерная реализация CSP, 10.3 Отправка, получение и прямая передача, 11.9 Модель согласованности памяти, 12 Аллокация памяти.