10.5 Реализация select
Предыдущие разделы детально рассмотрели отправку и приём данных по одному каналу (10.3). На практике, однако, горутина редко наблюдает лишь один канал: требуется реагировать на первую готовую операцию из нескольких отправок и приёмов и при этом избегать блокировки, если ни одна из них не готова. select создан именно для этого. Его семантика выглядит просто, однако реализация должна одновременно решить две нетривиальные задачи: как справедливо выбрать ветку, когда несколько из них готовы, и как избежать взаимоблокировки при захвате нескольких каналов в рамках одного select. Оба требования определяют всю структуру selectgo, и данный раздел построен вокруг них.
|
|
Сформулируем соглашения заранее. Каждый case описывает одну операцию с каналом (приём или отправку), и после вычисления select выполняется не более одного взаимодействия. Если одновременно готовы несколько case, один выбирается равномерно случайным образом; если ни один не готов, то при наличии default выполняется default (что делает весь select неблокирующим), а без default select блокируется до тех пор, пока какой-либо case не станет готов. Спецификация языка (Select statements) фиксирует эту семантику; рантайм обеспечивает её выполнение, а компилятор преобразует синтаксическую форму в данные, пригодные для рантайма.
10.5.1 Понижение уровня компилятором: выделение простых случаев
Не каждый select заслуживает полного механизма. На фазе walk (cmd/compile/internal/walk/select.go) компилятор сначала отдельно транслирует несколько вырожденных форм в зависимости от их размера, и только оставшиеся передаются в общий selectgo:
- Ноль case (
select {}): блокируется навсегда. Транслируется непосредственно вruntime.block(), который черезgoparkпереводит текущую горутину в режим сна без последующего пробуждения. - Один case (
select { case ... }, безdefault): эквивалентно записи этой операции с каналом напрямую. Понижается до обычной отправки или приёма без входа вselectgo. - Один case плюс default (ровно два case, один из которых —
default): идиома неблокирующей отправки и приёма. Транслируется в одинifс вызовомselectnbsendилиselectnbrecv, которые являются не чем иным, как тонкими обёртками, устанавливающими аргументblockфункцийchansend/chanrecvвfalse:
|
|
Смысл такого понижения: наиболее распространённый на практике вид select — это именно «один case плюс default» как неблокирующий зондирующий вызов; исключение таких case из selectgo позволяет избежать накладных расходов на построение массива case, вычисление двух порядков обхода и захват всех каналов. В selectgo попадает только select с двумя и более реальными ветками взаимодействия. Отдельно стоит отметить: даже если в исходном коде задано много case, если большинство каналов во время выполнения равны nil (case с nil-каналом никогда не готов, и его можно считать несуществующим), общий код всё равно обрабатывает это корректно, поэтому компилятор больше не формирует отдельный быстрый путь для «случайно оставшихся одного или двух эффективных case».
10.5.2 Два порядка: структуры данных selectgo
В selectgo поступает массив scase. В go1.26 структура scase сведена к минимуму:
|
|
Признак того, является ли case отправкой или приёмом, больше не хранится в поле структуры, а кодируется позицией: компилятор располагает все case-отправки в начале массива, а все case-приёмы — в конце; selectgo(cas0, order0, pc0, nsends, nrecvs, block) использует nsends / nrecvs для определения границы. Индекс casi < nsends соответствует отправке, иначе — приёму. Это позволяет сэкономить одно поле kind на каждый case.
Главная хитрость — в массиве order0 длиной 2*ncases, разделённом на два сегмента, каждый из которых несёт свой ключевой инвариант select:
|
|
pollorder обеспечивает справедливость, lockorder обеспечивает свободу от взаимоблокировок. Они решают две ортогональные задачи — именно поэтому используются два независимых порядка. Рассмотрим, как каждый из них строится.
10.5.3 pollorder: случайный опрос обеспечивает справедливость
Если бы selectgo всегда проверял case в порядке их записи в исходном коде, то при одновременной готовности нескольких case ранние case устойчиво получали бы предпочтение, а поздние могли бы испытывать голодание. Требование спецификации «выбрать один равномерно случайным образом» призвано именно предотвратить такое предпочтение. Реализация: перед проверкой готовности порядок обхода case перемешивается случайным образом, и тогда «первый найденный готовый case» оказывается равномерно распределённым по всем case.
Перемешивание выполняется in-place по алгоритму Фишера — Йетса, где в качестве источника случайности используется cheaprandn (дешёвый, lock-free, локальный для M генератор случайных чисел):
|
|
Корректность алгоритма Фишера — Йетса — классический результат: он порождает все перестановок с равными вероятностями за один проход без дополнительной памяти. Использование cheaprandn обеспечивает справедливость select: каждый готовый case имеет равную вероятность быть выбранным. Здесь стоит обратить внимание на инженерное решение: источник случайности — cheaprandn, а не криптографически стойкий генератор, поскольку требуется равномерность в статистическом смысле, а не устойчивость к предсказанию; дешёвый вариант — правильный выбор.
Немного истории. Справедливость select была введена не произвольно, а в ответ на реальную инженерную проблему. В раннем issue golang/go#21806 обсуждалось: при определённых нагрузках пользователи наблюдали заметный перекос в выборе ветки select, что в итоге побудило сообщество закрепить «случайный опрос» как в описании спецификации, так и в реализации.
cheaprandnв сочетании с алгоритмом Фишера — Йетса — это то, во что воплотилось данное требование справедливости.
10.5.4 lockorder: глобально согласованный порядок захвата блокировок предотвращает взаимоблокировку
Для выполнения одного select рантайм должен одновременно удерживать блокировки всех задействованных каналов: необходимо поочерёдно проверить каждый из них на готовность и, возможно, поставить горутину в очередь ожидания каждого канала — всё это должно происходить под соответствующими блокировками. Как только в игру вступает несколько блокировок, возникает угроза взаимоблокировки. Предположим, горутина A выполняет select { case <-x: ; case <-y: }, а горутина B — select { case <-y: ; case <-x: }. Если каждая захватывает блокировки в порядке записи, A удерживает блокировку x и ожидает y, тогда как B удерживает блокировку y и ожидает x — хрестоматийная перекрёстная взаимоблокировка.
Выход — классический приём параллельного программирования: задать единый глобально согласованный порядок захвата всех блокировок и заставить всех придерживаться именно этого порядка. Если все горутины захватывают x и y в одном и том же относительном порядке, цикл ожидания становится невозможным. selectgo в качестве ключа глобального порядка использует адрес канала (sortkey возвращает значение указателя), сортирует case по адресу канала и получает lockorder:
|
|
Для сортировки применяется пирамидальная сортировка (heapsort), а не пакет sort, и тому есть конкретная причина: все данные select находятся на стеке горутины, а heapsort гарантирует время при постоянном потреблении стека, не утяжеляя этот предположительно лёгкий путь рекурсией или дополнительными аллокациями. Сортировка отправной точкой берёт pollorder (что позволяет заодно стабильно группировать несколько case одного и того же канала), а результат помещается в lockorder:
|
|
Захват и освобождение блокировок выполняются по lockorder: sellock захватывает в прямом порядке, selunlock освобождает в обратном; при этом одному и тому же каналу (несколько case могут использовать один и тот же канал) блокировка захватывается и освобождается лишь один раз:
|
|
Теперь разделение труда между двумя порядками очевидно: pollorder определяет порядок просмотра (для справедливости), lockorder — порядок захвата блокировок (для свободы от взаимоблокировок). Они не мешают друг другу — именно поэтому используются два массива, а не один.
10.5.5 Полный поток выполнения: три прохода
После подготовки данных тело selectgo представляет собой три прохода по case. Сначала общая схема:
flowchart TD
START["вход в selectgo"] --> BUILD["построить pollorder (случайный)<br/>и lockorder (по адресу)"]
BUILD --> LOCK["sellock: захватить все каналы согласно lockorder"]
LOCK --> P1["проход 1: найти готовый case по pollorder"]
P1 -->|найден готовый case| DO["send/recv, selunlock, вернуть индекс case"]
P1 -->|нет готовых| HASDEF{есть default?}
HASDEF -->|да| DEF["selunlock, вернуть default"]
HASDEF -->|нет| P2["проход 2: по lockorder добавить sudog<br/>в очередь ожидания каждого канала<br/>(isSelect=true)"]
P2 --> PARK["gopark: selparkcommit снимает блокировки по порядку, затем усыпляет горутину"]
PARK -->|разбужен одним из case| RELOCK["sellock повторно"]
RELOCK --> P3["проход 3: удалить собственный sudog из очередей остальных каналов,<br/>определить case, который разбудил горутину"]
P3 --> DO2["selunlock, вернуть индекс case"]
Проход первый: найти уже готовый case. Удерживая блокировки, проверяем case один за другим по pollorder. Case-приём проверяет, есть ли ожидающий отправитель в очереди отправки, есть ли данные в буфере, закрыт ли канал; case-отправка проверяет, не закрыт ли канал (отправка в закрытый канал должна вызвать панику), есть ли ожидающий получатель в очереди приёма, есть ли свободный слот в буфере. При первом совпадении выполняется переход к соответствующей ветке для завершения отправки или приёма (прямая передача, работа через буфер или чтение нулевого значения из закрытого канала), после чего блокировки освобождаются и происходит возврат. Если этот проход даёт результат, select завершается одной синхронной отправкой или приёмом без какой-либо блокировки.
|
|
default представлен здесь в виде block == false: компилятор кодирует наличие default в аргументе block. Если первый проход не находит ни одного готового case и block равен false, немедленно снимаются блокировки и возвращается -1 (вызывающий код соответственно выполняет тело default), тем самым превращая select в неблокирующую операцию.
Проход второй: прикрепиться к каждому каналу. Если ни один case не готов и требуется блокировка, горутина не может ждать лишь на одном канале; она должна оставить метку «я жду» на каждом канале, чтобы первый из них, ставший готовым, мог разбудить горутину. Для этого для каждого case берётся sudog, устанавливается isSelect = true, они нанизываются в цепочку waiting горутины по lockorder и каждый ставится в очередь отправки или приёма соответствующего канала:
|
|
Метка isSelect обязательна. Обычный sudog отправки/приёма привязан лишь к одному каналу, и тот, кто его будит, просто извлекает его из очереди; sudog select, напротив, одновременно находится в нескольких каналах, и будитель обязан знать, что «этот ожидатель принадлежит select и за него может одновременно бороться другой канал», и захватить его через CAS (посредством gp.selectDone), тем самым гарантируя, что один select пробуждается ровно одним каналом. Функция selparkcommit, переданная в gopark, обходит цепочку gp.waiting, уже упорядоченную по lockorder, и поочерёдно освобождает блокировку каждого канала перед тем, как перевести горутину в сон. Порядок здесь соответствует lockorder — чтобы не вносить новый цикл на пути разблокировки.
Проход третий: очистить состояние после пробуждения. Некий канал стал готов и разбудил горутину. Она снова вызывает sellock, захватывая блокировки всех каналов, затем обходит lockorder: в канале, который её разбудил, собственный sudog уже извлечён другой стороной (горутина опознаёт его и фиксирует сработавший case); во всех остальных каналах собственный sudog по-прежнему висит, и каждый нужно удалить через dequeueSudoG, иначе они засорят очереди ожидания «молчащих» каналов. Каждый sudog возвращается через releaseSudog, блокировки снимаются, и возвращается индекс сработавшего case.
|
|
Три прохода в совокупности составляют полную блокирующую семантику select: первый проход берёт то, что уже готово; если ничего нет — второй проход прикрепляется ко всем каналам и переводит горутину в сон; после пробуждения третий проход отменяет лишние привязки и фиксирует только тот случай, который вызвал пробуждение. reflect.Select (reflect_rselect) проходит через тот же selectgo, только предварительно преобразуя case, описанные через рефлексию, в массив scase.
10.5.6 Проектные решения и происхождение
Если рассмотреть несколько ключевых решений selectgo, каждое из них имеет чёткое обоснование:
- Два порядка, а не один. Справедливость (случайный опрос) и безопасность (упорядоченный захват блокировок) — ортогональные требования; объединение их в один порядок лишь создало бы взаимные ограничения. Разделение нагрузки на два массива
pollorderиlockorderстоит одного дополнительного сегмента стека размеромncases, обеспечивая при этом максимально чистое раздельное решение каждой задачи. - Адрес как глобальный порядок захвата блокировок. Использование значения указателя в качестве ключа сортировки выглядит почти примитивным, однако точно удовлетворяет единственному требованию — «глобальной согласованности». Оно не требует семантического приоритета между каналами, а лишь того, чтобы все горутины видели один и тот же порядок. Это роднит данный подход с классическим методом предотвращения взаимоблокировок — «захватывать несколько блокировок в фиксированном порядке», — отражение которого можно увидеть в двухфазной блокировке баз данных и в упорядочивании блокировок ядра ОС.
- Heapsort вместо общей сортировки. Это обеспечивает постоянное потребление стека и детерминированное время — ещё один пример «экономии на аллокациях и глубине стека на горячем пути», того же инженерного духа, что и экономия аллокатора на строках кэша (12.2).
isSelectи захват через CAS. Sudog select соотносится с несколькими каналами одновременно, что вводит новый вид состязания — «один ожидатель оспаривается несколькими сторонами»; оно разрешается меткойisSelectи атомарным захватомgp.selectDone, гарантируя, что один select завершается ровно один раз. Эта дополнительная сложность — необходимая цена самой возможности «ожидать нескольких каналов одновременно».
В контексте своего происхождения select является прямым наследником охраняемых команд (guarded commands) теории CSP. В CSP Хора 1978 года (10.1) процесс формирует выбор из множества охраняемых взаимодействий, а «инструкция выбора» продвигает один из готовых охранников; ещё раньше (1975) Дейкстра абстрагировал этот недетерминизм «несколько кандидатов, выполнить один» в языковую конструкцию — охраняемые команды. select в Go переносит эту теорию в рантайм: охранник — это case, «выбрать один» — три прохода selectgo, а теоретически расплывчатое «недетерминированно выбрать один» конкретизировано в Go как «равномерно случайным образом», реализованное посредством cheaprandn и алгоритма Фишера — Йетса. Языки occam и Newsqueak (ближайшие родственники модели конкурентности Go) выбрали тот же путь для своих аналогичных конструкций. Таким образом, select был спроектирован не с нуля; это теоретическая нить длиной более сорока лет, воплощённая инженерными средствами в сегодняшние несколько сотен строк select.go.
Дополнительная литература
- The Go Authors. runtime/select.go (
selectgo,sellock,selparkcommit,sortkey). https://github.com/golang/go/blob/master/src/runtime/select.go - The Go Authors. cmd/compile/internal/walk/select.go (понижение уровня для нуля case / одного case и default). https://github.com/golang/go/blob/master/src/cmd/compile/internal/walk/select.go
- The Go Authors. The Go Programming Language Specification: Select statements. https://go.dev/ref/spec#Select_statements
- Go issue #21806. runtime: select is not fair / biased case selection. https://github.com/golang/go/issues/21806
- C. A. R. Hoare. “Communicating Sequential Processes.” Communications of the ACM, 21(8), 1978. https://doi.org/10.1145/359576.359585
- Edsger W. Dijkstra. “Guarded Commands, Nondeterminacy and Formal Derivation of Programs.” Communications of the ACM, 18(8), 1975. https://doi.org/10.1145/360933.360975
- Эта книга: 10.1 Каналы и проектирование CSP, 10.3 Отправка, приём и прямая передача, 10.4 Семантика закрытия, 10.6 Модель памяти и эволюция без блокировок.