22. Memory Ordering и CPU Memory Model
В предыдущей главе (21. False Sharing и Cache Line Contention) мы познакомились с аппаратным протоколом MESI, обеспечивающим когерентность кэшей. Если Ядро 1 изменило значение по некоторому адресу в памяти, протокол гарантирует, что Ядро 2 рано или поздно обязательно увидит именно это новое значение, а не устаревший мусор.
Но здесь инженеров подстерегает грандиозная иллюзия, на которой спотыкаются многие разработчики при переходе от классических мьютексов к конкурентному lock-free программированию.
Протокол когерентности кэшей гарантирует актуальность значения одной конкретной ячейки. Однако он ничего не говорит о том, в каком ПОРЯДКЕ другие ядра увидят последовательность изменений нескольких независимых переменных!
И компилятор Go при оптимизации машинного кода, и сам процессор в процессе исполнения имеют законное право переставлять инструкции записи и чтения местами. Это явление регулируется фундаментальной концепцией — Memory Ordering (Порядок доступа к памяти) и Моделью памяти процессора (CPU Memory Model).
Иллюзия последовательного исполнения
Когда мы пишем программу на экране редактора или читаем чужой код сверху вниз, наш разум находится в плену естественной интуиции: строка 2 обязана выполниться строго после завершения строки 1. Эта идеализированная картина мира в науке называется Последовательной согласованностью (Sequential Consistency), описанной Лесли Лампортом еще в 1979 году.
Взглянем на классический паттерн передачи сообщений между двумя параллельными горутинами без использования мьютексов (Message Passing):
package main
import "fmt"
var data int
var ready bool
// Горутина 1 (Писатель - Ядро 1)
func producer() {
data = 42 // Операция A: подготовили полезные данные
ready = true // Операция B: подняли флаг готовности
}
// Горутина 2 (Читатель - Ядро 2)
func consumer() {
for !ready { // Операция C: крутимся (spin-wait), пока ready != true
// ждем
}
fmt.Println(data) // Операция D: читаем data
}
С точки зрения человеческой логики сценарий безупречен:
- Писатель записывает число
42в переменнуюdata. - Писатель выставляет флаг
ready = true. - Читатель дожидается появления флага
readyи радостно считываетdata.
Кажется очевидным, что читатель обязан напечатать 42.
Может ли этот код вывести 0?
Ответ поражает: Да, по спецификации этот код может вывести 0. И это не дефект микросхемы и не случайная ошибка транслятора. Это строгое следствие архитектурных оптимизаций компилятора и кремния. (Оговорка для честности: на x86 такой сбой на практике воспроизвести почти невозможно — TSO сохраняет порядок записей; чаще он проявляется на ARM64/POWER. А ещё в Go это просто гонка данных, и компилятор вправе «вытащить» чтение ready из цикла, превратив его в бесконечный. На Go 1.27.1 для глобальной переменной ready такого не происходит — флаг перечитывается на каждой итерации, — но полагаться на это нельзя.)
Почему происходит Reordering (Переупорядочивание)?
Перестановка операций чтения и записи (Memory Reordering) происходит на двух совершенно независимых уровнях архитектуры.
1. Оптимизация компилятора
Компилятор Go при генерации промежуточного SSA-представления анализирует функцию producer().
Переменная data и переменная ready расположены по разным адресам в памяти и логически никак не связаны между собой по данным (здесь нет зависимости вида data = 42; data = data + 1).
Ради экономии регистров процессора или уменьшения размера генерируемого кода компилятор имеет полное право поменять инструкции местами и сгенерировать ассемблер, где флаг ready = true поднимается до того, как число 42 запишется в data!
2. Аппаратная перестановка (Hardware Reordering) и Store Buffer
Но предположим, что мы отключили все оптимизации компилятора или написали функцию на строгом ассемблере, сохранив порядок инструкций. Код все равно разрушится на уровне физического кремния!
Вспомним физику записи из статьи 20. Многоядерные процессоры и Cache Coherence. Чтобы записать data = 42, ядру процессора требуется получить эксклюзивный доступ к кэш-линии (состояние M протокола MESI). Если эта линия находится в состоянии Shared или вовсе отсутствует в кэше ядра, процессору нужно запросить эксклюзивное владение (RFO, Invalidate) и ждать ответов порядка 40–80 наносекунд.
Современный процессор, работающий на частоте 4 ГГц, физически не может позволить себе стоять без дела.
Поэтому инженеры встроили между исполнительным ядром и кэшем L1 специальную аппаратную очередь — Store Buffer (Буфер записи).
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph Core["Исполнительное ядро (Ядро 1)"]
ALU["ALU / Конвейер"]:::process
SB["Store Buffer (Буфер записи)<br>Быстрая аппаратная FIFO очередь"]
end
subgraph MemorySubsystem["Подсистема памяти"]
L1["Локальный кэш L1 (Ядро 1)"]
Bus["Межъядерная шина (MESI)"]
end
ALU -->|"1. Быстрый сброс: data = 42"| SB
ALU -->|"3. Слабая модель (ARM/POWER): запись ready может<br>попасть в L1 раньше, чем ожидающая data"| L1
SB -.->|"2. Store Forwarding<br>(Ядро 1 видит свои записи)"| ALU
SB -->|"5. Запоздалый асинхронный сброс в L1"| L1
L1 <--> Bus
Анатомия скрытой катастрофы:
- Ядро 1 исполняет
data = 42. Кэш-линия с переменнойdataотсутствует в кэше L1. Процессор не ждет! Он сбрасывает пару «адрес-значение» в Store Buffer и мгновенно переходит к следующей инструкции. - Ядро 1 исполняет
ready = true. Эта запись тоже уходит в Store Buffer, но кэш-линия с переменнойreadyслучайно уже находится в локальном кэше L1 в состоянии Exclusive (E), и ее можно применить сразу. В слабой модели памяти (ARM64, POWER) буферу записей разрешено сбросить такую запись в L1 раньше предыдущей, всё еще ждущей RFO. На x86 (TSO) Store Buffer — строгая FIFO-очередь, и обогнатьdataзаписьreadyне может. - Ядро 2 на соседнем кристалле опрашивает переменную
ready. По шине MESI оно немедленно получает актуальное значениеtrueиз кэша Ядра 1. - Горутина на Ядре 2 выходит из цикла ожидания
for !readyи читает переменнуюdata. - Но значение
42всё еще томится в очереди Store Buffer Ядра 1 — оно еще физически не сброшено в кэш L1! - Ядро 2 загружает старую кэш-линию
dataиз памяти, видит начальный 0 и выводит его в терминал!
С точки зрения внешнего наблюдателя на слабой модели Ядро 1 выполнило инструкции задом наперед. При этом само Ядро 1 не видит никакой проблемы, так как механизм Store Forwarding позволяет ему локально читать значения из собственного Store Buffer еще до их попадания в кэш.
Memory Models: x86-64 против ARM64
Правила, разрешающие процессору выполнять перестановки, строго специфицированы в его Аппаратной модели памяти (CPU Memory Model). Разные микроархитектуры предлагают разную степень свободы для Out-of-Order оптимизаций.
1. Сильная модель (Strong Memory Model) — x86-64 (Intel / AMD)
Архитектура x86-64 придерживается модели TSO (Total Store Order).
Это крайне консервативная, строгая модель:
- ЗАПРЕЩЕНЫ перестановки между двумя чтениями (Load-Load).
- ЗАПРЕЩЕНЫ перестановки между двумя записями (Store-Store).
- ЗАПРЕЩЕНА перестановка записи перед предшествующим чтением, то есть запись не может «обогнать» более раннее чтение (Load-Store).
Единственная аппаратная вольность, разрешенная в x86 TSO — это то, что последующее чтение может завершиться раньше более ранней записи, пока та сидит в Store Buffer (Store-Load). Из-за нее ломаются даже на x86 алгоритмы взаимного исключения вроде Деккера или Петерсона, если не поставить барьер.
Именно поэтому наивный код producer / consumer на процессорах Intel x86 часто чудом работает правильно (если компилятор не переставил инструкции), маскируя грубейшую ошибку проектирования.
2. Слабая модель (Weak Memory Model) — ARM64 (Apple Silicon, Graviton)
Архитектура ARM64 проектировалась с прицелом на предельную энергоэффективность и максимальный параллелизм.
Здесь царствует Weak Memory Ordering:
- Разрешены ЛЮБЫЕ перестановки независимых инструкций: Load-Load, Store-Store, Load-Store и Store-Load!
Процессор ARM64 отправляет независимые чтения и записи в память в том порядке, который наиболее выгоден его конвейеру и контроллеру кэша в текущую наносекунду. На машинах с Apple M3 или серверах AWS Graviton наивный код producer / consumer будет выдавать нули регулярно.
Go Memory Model (Модель памяти Go)
Неужели программист на Go обязан держать в голове нюансы поведения микроархитектур x86, ARM, RISC-V и MIPS?
К счастью, нет! Создатели Go избавили нас от этого кошмара, разработав формальную Модель памяти языка Go (The Go Memory Model) (существенно уточненную Рассом Коксом в версиях Go 1.19+).
Вы пишете код для абстрактной виртуальной машины Go. А компилятор берет на себя рутину: на процессорах x86 он опустит лишние инструкции, а на ARM64 автоматически встроит специальные инструкции аппаратных барьеров (Memory Barriers / Fences), связывающие руки ретивому кремнию.
Фундаментом модели памяти Go является строгое математическое отношение частичного порядка: Happens-Before (Происходит-До).
Священное правило Модели Памяти Go:
Если операция записи $A$ гарантированно Happens-Before операции чтения $B$, то операция $B$ обязана увидеть результат операции $A$ (или результат более поздней записи).
Если между операцией записи и операцией чтения нет связи Happens-Before, они выполняются конкурентно — это классический Data Race. В модели памяти Go (редакция 2022 года) гоночное чтение одного машинного слова увидит одно из значений, записанных конкурентно, но многословные значения (строки, слайсы, интерфейсы) могут «порваться» — стать смесью двух записей, — поэтому полагаться на результат нельзя.
Как создать связь Happens-Before в Go?
Язык Go предоставляет исчерпывающий набор высокоуровневых гарантий:
- Последовательный код: В рамках одной горутины инструкции логически выполняются строго в порядке их написания (хотя компилятор может их переставлять, наблюдаемый эффект внутри горутины всегда эквивалентен последовательному).
- Инициализация: Функция
init()импортируемого пакета Happens-Before начала выполнения функцииmain.main(). - Запуск горутины: Вызов
go f()Happens-Before начала выполнения тела функцииf(). - Синхронизация через каналы (Channels):
- Отправка элемента в канал Happens-Before завершения приема этого элемента из канала.
- Закрытие канала Happens-Before возврата нулевого значения читателю.
- Прием из небуферизованного канала Happens-Before завершения соответствующей отправки.
- Мьютексы (
sync.Mutex): ВызовUnlock()на мьютексе Happens-Before завершения любого последующего вызоваLock()на том же мьютексе. - Атомики (
sync/atomic): Начиная с Go 1.19, операции пакетаsync/atomicобладают семантикой последовательной согласованности (Sequential Consistency) и создают строгие ребра в графе Happens-Before.
Перепишем наш алгоритм корректно, используя идиоматичный канал:
package main
import "fmt"
var data int
var ready = make(chan struct{}) // Канал выступает барьером синхронизации
func producer() {
data = 42 // Операция A
ready <- struct{}{} // Операция B (отправка в канал)
}
func consumer() {
<-ready // Операция C (чтение из канала)
fmt.Println(data) // Операция D (чтение data)
}
Разберем цепь причинно-следственных связей:
- Операция A Happens-Before операции B (порядок внутри горутины писателя).
- Операция B Happens-Before операции C (правило отправки и приема из канала).
- Операция C Happens-Before операции D (порядок внутри горутины читателя).
- Транзитивность: Операция A гарантированно Happens-Before операции D!
Компилятор Go видит использование канала и на процессоре ARM64 генерирует нужные аппаратные инструкции барьеров памяти. Программа со 100% гарантией выведет число 42 на любом железе в мире.
Итог
- Memory Ordering регулирует допустимые перестановки операций чтения и записи компилятором и процессором ради достижения максимальной скорости.
- Store Buffer — скоростной FIFO-буфер процессора, позволяющий ядру не ждать сброса записей в кэш. Именно он приводит к тому, что соседние ядра видят результаты записей с непредсказуемой задержкой.
- Процессоры различаются моделями памяти: x86-64 использует строгую модель TSO (запрещая почти все перестановки кроме Store-Load), а ARM64 реализует слабую модель (Weak Ordering), переставляя инструкции с максимальной свободой.
- В коде на Go правилом синхронизации является Go Memory Model и отношение Happens-Before.
- Использование каналов, мьютексов (
sync.Mutex), групп ожидания (sync.WaitGroup) и атомиков (sync/atomic) инструктирует компилятор генерировать правильные платформенные барьеры, принуждающие процессор соблюдать порядок.
Но какие именно машинные инструкции заставляют своенравный ARM или x86 зафиксировать порядок памяти? Что такое атомарные операции на уровне кремния и как инструкция Compare-And-Swap (CAS) позволяет обновлять данные без тяжелых мьютексов операционной системы?
Об этом читайте в следующей главе: 23. Атомарные операции. CAS, Compare And Swap, Fetch And Add.