Модель памяти в C++: как устроено внутри и что ломается
Почему два потока, которые «просто читают и пишут переменную», дают результат, невозможный ни при какой последовательности операций. Разбор модели памяти C++11 и далее: happens-before, барьеры, порядки memory_order.

Классический эксперимент: два потока, две переменные x и y, обе изначально нули. Поток A пишет x = 1, потом читает y. Поток B пишет y = 1, потом читает x. Интуиция подсказывает, что хотя бы один поток должен прочитать единицу — ведь кто-то записал первым. На практике на x86 и особенно на ARM оба потока регулярно читают нули. Это не баг компилятора и не гонка в бытовом смысле: это ровно то поведение, которое разрешает модель памяти C++. Разберём, почему так и какие гарантии вы на самом деле покупаете, обращаясь к std::atomic.
Материал предполагает, что вы уже писали многопоточный код на C++ и упирались в вопрос «почему это иногда падает под нагрузкой». Ориентируемся на стандарт C++11 и позже — модель памяти появилась именно в C++11 и с тех пор уточнялась, но базовые понятия стабильны вплоть до C++23.
Что такое модель памяти и зачем она вообще есть
До C++11 стандарт не знал слова «поток». Многопоточность жила в библиотеках (pthreads, Win32) и в головах авторов компиляторов. Формально программа с двумя потоками, дёргающими одну переменную, была за рамками языка — компилятор мог делать что угодно.
C++11 ввёл абстрактную машину, которая описывает, какие результаты многопоточной программы допустимы. Ключевое понятие — data race: если два потока обращаются к одной ячейке памяти, хотя бы одно обращение — запись, и между ними нет отношения happens-before, поведение программы неопределённое. Не «прочитаете мусор», а именно UB: компилятор вправе выкинуть код, зациклить программу или отформатировать вам настроение.
happens-before — отношение частичного порядка между операциями в программе. Если операция A happens-before B, то эффекты A видны при выполнении B. Это не то же самое, что «раньше по времени»: две операции могут выполниться в любом порядке по часам, но не иметь отношения happens-before между собой.
Единственный способ выйти из-под UB без блокировок — атомарные операции. Они не data race по определению, даже при одновременном доступе. Но «не UB» не значит «интуитивно понятно»: результат по-прежнему зависит от порядка памяти, который вы запросили.
Почему железо переупорядочивает то, что вы написали
Вы пишете x = 1; r = y;. Процессор не обязан выполнять это в таком порядке. Причин две.
- Компилятор переставляет независимые операции при оптимизации: если
xиyне связаны, порядок записи и чтения для него безразличен. - Процессор имеет буфер записи (store buffer). Запись
x = 1уходит в буфер и не сразу становится видна другим ядрам, а чтениеyидёт напрямую из кеша. С точки зрения другого ядра чтение произошло раньше записи.
Именно store buffer объясняет эксперимент из начала: оба потока успевают прочитать чужую переменную до того, как их собственная запись покинула буфер. На x86 разрешено переупорядочивание store-load (запись потом чтение), и этого достаточно, чтобы оба прочитали ноль. ARM и POWER позволяют себе гораздо больше перестановок.
Модель памяти — это контракт между вами, компилятором и процессором о том, какие перестановки разрешены. Атомик с нужным memory_order — это способ запретить конкретные перестановки, заплатив за это производительностью.
Порядки памяти: за что вы платите
У атомарных операций есть параметр std::memory_order. Он определяет, какие гарантии видимости и упорядочивания вы получаете. Основные из них:
| Порядок | Что гарантирует | Типичная стоимость |
|---|---|---|
relaxed | Только атомарность самой операции. Никаких гарантий порядка относительно других операций. | Дешевле всего, часто цена обычного доступа |
acquire (для чтения) | Операции после чтения не переедут выше него. Видит записи, освобождённые парным release. | Барьер на чтении |
release (для записи) | Операции до записи не переедут ниже неё. Публикует их для парного acquire. | Барьер на записи |
acq_rel | И acquire, и release для операций read-modify-write. | Двойной барьер |
seq_cst | Всё вышеперечисленное плюс единый глобальный порядок всех seq_cst-операций. | Дороже всего, часто требует полного барьера |
По умолчанию все операции над std::atomic используют seq_cst — самый строгий и самый дорогой порядок. Это безопасный выбор: рассуждать о программе с seq_cst можно как о чередовании операций потоков в едином порядке. Именно поэтому не стоит начинать оптимизацию с расстановки relaxed по всему коду.
Release-acquire на практике
Самая частая полезная пара. Один поток готовит данные и «публикует» флаг через release, другой видит флаг через acquire и получает гарантию, что данные готовы.
#include <atomic>
#include <thread>
#include <cassert>
// C++11 и новее
int payload = 0; // обычная переменная
std::atomic<bool> ready{false}; // флаг готовности
void producer() {
payload = 42; // запись до release
ready.store(true, std::memory_order_release);
}
void consumer() {
// ждём публикации
while (!ready.load(std::memory_order_acquire)) {
// spin; в реальном коде лучше yield или условная переменная
}
// здесь payload гарантированно виден как 42
assert(payload == 42);
}Release на записи ready гарантирует, что запись payload = 42 не переедет ниже. Acquire на чтении ready гарантирует, что чтение payload не переедет выше и увидит опубликованное значение. Без этой пары assert мог бы сработать: consumer увидел бы ready == true, но старое значение payload.
Где relaxed действительно уместен
Классический безопасный случай — счётчик, где важен только итог, а не порядок. Например, счётчик ссылок при увеличении (но не при уменьшении до нуля — там нужен acq_rel, потому что удаление объекта должно видеть все предыдущие операции).
#include <atomic>
std::atomic<int> hits{0};
void on_request() {
// порядок относительно других операций не важен,
// важна только атомарность инкремента
hits.fetch_add(1, std::memory_order_relaxed);
}Где это ломается первым
Ошибки в модели памяти не воспроизводятся на ноутбуке разработчика и вылезают в проде под нагрузкой на многоядерном сервере. Типичные грабли:
- «На x86 же работает». x86 — сильная модель, она прощает много неверных
relaxed. Тот же код на ARM (мобильные, серверные Graviton, Apple Silicon) разваливается, потому что ARM переупорядочивает агрессивнее. Тестируйте на целевой архитектуре. - relaxed вместо release-acquire для публикации данных. Флаг виден, а данные за ним — нет. Самая частая причина «иногда читаем мусор».
- Ожидание, что seq_cst сериализует вообще всё. seq_cst даёт единый порядок только для seq_cst-операций. Обычные и relaxed-операции в этот порядок не входят.
- Разрыв пары. release без парного acquire (или наоборот) не даёт синхронизации. Release публикует, но если читатель берёт значение через relaxed — гарантии нет.
Проверять такой код глазами почти бесполезно. Единственный надёжный инструмент — ThreadSanitizer (флаг -fsanitize=thread в GCC и Clang). Он ловит data race в рантайме, отслеживая happens-before. Не находит всё, но находит именно то, что человек пропускает.
Как это мерить и с чем сравнивать
Стоимость порядка памяти зависит от архитектуры, и абстрактные цифры вводят в заблуждение. На x86 store с seq_cst компилируется в инструкцию с полным барьером (обычно xchg или mov + mfence), а release-store — в обычный mov: то есть release там почти бесплатен, а seq_cst стоит заметно дороже. На ARM разница между relaxed, acquire/release и seq_cst выражена в разных барьерных инструкциях и ощутима на всех трёх уровнях.
Мерить нужно так: возьмите горячий участок, где атомик реально в цикле, зафиксируйте архитектуру и число потоков, снимите время на seq_cst (базовая линия) и на ослабленном порядке. Смотрите на godbolt.org, во что превращается ваш код на целевой архитектуре, — часто это отвечает на вопрос быстрее бенчмарка. Не оптимизируйте порядок памяти, пока профилировщик не показал, что атомик — это узкое место: чаще узким местом оказывается сама блокировка или ложное разделение кеш-линий (false sharing), а не выбор memory_order.
Что делать дальше
Практический план, если вы разбираетесь с многопоточностью в существующем коде:
- Прогоните сборку под ThreadSanitizer на реалистичной нагрузке — сначала найдите гонки, потом рассуждайте о порядках.
- Оставьте
seq_cstпо умолчанию, пока профилировщик не докажет, что конкретный атомик — узкое место. - Для публикации данных используйте пару release-acquire, а не relaxed; проверяйте, что у каждого release есть парный acquire.
- Изучите раздел про std::memory_order на cppreference — там разобраны тонкие случаи вроде
consumeи release-sequence, которые не поместились сюда. - Если строите lock-free структуры — читайте первоисточник по железу: false sharing, выравнивание по кеш-линии (
alignas) и упорядочивание там важнее любого memory_order.
Инвалидация кешей на уровне процессора и модель памяти конкретной архитектуры (ARM, POWER, RISC-V) — отдельные большие темы; здесь мы разбирали абстрактную модель C++, которая поверх них строит переносимые гарантии.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: std::memory_order — cppreference, std::atomic — cppreference
Частые вопросы
С какой версии C++ вообще есть модель памяти?
А если у меня всё работает на x86 без всяких memory_order — зачем это учить?
Можно ли просто везде поставить relaxed для скорости?
Что делать, если ThreadSanitizer ничего не находит, а программа всё равно падает под нагрузкой?
В чём разница между volatile и atomic — разве volatile не решает проблему?
Нужен ли барьер, если я использую std::mutex?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.