Go under the hood
Go: Under the Hood

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 (обозначается <<):

  1. Отправка синхронизирована до завершения соответствующего приёма. Это справедливо для любого канала (буферизованного или нет).
  2. close(ch) синхронизирован до приёма, возвращающего нулевое значение по причине закрытия канала.
  3. Для небуферизованного канала приём синхронизирован до завершения соответствующей отправки.
  4. Для буферизованного канала ёмкостью CC kk-й приём синхронизирован до завершения (k+C)(k+C)-й отправки.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var data int
var done = make(chan struct{})

func producer() {
	data = 42       // (1) обычная запись
	close(done)     // (2) закрытие является синхронизацией
}

func consumer() {
	<-done          // (3) приём завершается
	print(data)     // (4) гарантированно прочитает 42
}

(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) &lt;-done завершается"] -->|порядок выполнения программы| E["(4) print(data) читает 42"]
    end
    B -.->|"порядок синхронизации: правило 2"| D

Без этого правила канала между (1) и (4) остаётся лишь гонка данных, а программа в 11.9.1, печатающая 0, служит контрпримером.

10.6.2 «Инверсия» для небуферизованных отправки/приёма: отправка как квитанция

Третье правило легко проглядеть, однако именно оно составляет суть семантики небуферизованного канала. Обратите внимание: оно инвертирует направление — для буферизованного канала отправка предшествует завершению приёма; для небуферизованного приём предшествует завершению отправки.

Эта инверсия непосредственно соответствует прямой передаче, описанной в 10.3. Небуферизованный канал не имеет слота буфера, поэтому отправитель вынужден ждать, пока не появится получатель, и передать ему значение напрямую, прежде чем операция отправки будет считаться завершённой. Таким образом, событие «отправка вернула управление» служит доказательством того, что «кто-то уже принял значение». Небуферизованная отправка обладает семантикой подтверждения (acknowledgement):

1
2
3
4
5
6
7
8
9
ch := make(chan struct{}) // небуферизованный

go func() {
	doWork()
	ch <- struct{}{} // отправка: возвращает управление только после того, как другая сторона приняла значение
}()

<-ch       // приём: в этот момент можно утверждать, что doWork завершена и её записи видимы
useResult()

Правило 3 гарантирует, что <-ch (приём) синхронизирован до завершения ch <- struct{}{}, а последнее в свою очередь следует за doWork() в порядке выполнения программы. Читатель тем самым получает вывод, более сильный, чем «данные поступили»: отправитель продвинулся дальше точки отправки. Буферизованный канал не может дать такой гарантии, поскольку отправка может лишь поместить значение в буфер и вернуть управление, а получатель при этом ещё не действовал. Реальное различие между небуферизованным и буферизованным каналом — не «может ли значение кэшироваться», а именно эта инверсия направления видимости.

10.6.3 Буферизованный канал как семафор

Четвёртое правило — что kk-й приём синхронизирован до завершения (k+C)(k+C)-й отправки — выглядит абстрактно на первый взгляд; на самом деле это формальная основа идиомы «буферизованный канал есть счётный семафор». Прочитаем правило в обратном направлении: чтобы (k+C)(k+C)-я отправка завершилась, kk-й приём должен произойти раньше. То есть буфер удерживает не более CC непринятых значений, и (C+1)(C+1)-я отправка блокируется до тех пор, пока кто-нибудь не возьмёт одно значение и не освободит слот. Ёмкость CC — это в точности верхняя граница количества значений «одновременно в пути».

Классический способ ограничить конкурентность числом CC с помощью одного chan struct{}:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Ограничить количество одновременно работающих обработчиков числом C
func bounded(tasks []Task, C int) {
	sem := make(chan struct{}, C) // буферизованный канал ёмкостью C в роли семафора
	var wg sync.WaitGroup
	for _, t := range tasks {
		sem <- struct{}{} // занять токен: (C+1)-я отправка блокируется здесь
		wg.Add(1)
		go func(t Task) {
			defer wg.Done()
			defer func() { <-sem }() // освободить токен: один приём пропускает одну ожидающую отправку
			t.Run()
		}(t)
	}
	wg.Wait()
}

sem <- struct{}{} — это операция P (захват), а <-sem — операция V (освобождение). В любой момент количество горутин внутри t.Run() не превышает CC: как только CC токенов заняты, (C+1)(C+1)-я операция 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, так и не войдя в релиз.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// hchan: по состоянию на go1.26 по-прежнему полностью защищён одним мьютексом (схема, см. 10.1)
type hchan struct {
	qcount   uint   // количество элементов в буфере в данный момент
	dataqsiz uint   // ёмкость кольцевого буфера C
	sendx    uint   // курсор отправки
	recvx    uint   // курсор приёма
	recvq    waitq  // очередь заблокированных получателей (FIFO)
	sendq    waitq  // очередь заблокированных отправителей (FIFO)
	// lock защищает все поля hchan, а также ряд полей sudogs, заблокированных на этом канале
	lock mutex
}

Почему предложение, подкреплённое данными об ускорении, было отложено? Здесь следует быть осторожным: проект не публиковал «список причин отказа», и то, что изложено далее, — это выводимые трудности, основанные на семантике канала, а не единственная подтверждённая причина. Канал — это не обычная конкурентная очередь; под одной блокировкой он поддерживает несколько инвариантов, которые должны меняться согласованно: 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) решает это с помощью единственного перемешивания:

1
2
3
4
5
6
// Перед выполнением select перемешать порядок опроса веток (pollorder) для обеспечения справедливости (схема, см. src/runtime/select.go)
for i := 1; i < ncases; i++ {
	j := cheaprandn(uint32(i + 1)) // дешёвое случайное число
	pollorder[i] = pollorder[j]    // перемешивание Фишера–Йетса
	pollorder[j] = uint16(i)
}

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, рядом с правилами мьютекса и атомарных операций.

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

  1. The Go Authors. The Go Memory Model (Version of June 6, 2022). Section “Channel communication”. https://go.dev/ref/mem
  2. 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
  3. 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
  4. The Go Authors. runtime: select is not fair. Go issue #21806. https://github.com/golang/go/issues/21806
  5. The Go Authors. src/runtime/chan.go, src/runtime/select.go. (go1.26: hchan still protected by lock mutex; select shuffles pollorder with cheaprandn.) https://github.com/golang/go/tree/master/src/runtime
  6. C. A. R. Hoare. “Communicating Sequential Processes.” Communications of the ACM, 21(8), 1978. https://doi.org/10.1145/359576.359585
  7. Эта книга: 10.3 Отправка/приём и прямая передача, 10.5 select и справедливость, 11.9 Модель согласованности памяти.