JavaRush /Курсы /C++ SELF /std::memory_order: seq_cst по умолчанию

std::memory_order: seq_cst по умолчанию

C++ SELF
72 уровень , 2 лекция
Открыта

1. Зачем вообще существует memory_order: атомарность ≠ порядок

Если бы мир был справедлив, то std::atomic автоматически делал бы всё правильно всегда и везде. Но тогда половина книжек по многопоточности потеряла бы смысл, а авторы этих книжек начали бы писать про огород. В реальном C++ атомики разделяют две идеи: атомарность и упорядочивание, и memory_order в основном управляет второй.

Атомарность — это про «операция неделима»: никто не увидит «половинку» записи, и два потока не «разорвут» значение на куски. Упорядочивание — это про другое: какие операции компилятор/процессор имеет право переставлять местами, и какие изменения один поток обязан увидеть «в связке» с другими изменениями.

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

В виде картинки (очень упрощённо) мысль такая:

flowchart LR
  A[Поток 1: пишет данные] --> B[Поток 1: ставит флаг]
  C[Поток 2: видит флаг] --> D[Поток 2: читает данные]
  note1[[Если выбрать неправильный memory_order, поток 2 может увидеть флаг, но не «связанные» данные.]]
  B -.-> note1

Важно: сегодня мы не строим сложные протоколы «данные + флаг». Наша цель проще и практичнее: понять, почему по умолчанию разумно жить в seq_cst, и почему relaxed годится только там, где вы точно не используете атомик как «сигнал» для других данных.

2. Где memory_order в API и что значит «по умолчанию»

Когда вы впервые видите memory_order, кажется, что это «какой-то лишний параметр для зануд». На практике это такой же важный выбор, как «mutex или без mutex», просто более тонкий. Хорошая новость: стандартная библиотека сделала выбор по умолчанию максимально безопасным — и этим нужно пользоваться, пока вы не стали очень уверены в протоколе.

std::memory_order — это перечисление (в современном C++ оно оформлено как scoped enum, то есть значения пишутся как std::memory_order::seq_cst, std::memory_order::relaxed и так далее).

Почти все атомарные операции имеют перегрузку с порядком памяти. Например (упрощённо по смыслу):

  • x.store(value) и x.store(value, order)
  • x.load() и x.load(order)
  • x.fetch_add(1) и x.fetch_add(1, order)

И вот ключевой момент лекции: если вы не указали order, то используется самый строгий и понятный режим — seq_cst.

Наглядно:

#include <atomic>

int main() {
    std::atomic<int> x{0};

    x.store(10);                       // эквивалентно store(10, seq_cst)
    int v = x.load();                  // эквивалентно load(seq_cst)

    (void)v;
}

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

3. memory_order::seq_cst: самый понятный режим

Если вы когда-нибудь видели код с memory_order::relaxed везде подряд, знайте: где-то плачет один санитайзер. seq_cst (sequentially consistent) — это режим, который даёт максимально простую модель: атомарные операции как будто выстраиваются в один общий порядок, понятный всем потокам.

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

Посмотрим на простой пример: два потока увеличивают один счётчик. С std::atomic<int> это корректно, а с обычным int — data race и UB.

#include <atomic>
#include <iostream>
#include <thread>

int main() {
    std::atomic<int> x{0};

    std::jthread t1([&] { x.fetch_add(1); });  // seq_cst по умолчанию
    std::jthread t2([&] { x.fetch_add(1); });  // seq_cst по умолчанию

    std::cout << x.load() << '\n';             // чаще всего: 2  (и формально корректно)
}

Почему здесь «формально корректно»? Потому что fetch_add — атомарная RMW-операция. И поскольку мы не указали порядок явно, используется seq_cst, то есть самый «сильный» и самый простой для рассуждений режим.

Теперь важная практическая мысль: вы почти никогда не проиграете, если начнёте с seq_cst. Вы проиграете (по времени и нервам), если начнёте с relaxed, а потом окажется, что вы использовали этот атомик как часть протокола (например, как сигнал «данные готовы»).

