C++11: Модель памяти, которая изменила всё

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Как один стандарт спас C++ от хаоса многопоточности

Представьте: вы пишете многопоточную программу на C++, и она отлично работает на вашем ноутбуке. Вы заливаете код на сервер — и он падает. Или, что ещё хуже, выдаёт неправильные результаты, но только раз в месяц. Знакомо? До 2011 года это было нормой. C++ не имел единой модели памяти — набора правил, описывающих, как разные потоки видят изменения данных. Каждый компилятор и процессор делали, что хотели. Результат — хаос, гонки данных и проклятия в адрес многопоточности.

C++11 всё изменил. Он ввёл стандартизированную модель памяти. Звучит сухо? А зря. Эта модель — настоящий фундамент для безопасной и эффективной многопоточности. В этой статье мы заглянем под капот: как работает модель памяти, что такое атомарные операции и барьеры памяти, и как это влияет на ваш код. Если вы пишете на C++ и хотите, чтобы ваши программы были предсказуемыми, — читайте дальше.

Проблема: мир без модели памяти

До C++11 многопоточность в C++ была похожа на Дикий Запад. У вас были потоки (через pthreads, Windows API или Boost), но стандарт языка ничего не говорил о том, как они взаимодействуют через память. Компилятор мог переупорядочивать инструкции для оптимизации, процессор — менять порядок записей в кэш. В однопоточном коде это безопасно: результат не меняется. Но в многопоточном — катастрофа.

Рассмотрим простейший пример:

// Флаг готовности и данные
int data = 0;
bool ready = false;

// Поток 1 (производитель)
void producer() {
    data = 42;          // (1)
    ready = true;       // (2)
}

// Поток 2 (потребитель)
void consumer() {
    while (!ready);     // ждём флаг
    std::cout << data; // ожидаем 42
}

Интуитивно кажется: если ready == true, то data уже равен 42. Но без модели памяти компилятор может поменять (1) и (2) местами! Или процессор может сделать запись ready видимой для другого ядра раньше, чем запись data. В результате потребитель увидит ready == true, но прочитает data == 0. Баг, который невозможно отловить дебаггером.

Программисты боролись с этим вручную: вставляли барьеры памяти (asm volatile), использовали волатильные переменные (которые не решают проблему), молились. C++11 наконец дал чёткие правила.

Что такое модель памяти C++11?

Модель памяти — это формальные правила, определяющие, при каких условиях один поток гарантированно увидит изменения, сделанные другим потоком. Она отвечает на вопросы:

  • Когда запись в переменную становится видимой для другого потока?
  • В каком порядке операции из разных потоков могут наблюдаться?
  • Что такое гонка данных (data race) и как её избежать?

Ключевое нововведение C++11 — атомарные операции (std::atomic<T>) и шесть моделей упорядочения памяти (memory_order). Они дают программисту контроль над тем, как компилятор и процессор переупорядочивают операции.

Теперь разберём, как это работает под капотом.

Атомарность и порядок: два кита модели

Атомарные операции

Атомарная операция — это операция, которая выполняется целиком или не выполняется вовсе. Для других потоков она неделима. Например, чтение или запись int на x86 обычно атомарны (если выровнено), но ++count — нет (это read-modify-write). C++11 предоставляет std::atomic<T>, который гарантирует атомарность для базовых типов.

#include <atomic>

std::atomic<int> counter(0);

// Атомарное приращение
void increment() {
    counter.fetch_add(1); // эквивалентно counter++, но атомарно
}

Но атомарность — это только половина дела. Вторая половина — упорядочение памяти.

Memory order: дирижёр оркестра

Каждая атомарная операция может быть выполнена с одним из шести memory_order. Они определяют, какие гарантии упорядочения даёт операция. Самые важные:

  • memory_order_relaxed — никаких гарантий порядка. Только атомарность. Быстро, но опасно.
  • memory_order_acquire — все последующие чтения и записи (неатомарные) не могут быть переставлены до этой операции.
  • memory_order_release — все предыдущие чтения и записи не могут быть переставлены после этой операции.
  • memory_order_seq_cst — полная последовательная согласованность. Поведение такое, будто все потоки выполняются в едином глобальном порядке. По умолчанию.

Теперь перепишем наш пример с флагом правильно:

std::atomic<bool> ready{false};
int data = 0;

void producer() {
    data = 42;                          // (1)
    ready.store(true, std::memory_order_release); // (2) Release
}

void consumer() {
    while (!ready.load(std::memory_order_acquire)); // (3) Acquire
    std::cout << data; // теперь гарантированно 42
}

Что здесь происходит? release в строке (2) гарантирует, что запись data = 42 (и все предыдущие записи) будут видны до того, как ready станет true. acquire в строке (3) гарантирует, что чтение data (и все последующие чтения) произойдут после того, как ready стал true. Получается синхронизация happens-before между потоками. Гонки данных нет.

Как это работает под капотом: барьеры и кэши

Чтобы понять, почему это вообще нужно, заглянем в архитектуру современного процессора. У каждого ядра есть свой кэш L1/L2. Когда поток на ядре 1 пишет data = 42, значение попадает в его кэш. Ядро 2 может прочитать ready == true из своего кэша, но data всё ещё равно 0 — потому что кэши не синхронизированы мгновенно.

Процессоры используют протоколы когерентности кэша (например, MESI), но они гарантируют только согласованность для одной ячейки памяти. Для упорядочения разных ячеек нужны барьеры памяти (memory barriers/fences).

Блоги

Рекомендаций по книгам