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. Небуферизованный и буферизованный каналы отличаются единственным аргументом
ёмкости:
|
|
Как отправка, так и приём используют оператор <-, стрелка указывает направление потока данных:
|
|
У приёма есть форма с двумя значениями, где второй булев результат ok отличает «получено реальное значение»
от «канал закрыт и буфер пуст, поэтому получено нулевое значение»:
|
|
close закрывает канал, сигнализируя об отсутствии дальнейших отправок. После закрытия значения, уже
находящиеся в буфере, можно получить; как только буфер опустеет, дальнейшие операции приёма вернут нулевое
значение и ok == false одновременно. Полная семантика закрытия (кто должен закрывать, что отправка в
закрытый канал или повторное закрытие вызовет панику) рассмотрена в 10.4; здесь мы принимаем
только его поверхностное поведение.
range перебирает канал, повторно принимая значения до тех пор, пока канал не будет закрыт и опустошён, —
идиоматический способ потребления потока данных:
|
|
select выбирает один путь среди нескольких коммуникаций. Он наблюдает за несколькими операциями отправки и
приёма одновременно и выполняет ту из них, что готова; если готовы несколько — выбирает одну случайным образом;
если ни одна не готова — блокируется, если только не написана ветка default:
|
|
select — инженерная реализация «охраняемых команд и альтернатив» CSP; его случайный выбор, справедливость и
двухпроходная реализация блокировок рассмотрены в 10.5. Таким образом, полный набор поверхностных
операторов над каналом сводится лишь к: make, <-, close, range, select. В следующих подразделах мы
последовательно разберём те семантические точки среди них, которые проще всего понять неправильно.
10.1.3 Небуферизованный и буферизованный: рандеву или очередь
Аргумент ёмкости делит каналы на два семантически очень разных вида, и понимание этой разделительной черты является обязательным условием для корректного использования канала.
Небуферизованный канал (ёмкость 0) — это рандеву. Когда отправитель выполняет ch <- v, если в этот
момент ни один получатель не ожидает, отправитель блокируется на месте до тех пор, пока какой-либо получатель
не выполнит <-ch и не составит с ним пару; в момент успешного сопряжения значение передаётся напрямую от
отправителя к получателю, и лишь после этого оба продолжают выполнение. Получатель, прибывший первым,
блокируется симметрично, ожидая отправителя. Таким образом, успешная отправка или приём на небуферизованном
канале означает, что две стороны действительно встретились в этот момент: когда отправка возвращает
управление, можно быть уверен, что значение уже принято каким-либо получателем.
|
|
Буферизованный канал (ёмкость ) — это ограниченная очередь ёмкостью . Когда отправитель
выполняет ch <- v, пока очередь не заполнена, он помещает значение в неё и немедленно возвращает управление,
не ожидая прихода получателя; блокировка происходит только при заполненной очереди. Получатель извлекает
значение из головы очереди и блокируется лишь при её пустом состоянии. Ключевое отличие: когда буферизованная
отправка возвращает управление, значение может всё ещё находиться в очереди и не быть принятым получателем.
Приобретается развязка по времени между отправкой и приёмом — ценой утраты гарантии рандеву: «возврат из
отправки означает, что другая сторона приняла значение».
flowchart LR
subgraph U["Небуферизованный: рандеву"]
S1["отправитель ch<-v"] -->|"сделка закрывается только при наличии обеих сторон<br/>значение передаётся напрямую"| R1["получатель <-ch"]
end
subgraph B["Буферизованный n: ограниченная очередь"]
S2["отправитель ch<-v"] -->|постановка в очередь, если не заполнена| Q["очередь 0..n-1"]
Q -->|извлечение из очереди, если не пуста| R2["получатель <-ch"]
end
Выбор между ними сводится к нескольким простым соображениям. Когда требуется строгий сигнал синхронизации
вида «я завершил, другая сторона подтверждает приём» — используйте небуферизованный: его семантика рандеву —
это в точности рукопожатие. Когда нужно развязать производителя и потребителя по темпу, сгладить всплески или
установить явную верхнюю границу на количество данных в пути — используйте буферизованный: ёмкость очереди и
есть эта граница. Ёмкость может также выступать в роли семафора: make(chan struct{}, k) совместно с
«отправкой для занятия слота и приёмом для его освобождения» ограничивает количество одновременно выполняемых
операций числом не более (10.7).
Распространённое заблуждение, которого следует избегать, — воспринимать буферизацию как бесплатный рычаг ускорения. Буферизация развязывает тайминг, а не само вычисление; она не уменьшает общий объём работы и порождает новые вопросы: каким должен быть размер буфера, как обрабатывается обратное давление при его заполнении, что происходит с данными в пути при сбое. Очень большой буфер, установленный по наитию, зачастую лишь откладывает момент «когда происходит блокировка» и скрывает риск накопления очереди. Ёмкость — это параметр проектирования, требующий обоснования, а не переключатель производительности, который следует включать по умолчанию.
10.1.4 Ограничение направления: пусть тип закрепляет ваше намерение
Тип канала может нести направление, сужая двунаправленный канал до однонаправленного — только для отправки или только для приёма:
|
|
Запись направления в сигнатуру функции — дешёвое и мощное средство обеспечения чистоты API. Рассмотрим функцию-производителя, которая должна только писать в канал и никогда не читать из него; объявите параметр доступным только для отправки, и компилятор заблокирует любое случайное чтение:
|
|
Двунаправленный канал можно неявно присвоить направленному типу, но не наоборот. Поэтому обычная схема такова:
создать двунаправленный канал через make в одном месте, затем передать его производителю и потребителю в виде
двух ограниченных представлений — только для отправки и только для приёма. Направление не изменяет поведение в
рантайме, это исключительно ограничение на этапе компиляции, которое записывает намерение «этот конец должен
только отправлять» и «тот конец должен только принимать» в тип, обнаруживая нарушение обещания во время
компиляции, а не во время выполнения. Это также удобно фиксирует в типе вопрос «кто отвечает за close»:
только сторона, держащая отправляющий конец, вправе вызывать close, поскольку вызов close на представлении
только для чтения попросту не скомпилируется.
10.1.5 nil-канал: польза вечной блокировки
Канал, не инициализированный через make, чьё значение равно nil, блокируется навсегда как при приёме,
так и при отправке:
|
|
В изоляции это выглядит как ловушка, порождающая только взаимоблокировки. Но помещённый внутрь select, он
становится чистым переключателем. select игнорирует ветки, которые никогда не будут готовы, а ветка с
nil-каналом — это именно такая ветка. Таким образом, «установка канала ветки в nil» равнозначна динамическому
отключению этой ветки во время выполнения без изменения структуры select.
Этот приём особенно удобен в сценарии «после окончания чтения одного ввода перестать его слушать». Цикл ниже
одновременно потребляет два ввода; когда один из них закрывается, он устанавливает соответствующую переменную
канала в nil, после чего select больше не выбирает эту исчерпанную ветку, избегая холостого цикла повторяющихся
приёмов нулевого значения из закрытого канала:
|
|
Объединение двух правил — «nil-канал блокируется навсегда» и «select игнорирует неготовые ветки» — даёт
идиому со значительной выразительной мощью. Это также напоминает нам, что немногочисленные поверхностные правила
канала не изолированы друг от друга; именно их сочетание составляет по-настоящему полезный инструмент, и когда
мы обратимся к реализации, то снова и снова будем видеть, как рантайм точно выполняет эти комбинации.
10.1.6 Структура главы
Имея перед собой поверхностную модель, мы спускаемся слой за слоем в реализацию канала в рантайме. Разделы организованы следующим образом:
- 10.2 hchan: Внутренняя структура канала: как выглядит канал в памяти, кольцевой буфер, очереди ожидания отправки и приёма и блокировка.
- 10.3 Отправка, приём и прямая передача: как одна операция отправки или приёма приводит две горутины к рандеву и оптимизация, обходящая буфер для прямой записи данных в стек другой стороны.
- 10.4 Семантика закрытия: как
closeрассылает уведомления всем ожидающим и несколько границ, за которыми возникает паника. - 10.5 Реализация select: как
selectgoслучайным, справедливым и свободным от взаимоблокировок образом выбирает одну из нескольких коммуникаций. - 10.6 Модель памяти и эволюция без блокировок: гарантия happens-before, устанавливаемая отправкой и приёмом, почему канал предпочёл использовать мьютекс и возможно ли пойти дальше.
- 10.7 Инженерная практика и межъязыковое сравнение: типичные паттерны и ловушки при работе с каналами, сравнение с примитивами конкурентности других языков.
Завершив этот раздел, читатель сможет писать корректный код с каналами; завершив главу — объяснить, почему этот код работает именно так.
Дополнительные материалы
- The Go Authors. The Go Programming Language Specification: Channel types, Send statements, Receive operator, Close. https://go.dev/ref/spec#Channel_types
- The Go Authors. Effective Go: Channels. https://go.dev/doc/effective_go#channels
- Rob Pike. Go Concurrency Patterns. Google I/O 2012. https://go.dev/talks/2012/concurrency.slide
- Sameer Ajmani. Advanced Go Concurrency Patterns. Google I/O 2013. https://go.dev/talks/2013/advconc.slide (приём отключения ветки select с помощью nil-канала)
- C. A. R. Hoare. “Communicating Sequential Processes.” Communications of the ACM, 21(8), 1978. https://doi.org/10.1145/359576.359585
- Эта книга: 1.3 Communicating Sequential Processes, 10.6 Модель памяти и эволюция без блокировок, 11.9 Модель согласованности памяти.