4. memory_order::relaxed: когда достаточно «просто атомарно»

memory_order::relaxed звучит очень заманчиво, особенно если вы устали и хотите «расслабиться». Но в C++ это не про психологию, а про гарантии: relaxed даёт атомарность операции над конкретной переменной, но почти не даёт гарантий про то, как это связано по порядку с другими чтениями/записями (включая обычные переменные).

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

Самый классический сценарий для relaxedсчётчики статистики. Например, «сколько запросов обработали», «сколько задач выполнили», «сколько раз пользователь нажал кнопку». В таких местах обычно не требуется, чтобы чтение счётчика «гарантировало видимость» каких-то других данных. Нам нужно только, чтобы инкременты не терялись и не были UB.

Мини-пример:

#include <atomic>

int main() {
    std::atomic<int> hits{0};

    hits.fetch_add(1, std::memory_order::relaxed);
    hits.fetch_add(1, std::memory_order::relaxed);

    // Важно: hits корректно увеличивается, но это не “сигнал” для других данных.
}

Заметьте, что здесь мы сознательно говорим: «мне нужен только корректный конкурентный инкремент». Если в вашей голове есть хотя бы маленькое «а ещё я по этому флагу пойму, что можно читать данные» — это уже красный флажок: значит, relaxed вам почти наверняка не подходит.

5. Практический пример: мини‑TaskRunner

Чтобы тема не осталась абстрактной, продолжим нашу учебную линию «приложение растёт по курсу». Раньше у нас появлялись потоки, jthread, ожидания, и постепенно мы учились делать небольшой «исполнитель задач». Сегодня мы не строим безблокировочную очередь и не делаем фокусы с протоколами публикации данных — мы просто добавим статистику, которую можно обновлять конкурентно и дёшево.

Начнём с идеи: у нас есть рабочий поток, который «обрабатывает задачи» (для примера — просто крутит цикл). Нам нужно считать, сколько задач обработано. Этот счётчик — типичная метрика, и для него relaxed часто достаточен.

Сначала объявим атомарный счётчик:

#include <atomic>

struct Stats {
    std::atomic<int> processed{0};  // сколько “задач” выполнено
};

Теперь рабочая функция: делает работу и увеличивает счётчик. Обратите внимание: мы выбираем relaxed именно потому, что счётчик — не сигнал готовности данных, а просто число.

#include <atomic>
#include <thread>

void do_work(Stats& stats) {
    for (int i = 0; i < 1'000; ++i) {
        // ... имитация работы ...
        stats.processed.fetch_add(1, std::memory_order::relaxed);
    }
}

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

#include <chrono>
#include <iostream>
#include <thread>

void print_stats(const Stats& stats) {
    using namespace std::chrono_literals;

    int v = stats.processed.load(std::memory_order::relaxed);
    std::cout << "processed=" << v << '\n';  // например: processed=1000
    std::this_thread::sleep_for(50ms);
}

Наконец, соберём это в маленький main. Тут важно, что мы не строим сложный протокол остановки через атомики. Мы просто запускаем поток, ждём завершения (RAII у std::jthread нам поможет), и в конце печатаем итог.

#include <iostream>
#include <thread>

int main() {
    Stats stats;

    std::jthread worker([&] { do_work(stats); });

    // Пока worker работает, можно несколько раз прочитать метрику.
    print_stats(stats);

    // Когда main закончится, jthread дождётся завершения worker.
    std::cout << "final=" << stats.processed.load() << '\n'; // final=1000
}

Что важно понять из примера. Мы не используем processed как «флаг», мы не говорим «если processed > 0, значит данные готовы». Мы не читаем по этому событию какие-то структуры, которые другой поток меняет без синхронизации. Мы просто считаем число. Поэтому relaxed здесь — осмысленный выбор.

