Go under the hood
Go: Under the Hood

10.1 Каналы и инженерия CSP

К этому разделу прилагается записанный доклад: онлайн на YouTube, презентация в Google Slides.

CSP даёт Go основание утверждать: процессы не разделяют состояние, а координируются исключительно через передачу сообщений (1.3). Настоящий раздел посвящён другому вопросу: чтобы инженерный язык воплотил это утверждение на практике, какую форму должна принять «коммуникация», при которой обычный программист мог бы использовать её корректно. Ответ Go — канал, элемент языка первого класса, являющийся одновременно средством синхронизации и средством передачи данных. Мы начнём с изложения его модели на поверхности языка: тип, синтаксис отправки и приёма, две семантики — буферизованная и небуферизованная, ограничение направления и поведение nil — выстраивая интуицию, которая понадобится последующим разделам при погружении в реализацию рантайма (10.2–10.7).

10.1.1 Коммуникация как примитив первого класса

«Не общайтесь посредством общей памяти; разделяйте память посредством коммуникации.» Этот принцип часто цитируют, но в Go он не является лозунгом — он воплощён конкретной языковой конструкцией: каналом. Родословная CSP и то, в чём Go следует и в чём отступает от оригинальной статьи 1978 года, рассмотрены в 1.3 и здесь не повторяются; мы начинаем лишь с тех компромиссов, которые Go сделал при её инженерной реализации.

Чтобы перенести «коммуникацию» CSP в язык общего назначения, необходимо ответить как минимум на три вопроса: через какой носитель происходит коммуникация, можно ли использовать этот носитель как обычное значение и как он взаимодействует с системой типов. Носителем, который предлагает Go, является канал, и Go сделал его примитивом первого класса: канал — это типизированное значение, его можно создать с помощью make, хранить в переменной, передавать в функцию как аргумент, возвращать из функции, хранить в поле структуры, помещать в срез или словарь. Это отличает его от оригинального CSP Хоара, где процессы общаются напрямую по имени процесса (1.3.2), и именно это превращает абстрактное утверждение в компонуемый инструмент: поскольку канал является значением, «передача коммуникационного порта другому участку кода» сводится к обычной передаче аргумента без какого-либо специального механизма.

Канал реализует сразу две вещи, которые обычно разделяют: синхронизацию и передачу данных. Одна операция отправки или приёма одновременно перемещает значение и устанавливает отношение happens-before между двумя сторонами, которое гарантирует: «память, записанная отправителем до отправки, гарантированно видна получателю после приёма.» Иными словами, канал — это не просто труба для передачи значений, но и примитив синхронизации. Данная гарантия happens-before является основой, позволяющей каналу заменять явные блокировки; её точная формулировка дана в 10.6 и 11.9, а для целей настоящего раздела достаточно помнить: операция отправки или приёма несёт в себе собственную синхронизацию.

10.1.2 Поверхностная модель: от make до select

Пробежимся по поверхностному API канала на небольшом наборе кода, формируя операциональную интуицию; детали будут раскрыты в последующих разделах.

Создание выполняется через make. Небуферизованный и буферизованный каналы отличаются единственным аргументом ёмкости:

1
2
ch := make(chan int)      // небуферизованный: ёмкость 0
buf := make(chan int, 8)  // буферизованный: ёмкость 8

Как отправка, так и приём используют оператор <-, стрелка указывает направление потока данных:

1
2
ch <- 42        // отправка: поместить 42 в ch
v := <-ch       // приём: извлечь одно значение из ch

У приёма есть форма с двумя значениями, где второй булев результат ok отличает «получено реальное значение» от «канал закрыт и буфер пуст, поэтому получено нулевое значение»:

1
v, ok := <-ch   // ok == false означает, что ch закрыт и данных больше нет

close закрывает канал, сигнализируя об отсутствии дальнейших отправок. После закрытия значения, уже находящиеся в буфере, можно получить; как только буфер опустеет, дальнейшие операции приёма вернут нулевое значение и ok == false одновременно. Полная семантика закрытия (кто должен закрывать, что отправка в закрытый канал или повторное закрытие вызовет панику) рассмотрена в 10.4; здесь мы принимаем только его поверхностное поведение.

range перебирает канал, повторно принимая значения до тех пор, пока канал не будет закрыт и опустошён, — идиоматический способ потребления потока данных:

1
2
3
for v := range ch {   // цикл до закрытия и опустошения ch, затем завершается автоматически
    use(v)
}

