10.6 Модель памяти и эволюция без блокировок
Предыдущие разделы разобрали канал до составных частей: прямая передача в 10.3 позволяет
отправляющей и принимающей сторонам передавать значение напрямую, минуя кольцевой буфер, а select в
10.5 делает случайный выбор среди нескольких готовых веток. Эти механизмы объясняют, как канал
«работает». Настоящий раздел отвечает на два вопроса более высокого уровня: какую гарантию видимости канал
даёт конкурентной программе, и на один инженерный вопрос, который поднимается нередко, — почему канал по сей
день остаётся «мьютексом плюс очередью», а не предположительно более быстрой структурой без блокировок. Первый
вопрос связывает канал с моделью памяти в 11.9; второй представляет собой реальный
компромисс о том, «как корректность и сопровождаемость побеждают пиковую производительность».
10.6.1 Гарантии модели памяти для канала
Канал — это не просто труба для переноса данных; он одновременно является точкой синхронизации, устанавливающей отношение happens-before. Полная картина изложена в 11.9. Здесь мы выделяем четыре правила, касающиеся каналов, и разбираем смысл каждого из них. Модель памяти Go (go.dev/ref/mem, версия от июня 2022 года) утверждает следующее о каналах; мы используем терминологию, пересмотренную в версии 1.19, — synchronized before (обозначается ):
- Отправка синхронизирована до завершения соответствующего приёма. Это справедливо для любого канала (буферизованного или нет).
close(ch)синхронизирован до приёма, возвращающего нулевое значение по причине закрытия канала.- Для небуферизованного канала приём синхронизирован до завершения соответствующей отправки.
- Для буферизованного канала ёмкостью -й приём синхронизирован до завершения -й отправки.
Первое правило выражает наиболее очевидную интуицию: каждая запись, произведённая до помещения данных в канал, видна получателю после того, как он извлёк эти данные. Это является формальным основанием принципа «не общайтесь через разделяемую память — разделяйте память посредством коммуникации». Минимальный пример:
|
|
(1) предшествует (2) в порядке выполнения программы; по правилу 2, (2) синхронизирован до (3); (3) предшествует
(4) в порядке выполнения программы. Транзитивное замыкание этих трёх отрезков даёт (1) (4), поэтому (4)
должно прочитать 42. Если изобразить эту цепочку, межгорутинный шаг (close синхронизирован до приёма) — это в
точности то ребро, которое скрепляет два порядка выполнения программ:
flowchart LR
subgraph P["producer (производитель)"]
A["(1) data = 42"] -->|порядок выполнения программы| B["(2) close(done)"]
end
subgraph C["consumer (потребитель)"]
D["(3) <-done завершается"] -->|порядок выполнения программы| E["(4) print(data) читает 42"]
end
B -.->|"порядок синхронизации: правило 2"| D
Без этого правила канала между (1) и (4) остаётся лишь гонка данных, а программа в 11.9.1, печатающая 0, служит контрпримером.
10.6.2 «Инверсия» для небуферизованных отправки/приёма: отправка как квитанция
Третье правило легко проглядеть, однако именно оно составляет суть семантики небуферизованного канала. Обратите внимание: оно инвертирует направление — для буферизованного канала отправка предшествует завершению приёма; для небуферизованного приём предшествует завершению отправки.
Эта инверсия непосредственно соответствует прямой передаче, описанной в 10.3. Небуферизованный канал не имеет слота буфера, поэтому отправитель вынужден ждать, пока не появится получатель, и передать ему значение напрямую, прежде чем операция отправки будет считаться завершённой. Таким образом, событие «отправка вернула управление» служит доказательством того, что «кто-то уже принял значение». Небуферизованная отправка обладает семантикой подтверждения (acknowledgement):
|
|
Правило 3 гарантирует, что <-ch (приём) синхронизирован до завершения ch <- struct{}{}, а последнее в свою
очередь следует за doWork() в порядке выполнения программы. Читатель тем самым получает вывод, более сильный,
чем «данные поступили»: отправитель продвинулся дальше точки отправки. Буферизованный канал не может дать такой
гарантии, поскольку отправка может лишь поместить значение в буфер и вернуть управление, а получатель при этом
ещё не действовал. Реальное различие между небуферизованным и буферизованным каналом — не «может ли значение
кэшироваться», а именно эта инверсия направления видимости.
10.6.3 Буферизованный канал как семафор
Четвёртое правило — что -й приём синхронизирован до завершения -й отправки — выглядит абстрактно на первый взгляд; на самом деле это формальная основа идиомы «буферизованный канал есть счётный семафор». Прочитаем правило в обратном направлении: чтобы -я отправка завершилась, -й приём должен произойти раньше. То есть буфер удерживает не более непринятых значений, и -я отправка блокируется до тех пор, пока кто-нибудь не возьмёт одно значение и не освободит слот. Ёмкость — это в точности верхняя граница количества значений «одновременно в пути».
Классический способ ограничить конкурентность числом с помощью одного chan struct{}:
|
|
sem <- struct{}{} — это операция P (захват), а <-sem — операция V (освобождение). В любой момент количество
горутин внутри t.Run() не превышает : как только токенов заняты, -я операция sem <-
блокируется до тех пор, пока приём <-sem от какого-либо завершившегося обработчика не освободит слот. Правило
4 закрепляет это как гарантию на уровне модели памяти, а не просто как эмпирически «работающее» решение. Тип
элемента выбран как struct{}, поскольку он занимает ноль байт; буфер используется исключительно для подсчёта,
а не для хранения данных.
10.6.4 Почему канал не является lock-free
Прочитав структуру hchan в 10.1, внимательный читатель заметит lock mutex. В рантайме Go,
стремящемся к высокой конкурентности, где даже быстрый путь аллокатора
(12.2) и локальные очереди шедулера сделаны lock-free, почему
именно канал сохраняет мьютекс? Попытки изменить это предпринимались.
В 2014 году Дмитрий Вьюков подал предложение golang/go#8899 “runtime: lock-free channels” с проектным
документом и реализацией (CL 12544043). В предложении сообщалось, что на реальном приложении, которое когда-то
отказалось от каналов из-за их производительности, lock-free канал дал сквозное ускорение примерно на 23%.
Цифра значительная, и направление заманчиво. Тем не менее по состоянию на go1.26 hchan по-прежнему содержит
единственный lock mutex, защищающий кольцевой буфер и очереди ожидания отправки и приёма, а само предложение
давно находится в статусе Open / Unplanned, так и не войдя в релиз.
|
|
Почему предложение, подкреплённое данными об ускорении, было отложено? Здесь следует быть осторожным: проект не
публиковал «список причин отказа», и то, что изложено далее, — это выводимые трудности, основанные на семантике
канала, а не единственная подтверждённая причина. Канал — это не обычная конкурентная очередь; под одной
блокировкой он поддерживает несколько инвариантов, которые должны меняться согласованно: FIFO-порядок
очередей ожидания отправки и приёма
(10.6.5), атомарный выбор и
справедливость select для нескольких каналов (10.5), и широковещательное пробуждение всех
ожидающих при вызове close (одновременная активация recvq и sendq). Сделать все три аспекта lock-free
одновременно, при этом сохраняя линеаризуемость, значительно сложнее, чем реализовать чистую MPMC-очередь.
Стоимость мьютекса — это конкуренция за блокировку; то, что он даёт взамен, — несравнимо более простые
реализация и верификация этих инвариантов. Позиция команды Go здесь разделяет тот же подход, что и раскрытие в
модели памяти только последовательно согласованных атомарных операций
(11.9.10): когда пиковая производительность вступает в противоречие с корректностью и
сопровождаемостью, приоритет отдаётся последним.
10.6.5 Инженерный анализ FIFO, справедливости и справедливости select
Два инварианта, которые охраняет блокировка, — FIFO и справедливость — сами прошли определённую эволюцию, и каждый заслуживает отдельного упоминания.
FIFO в очереди блокировки. Предложение golang/go#11506 “runtime: make sure blocked channels run operations
in FIFO order” (поданное Расом Коксом, исправленное в начале версии Go 1.6) указало на следующую опасность:
когда несколько горутин заблокированы на некотором канале и канал становится доступным, выполняющаяся горутина,
«проходящая мимо», может завершить операцию раньше уже заблокированных, оставляя последних подвергаться
произвольным задержкам. Исправление — это именно прямая передача из 10.3: когда канал
становится доступным, пробуждение передаёт значение напрямую ожидающему в голове recvq, а recvq — это
очередь по принципу «первым пришёл — первым обслужен», упорядоченная по времени прихода (enqueue добавляет в
хвост, dequeue извлекает из головы). Принцип «первым пришёл — первым обслужен» тем самым становится
гарантией, а не вероятностью.
Справедливость select. Когда select сталкивается с несколькими одновременно готовыми ветками, он должен
выбрать одну случайным образом, иначе ветки, написанные раньше, будут систематически вытеснять более поздние.
Предложение golang/go#21806 “runtime: select is not fair” зафиксировало, что в Go 1.9 случайность была
утрачена при некоторых конфигурациях каналов. Современная реализация (10.5) решает это с помощью
единственного перемешивания:
|
|
pollorder определяет порядок, в котором проверяется готовность каждой ветки, а перемешивание даёт каждой
готовой ветке равную вероятность быть выбранной. Следует отметить, что pollorder и lockorder — это два
разных упорядочивания: lockorder сортирует по адресу канала, обеспечивая единообразный порядок захвата
блокировок несколькими операторами select для предотвращения взаимоблокировок; pollorder отвечает за
справедливость. Оба существуют в рамках протокола этой блокировки, что служит дополнительным аргументом в
пользу суждения из 10.6.4: инварианты FIFO и справедливость
select, «охватывающие несколько ожидающих и несколько каналов», — это конкретная причина удержания канала
внутри блокировки.
10.6.6 Итог: компромисс ради одной блокировки
Гарантии модели памяти для канала и форма его реализации — две стороны одного компромисса. Четыре правила
synchronized-before дают пользователю сильный и ясный контракт видимости: отправка устанавливает
happens-before, небуферизованная отправка является квитанцией, а ёмкость буфера — семафором. Чтобы надёжно
соблюдать этот контракт поверх взаимосвязанных инвариантов FIFO, справедливости select и широковещательного
оповещения при close, наиболее прямой путь — одна блокировка. Lock-free канал (#8899) действительно может
быть быстрее на отдельных нагрузках, но только ценой переписывания доказательств корректности этих инвариантов
— счёт, который команда Go не выписала по сей день. Прирост производительности никогда не достаётся бесплатно;
здесь его ценой является семантика конкурентности, которую можно прочитать, сопровождать и которой можно
доверять. Следующая глава 11.9 поместит этот контракт канала обратно в полную модель
памяти Go, рядом с правилами мьютекса и атомарных операций.
Дополнительные материалы
- The Go Authors. The Go Memory Model (Version of June 6, 2022). Section “Channel communication”. https://go.dev/ref/mem
- Dmitry Vyukov. runtime: lock-free channels. Go issue #8899 (with design document and CL 12544043; reports about a 23% speedup; as of go1.26 still Unplanned, not merged). https://github.com/golang/go/issues/8899
- Russ Cox. runtime: make sure blocked channels run operations in FIFO order. Go issue #11506 (fixed early in Go 1.6). https://github.com/golang/go/issues/11506
- The Go Authors. runtime: select is not fair. Go issue #21806. https://github.com/golang/go/issues/21806
- The Go Authors. src/runtime/chan.go, src/runtime/select.go. (go1.26:
hchanstill protected bylock mutex;selectshufflespollorderwithcheaprandn.) https://github.com/golang/go/tree/master/src/runtime - C. A. R. Hoare. “Communicating Sequential Processes.” Communications of the ACM, 21(8), 1978. https://doi.org/10.1145/359576.359585
- Эта книга: 10.3 Отправка/приём и прямая передача, 10.5 select и справедливость, 11.9 Модель согласованности памяти.