А вот если бы вы захотели сделать так: «когда счётчик стал 1000, значит можно безопасно читать массив результатов, который рабочий поток заполнял», — это уже другой класс задач. В таких задачах одного relaxed обычно недостаточно, и на уровне курса это уже повод выбрать более подходящую синхронизацию (например, mutex/condition_variable) или другой протокол. Здесь мы эту дверь лишь аккуратно обозначаем, но не открываем.

Мини-таблица выбора: seq_cst vs relaxed

В реальном проекте memory_order часто превращается в «религию»: кто-то ставит relaxed ради скорости, кто-то запрещает всё кроме seq_cst ради спокойного сна. На уровне обучения полезнее не религия, а маленькая карта местности, чтобы не заблудиться.

Порядок памяти Что гарантирует (по-простому) Типичный сценарий Главный риск
std::memory_order::seq_cst
«Самая строгая и понятная модель»: атомики ведут себя максимально предсказуемо между потоками; используется по умолчанию, если порядок не указан. Флаги состояния, счётчики, простая синхронизация «лишь бы корректно» Обычно не риск, а потенциальная цена по производительности и/или избыточность
std::memory_order::relaxed
«Только атомарность этой переменной», без обещаний про порядок относительно других данных Метрики, статистика, счётчики «сколько раз случилось» Использовать как сигнал готовности данных и получить редкие, злые, трудноуловимые баги

Если у вас нет чёткой причины выбирать relaxed, то причина почти всегда одна: «я увидел это в интернете». Это, мягко говоря, не лучший источник проектных решений.

6. Типичные ошибки

Ошибка №1: ставить relaxed «потому что так быстрее», не понимая протокола.
Это самая частая история. Код начинает выглядеть «профессионально», но становится профессионально непредсказуемым. Если вы не можете объяснить, почему вам достаточно только атомарности без упорядочивания, то безопаснее оставить порядок по умолчанию (seq_cst) и получить код, который можно читать без шаманского бубна.

Ошибка №2: использовать relaxed для флага «готово», а потом читать обычные данные.
Новички часто думают так: «флаг атомарный, значит всё, что перед ним записали, тоже увидят». Это неверная логика: relaxed не обещает «публикации» других данных. В итоге вы получаете баг, который может проявляться только на части архитектур, только под оптимизациями, или только когда вы уже отправили релиз и поехали отдыхать.

Ошибка №3: вручную «фиксить» проблему sleep_for()-ами.
Когда что-то не синхронизировано, появляется соблазн «дать потоку время» и вставить задержку. Это создаёт иллюзию порядка, но не создаёт гарантий. Задержка не превращает некорректный протокол в корректный — она лишь делает баг более стеснительным.

Ошибка №4: смешивать разные memory_order в одном атомике без строгой дисциплины.
Даже если вы знаете, что делаете, код с разными порядками на load()/store() быстро становится нечитаемым. Для уровня курса хорошая дисциплина такая: либо вы везде живёте в режиме по умолчанию, либо вы используете relaxed только в «островках статистики», где нет протоколов готовности и связанных данных.

Ошибка №5: думать, что «по умолчанию» — это что-то слабое.
В C++ с атомиками всё наоборот: режим по умолчанию — самый строгий. То есть если вы написали x.load() и не указали порядок, это не «ну как-нибудь», а вполне конкретный и сильный контракт. Это сделано специально, чтобы вы могли писать корректный многопоточный код, даже не превращаясь в специалиста по модели памяти в первый же день.

1
Задача
C++ SELF, 72 уровень, 2 лекция
Недоступна
Два протокола
Два протокола
1
Задача
C++ SELF, 72 уровень, 2 лекция
Недоступна
Метрика запросов
Метрика запросов
1
Задача
C++ SELF, 72 уровень, 2 лекция
Недоступна
Сканер логов
Сканер логов
1
Задача
C++ SELF, 72 уровень, 2 лекция
Недоступна
Два режима
Два режима
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