select выбирает один путь среди нескольких коммуникаций. Он наблюдает за несколькими операциями отправки и приёма одновременно и выполняет ту из них, что готова; если готовы несколько — выбирает одну случайным образом; если ни одна не готова — блокируется, если только не написана ветка default:

1
2
3
4
5
6
7
8
select {
case v := <-in:        // перейти сюда, если in доступен для чтения
    handle(v)
case out <- x:         // перейти сюда, если out доступен для записи
    x = next()
default:               // если ни одно из условий не готово — вернуться немедленно, избегая блокировки
    idle()
}

select — инженерная реализация «охраняемых команд и альтернатив» CSP; его случайный выбор, справедливость и двухпроходная реализация блокировок рассмотрены в 10.5. Таким образом, полный набор поверхностных операторов над каналом сводится лишь к: make, <-, close, range, select. В следующих подразделах мы последовательно разберём те семантические точки среди них, которые проще всего понять неправильно.

10.1.3 Небуферизованный и буферизованный: рандеву или очередь

Аргумент ёмкости делит каналы на два семантически очень разных вида, и понимание этой разделительной черты является обязательным условием для корректного использования канала.

Небуферизованный канал (ёмкость 0) — это рандеву. Когда отправитель выполняет ch <- v, если в этот момент ни один получатель не ожидает, отправитель блокируется на месте до тех пор, пока какой-либо получатель не выполнит <-ch и не составит с ним пару; в момент успешного сопряжения значение передаётся напрямую от отправителя к получателю, и лишь после этого оба продолжают выполнение. Получатель, прибывший первым, блокируется симметрично, ожидая отправителя. Таким образом, успешная отправка или приём на небуферизованном канале означает, что две стороны действительно встретились в этот момент: когда отправка возвращает управление, можно быть уверен, что значение уже принято каким-либо получателем.

1
2
3
4
5
6
done := make(chan struct{})
go func() {
    work()
    done <- struct{}{}   // сигнал: я завершил работу
}()
<-done                   // блокируется до выполнения строки выше; здесь стороны встречаются

Буферизованный канал (ёмкость n>0n>0) — это ограниченная очередь ёмкостью nn. Когда отправитель выполняет ch <- v, пока очередь не заполнена, он помещает значение в неё и немедленно возвращает управление, не ожидая прихода получателя; блокировка происходит только при заполненной очереди. Получатель извлекает значение из головы очереди и блокируется лишь при её пустом состоянии. Ключевое отличие: когда буферизованная отправка возвращает управление, значение может всё ещё находиться в очереди и не быть принятым получателем. Приобретается развязка по времени между отправкой и приёмом — ценой утраты гарантии рандеву: «возврат из отправки означает, что другая сторона приняла значение».

flowchart LR
    subgraph U["Небуферизованный: рандеву"]
        S1["отправитель ch&lt;-v"] -->|"сделка закрывается только при наличии обеих сторон<br/>значение передаётся напрямую"| R1["получатель &lt;-ch"]
    end
    subgraph B["Буферизованный n: ограниченная очередь"]
        S2["отправитель ch&lt;-v"] -->|постановка в очередь, если не заполнена| Q["очередь 0..n-1"]
        Q -->|извлечение из очереди, если не пуста| R2["получатель &lt;-ch"]
    end

Выбор между ними сводится к нескольким простым соображениям. Когда требуется строгий сигнал синхронизации вида «я завершил, другая сторона подтверждает приём» — используйте небуферизованный: его семантика рандеву — это в точности рукопожатие. Когда нужно развязать производителя и потребителя по темпу, сгладить всплески или установить явную верхнюю границу на количество данных в пути — используйте буферизованный: ёмкость очереди и есть эта граница. Ёмкость может также выступать в роли семафора: make(chan struct{}, k) совместно с «отправкой для занятия слота и приёмом для его освобождения» ограничивает количество одновременно выполняемых операций числом не более kk (10.7).

Распространённое заблуждение, которого следует избегать, — воспринимать буферизацию как бесплатный рычаг ускорения. Буферизация развязывает тайминг, а не само вычисление; она не уменьшает общий объём работы и порождает новые вопросы: каким должен быть размер буфера, как обрабатывается обратное давление при его заполнении, что происходит с данными в пути при сбое. Очень большой буфер, установленный по наитию, зачастую лишь откладывает момент «когда происходит блокировка» и скрывает риск накопления очереди. Ёмкость — это параметр проектирования, требующий обоснования, а не переключатель производительности, который следует включать по умолчанию.

10.1.4 Ограничение направления: пусть тип закрепляет ваше намерение

