Модель памяти C++: как устроен порядок операций внутри
Почему два потока видят записи в разном порядке, что на самом деле означают memory_order_relaxed и seq_cst и где счётчик на atomic всё равно ломается.

Классический баг, который не воспроизводится под отладчиком
Два потока. Первый пишет data = 42; ready = true;. Второй крутится в while (!ready); и потом читает data. Если ready и data — обычные int, второй поток может увидеть ready == true, но прочитать data == 0. Под отладчиком, с одним активным потоком и выключенными оптимизациями, всё работает. В релизе на многоядерной машине — раз в миллион итераций отдаёт мусор.
Причина не в race condition в привычном смысле, а в том, что стандарт C++ (начиная с C++11 и до текущего C++23) вообще не гарантирует, что запись data станет видна другому потоку раньше записи ready. Порядок, который вы написали в исходнике, — не тот порядок, который увидит другое ядро. Разберём, откуда берётся этот зазор и какими средствами модель памяти его закрывает.
Три уровня переупорядочивания
Между строкой исходника и моментом, когда чужое ядро увидит результат, стоят три независимых переставщика операций.
- Компилятор. При
-O2он свободно меняет местами независимые обращения к памяти, если это не меняет наблюдаемого поведения одного потока. Запись вdataи запись вreadyдля компилятора независимы — он может их поменять местами или вообще держатьdataв регистре. - Процессор. Ядро исполняет инструкции out-of-order и имеет store buffer: запись сначала попадает в буфер, а в кеш (и к другим ядрам) уходит позже. На x86-64 буфер не переставляет записи между собой (модель TSO — сильная), но допускает переупорядочивание store→load. На ARM и POWER модель слабая: почти любые пары могут переставиться.
- Система кеш-когерентности. Даже когда запись ушла из буфера, момент, в который её увидит конкретное чужое ядро, зависит от протокола когерентности.
Модель памяти C++ — это контракт, который поверх всех трёх уровней даёт переносимые гарантии. Вы формулируете требования в терминах std::atomic и memory_order, а компилятор превращает их в нужные барьеры под конкретную архитектуру.
Data race — это undefined behavior
Ключевой факт: если два потока обращаются к одной ячейке, хотя бы один пишет, и обращения не упорядочены отношением happens-before, — это data race, а data race в C++ есть неопределённое поведение. Не «прочитаете старое значение», а UB со всеми последствиями: компилятор вправе предположить, что гонки нет, и на этом основании выкинуть ваш while (!ready) в бесконечный цикл или наоборот. Поэтому «обычный bool ready без atomic» — не медленный код, а формально сломанный.
memory_order: шесть значений, четыре режима
Каждая атомарная операция принимает аргумент порядка. Значений шесть, но по смыслу режимов четыре.
| memory_order | Что гарантирует | Типичное применение |
|---|---|---|
relaxed | Только атомарность самой операции. Никакого упорядочивания относительно других обращений | Счётчики, где важен лишь итог, не порядок |
acquire (при load) | Никакое чтение/запись после не переедет вверх через эту загрузку | Читатель флага готовности |
release (при store) | Никакое чтение/запись до не переедет вниз через эту запись | Писатель флага готовности |
acq_rel | И acquire, и release для read-modify-write операций | fetch_add, compare_exchange в очередях |
seq_cst | Всё вышеперечисленное плюс единый глобальный порядок всех seq_cst операций | Значение по умолчанию, самый безопасный вариант |
consume формально существует, но комитет фактически не рекомендует его использовать: большинство компиляторов повышают его до acquire. Не закладывайтесь на него.
Как acquire и release чинят исходный пример
Пара release-store и acquire-load создаёт то самое happens-before. Если поток-читатель через acquire увидел значение, записанное release, то все записи, сделанные писателем до release-store, видны читателю после acquire-load.
#include <atomic>
#include <thread>
#include <cassert>
// C++11 и новее, компилировать с -O2
std::atomic<bool> ready{false};
int payload = 0; // обычный int, защищён флагом
void producer() {
payload = 42; // (1) обычная запись
ready.store(true, std::memory_order_release); // (2) release
}
void consumer() {
// acquire крутится, пока не увидит true
while (!ready.load(std::memory_order_acquire)) {
// busy-wait; в реальном коде здесь пауза/yield
}
assert(payload == 42); // гарантированно 42: (1) happens-before чтения
}Запись (1) — обычная, не атомарная. Это допустимо: release в (2) не даёт компилятору и процессору переставить (1) ниже (2), а acquire в читателе не даёт прочитать payload раньше, чем увидели флаг. Гонки нет, UB нет, значение гарантированно 42.
relaxed: где он безопасен, а где нет
Самая частая ошибка — считать, что relaxed «просто быстрый atomic». Он атомарен, но не упорядочивает ничего вокруг себя. Для чистого счётчика это ровно то, что нужно.
#include <atomic>
std::atomic<long> hits{0};
void on_request() {
// Нам важен только итоговый счёт, а не порядок
// относительно другой работы — relaxed достаточно и дешевле.
hits.fetch_add(1, std::memory_order_relaxed);
}relaxed уместен ровно тогда, когда единственное, что вам нужно от переменной, — её собственное итоговое значение. Как только через неё вы пытаетесь «передать» другие данные другому потоку — relaxed сломает вас молча.
Классическая ловушка — заменить в примере с флагом release/acquire на relaxed «ради скорости». Атомарность флага сохранится, но happens-before исчезнет, и payload снова начнёт читаться как 0.
seq_cst: цена дефолта
Если писать ready.store(true) без второго аргумента, подставляется seq_cst — самый строгий режим. Он корректен всегда, поэтому и стоит по умолчанию. Плата — барьеры.
- На x86-64 seq_cst-store компилируется в инструкцию с фиксацией (
xchgилиmov+mfence), которая сбрасывает store buffer. seq_cst-load обычно остаётся обычнымmov. То есть на x86 разница между acquire/release и seq_cst заметна в основном на записи. - На ARM64 seq_cst раскладывается в инструкции с барьерами (
stlr/ldarи, в части случаев,dmb), и разница с relaxed/acquire-release существеннее.
Точную кодогенерацию для вашей версии компилятора смотрите не в статье, а в дизассемблере или на compiler explorer — она меняется между версиями GCC и Clang. Практический вывод: начинайте с seq_cst, ослабляйте до acquire/release/relaxed только там, где профилировщик показал, что барьер стоит в горячей точке, и вы можете доказать корректность.
Где модель ломается на практике
Граничные случаи, на которых спотыкаются даже опытные:
- Ослабили порядок «на глаз». Понижение с seq_cst до relaxed без доказательства корректности — самый частый источник heisenbug'ов. Такой баг не ловится тестами: он проявляется на другой архитектуре или под другой нагрузкой через месяцы.
- ABA в lock-free структурах.
compare_exchangeвидит то же значение указателя и считает, что ничего не менялось, хотя объект успели освободить и переиспользовать. Модель памяти тут не спасает — нужны tagged pointers или hazard pointers. - Смешивание atomic и обычного доступа к одной переменной. Если хоть один поток пишет в
xнеатомарно, а другой читает атомарно, это по-прежнему гонка. - Ожидание, что
volatileзаменяет atomic. В C++volatileне даёт ни атомарности, ни упорядочивания между потоками. Он для memory-mapped регистров и сигналов, а не для многопоточности.
Как это проверять и что делать дальше
Модель памяти нельзя проверить «прогоном на своей машине»: ваш x86 с сильной моделью TSO спрячет ошибки, которые вылезут на ARM. Инструменты:
- Соберите с ThreadSanitizer (
-fsanitize=threadв GCC и Clang) и прогоните нагрузочные тесты — он ловит data race через happens-before, а не через «повезло воспроизвести». - Смотрите кодогенерацию на godbolt.org: сравните, во что превращаются seq_cst и acquire/release на x86-64 и ARM64, прежде чем ослаблять порядок.
- Для lock-free кода изучите формальные модели (litmus-тесты) — примеры есть в документации по
<atomic>и в материалах комитета. - Дальше в глубину: инвалидация кеша между ядрами, false sharing (когда две независимые atomic-переменные лежат в одной кеш-линии и убивают производительность) — это отдельная тема, здесь мы разбирали только семантику порядка.
Практическое правило на каждый день: пишите seq_cst по умолчанию, покрывайте многопоточный код ThreadSanitizer, и ослабляйте memory_order только с профилировщиком в одной руке и доказательством корректности в другой.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: cppreference: std::memory_order, cppreference: Memory model
Частые вопросы
У меня всё работает на x86 без atomic — зачем усложнять?
Чем acquire/release отличается от seq_cst на практике?
Можно ли использовать volatile вместо atomic для многопоточности?
А если ThreadSanitizer ничего не нашёл — код точно корректен?
В какой версии C++ появилась модель памяти и менялась ли она?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.