Тип канала может нести направление, сужая двунаправленный канал до однонаправленного — только для отправки или только для приёма:

1
2
chan<- int   // только для отправки
<-chan int   // только для приёма

Запись направления в сигнатуру функции — дешёвое и мощное средство обеспечения чистоты API. Рассмотрим функцию-производителя, которая должна только писать в канал и никогда не читать из него; объявите параметр доступным только для отправки, и компилятор заблокирует любое случайное чтение:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func produce(out chan<- int) {   // out — только для отправки
    for i := 0; i < 10; i++ {
        out <- i
    }
    close(out)                   // закрывается производителем, следуя соглашению «отправитель закрывает»
}

func consume(in <-chan int) {    // in — только для приёма
    for v := range in {
        use(v)
    }
}

Двунаправленный канал можно неявно присвоить направленному типу, но не наоборот. Поэтому обычная схема такова: создать двунаправленный канал через make в одном месте, затем передать его производителю и потребителю в виде двух ограниченных представлений — только для отправки и только для приёма. Направление не изменяет поведение в рантайме, это исключительно ограничение на этапе компиляции, которое записывает намерение «этот конец должен только отправлять» и «тот конец должен только принимать» в тип, обнаруживая нарушение обещания во время компиляции, а не во время выполнения. Это также удобно фиксирует в типе вопрос «кто отвечает за close»: только сторона, держащая отправляющий конец, вправе вызывать close, поскольку вызов close на представлении только для чтения попросту не скомпилируется.

10.1.5 nil-канал: польза вечной блокировки

Канал, не инициализированный через make, чьё значение равно nil, блокируется навсегда как при приёме, так и при отправке:

1
2
3
var ch chan int   // nil-канал
ch <- 1           // блокируется навсегда
<-ch              // блокируется навсегда

В изоляции это выглядит как ловушка, порождающая только взаимоблокировки. Но помещённый внутрь select, он становится чистым переключателем. select игнорирует ветки, которые никогда не будут готовы, а ветка с nil-каналом — это именно такая ветка. Таким образом, «установка канала ветки в nil» равнозначна динамическому отключению этой ветки во время выполнения без изменения структуры select.

Этот приём особенно удобен в сценарии «после окончания чтения одного ввода перестать его слушать». Цикл ниже одновременно потребляет два ввода; когда один из них закрывается, он устанавливает соответствующую переменную канала в nil, после чего select больше не выбирает эту исчерпанную ветку, избегая холостого цикла повторяющихся приёмов нулевого значения из закрытого канала:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
for in1 != nil || in2 != nil {
    select {
    case v, ok := <-in1:
        if !ok {
            in1 = nil   // in1 исчерпан, отключить эту ветку
            continue
        }
        handle(v)
    case v, ok := <-in2:
        if !ok {
            in2 = nil   // in2 исчерпан, отключить эту ветку
            continue
        }
        handle(v)
    }
}

Объединение двух правил — «nil-канал блокируется навсегда» и «select игнорирует неготовые ветки» — даёт идиому со значительной выразительной мощью. Это также напоминает нам, что немногочисленные поверхностные правила канала не изолированы друг от друга; именно их сочетание составляет по-настоящему полезный инструмент, и когда мы обратимся к реализации, то снова и снова будем видеть, как рантайм точно выполняет эти комбинации.

10.1.6 Структура главы

Имея перед собой поверхностную модель, мы спускаемся слой за слоем в реализацию канала в рантайме. Разделы организованы следующим образом:

Завершив этот раздел, читатель сможет писать корректный код с каналами; завершив главу — объяснить, почему этот код работает именно так.

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

  1. The Go Authors. The Go Programming Language Specification: Channel types, Send statements, Receive operator, Close. https://go.dev/ref/spec#Channel_types
  2. The Go Authors. Effective Go: Channels. https://go.dev/doc/effective_go#channels
  3. Rob Pike. Go Concurrency Patterns. Google I/O 2012. https://go.dev/talks/2012/concurrency.slide
  4. Sameer Ajmani. Advanced Go Concurrency Patterns. Google I/O 2013. https://go.dev/talks/2013/advconc.slide (приём отключения ветки select с помощью nil-канала)
  5. C. A. R. Hoare. “Communicating Sequential Processes.” Communications of the ACM, 21(8), 1978. https://doi.org/10.1145/359576.359585
  6. Эта книга: 1.3 Communicating Sequential Processes, 10.6 Модель памяти и эволюция без блокировок, 11.9 Модель согласованности памяти.