JavaRush /Курсы /C++ SELF /Acquire/Release: публикация данных между потоками

Acquire/Release: публикация данных между потоками

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

1. Зачем нужен Acquire/Release и в чём тут проблема

Если вы когда‑нибудь писали многопоточный код, почти наверняка у вас возникала мысль: «Я подготовлю данные в одном потоке, подниму флажок ready = true, а второй поток увидит флажок и спокойно прочитает данные». Звучит логично — примерно как «я положил пиццу на стол и включил лампу “ГОТОВО”, значит её можно есть». Проблема в том, что в многопоточности логика часто ломается не потому, что вы плохой человек, а потому что компилятор и процессор очень любят переупорядочивать и кэшировать.

Нам нужен механизм, который делает из фразы «поднял флаг готовности» действительно полезный сигнал: если потребитель увидел флаг, он обязан увидеть и данные, подготовленные до этого флага.

Именно это и есть классический сценарий для пары порядков памяти:

  • std::memory_order_release — на записи (публикации),
  • std::memory_order_acquire — на чтении (подхвате публикации).

Важно: это не «ускорение», не «тонкая оптимизация», а протокол синхронизации. Он проще, чем мьютекс, но и применим в более узких случаях.

Опубликовать данные — не то же самое, что записать данные

Когда мы говорим «я записал поле message», мы обычно представляем себе прямую запись в память. Но реальность сложнее: компилятор может переставить инструкции, процессор может выполнить их в другом порядке, а данные могут какое-то время жить в кэше одного ядра и «не торопиться» становиться видимыми другим ядрам. Это не потому, что железо вредничает, а потому что так оно достигает производительности.

И вот тут появляется тонкое различие:

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

Поэтому подход «я сделаю ready.store(true) и всё будет хорошо» без уточнения какого store — это как договориться о встрече «в пятницу», но не указать год. Вроде звучит знакомо, но в продакшене внезапно появляются сюрпризы.

Формально в стандарте сами варианты memory_order закреплены как перечисление (enum class), и это не случайная «фишка библиотеки», а часть контракта модели памяти.

2. Минимальный протокол публикации: producer и consumer

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

Идея такая:

  1. Поток‑producer записывает обычные (не атомарные) данные.
  2. Поток‑producer делает ready.store(true, std::memory_order_release).
  3. Поток‑consumer ждёт ready.load(std::memory_order_acquire) == true.
  4. Поток‑consumer читает обычные данные.

В виде схемы (упрощённо):

flowchart LR
    P[Producer] -->|записывает data| D["data (обычная память)"]
    P -->|"store(true, release)"| F["ready (atomic<bool>)"]
    C[Consumer] -->|"load(acquire)"| F
    C -->|читает data| D

Пример 1: «почтовый ящик» на одно сообщение

Начнём с намеренно простого примера: один поток один раз пишет сообщение, второй поток один раз его читает.

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

int main() {
    std::string message;              // обычные данные
    std::atomic<bool> ready{false};   // флаг публикации

    std::jthread producer([&] {
        message = "Hello from producer!";
        ready.store(true, std::memory_order_release);
    });

    std::jthread consumer([&] {
        while (!ready.load(std::memory_order_acquire)) {
            // Спин (busy-wait): работает, но тратит CPU.
            // Здесь нам важна логика acquire/release, а не эффективность ожидания.
        }
        std::cout << message << '\n'; // Hello from producer!
    });
}

Что здесь происходит по смыслу (без формализма, но честно):

  • release на записи флага говорит: все записи в этом потоке до release-store должны стать видимыми тому потоку, который сделает acquire-load и увидит результат.
  • acquire на чтении флага говорит: если я увидел true, я «подхватываю» публикацию и могу читать опубликованные данные.

Обратите внимание: мы читаем message уже после того, как ready стал true и был прочитан с acquire. Это критично.

3. Инварианты: когда acquire/release работает, а когда это самообман

Acquire/Release — отличный инструмент, но он не «универсальный антивирус от гонок». Он решает конкретную задачу: передать видимость ранее записанных данных через атомарный «маркер». Чтобы это было корректно, вам нужно держать в голове несколько инвариантов (то есть правил, которые вы обязуетесь соблюдать дизайном кода).

Самый важный инвариант звучит почти по‑детски, но именно его чаще всего нарушают: после публикации данные нельзя продолжать менять параллельно с чтением. Если producer продолжит писать в message, пока consumer читает message, это будет data race на message. И никакой ready уже не спасёт: он спасает видимость «до», но не превращает обычные данные в атомарные.

Хорошая ментальная модель такая: публикация — это как «передать коробку». Пока коробка у вас в руках, вы можете класть туда вещи. В момент ready.store(true, release) вы коробку закрыли, заклеили скотчем и поставили на стол «забирайте». Если вы потом снова её открываете и перекладываете вещи, а другой человек в этот момент читает список вложений — начинается хаос.

Небольшая таблица, чтобы не путаться:

Сценарий Можно ли без мьютекса через acquire/release? Почему
Один поток один раз заполнил данные и больше не трогает, второй поток читает после ready Да Нет конкурентных записей в данные после публикации
Один поток периодически обновляет данные, второй поток периодически читает Обычно нет Нужен более сложный протокол или мьютекс, иначе легко получить data race
Несколько потоков пишут в одну структуру данных Почти всегда нет Сложные инварианты; в учебной практике лучше мьютекс

В этой лекции мы сознательно держим сценарий простым: «данные подготовили → опубликовали → читаем».

4. Как выбирать порядок памяти: relaxed, seq_cst и acq_rel

Почему relaxed здесь не подходит

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

Если сделать так:

ready.store(true, std::memory_order_relaxed);

и читать так:

while (!ready.load(std::memory_order_relaxed)) { }
std::cout << message << '\n';

то флаг вы прочитаете корректно, но контракт «увидел флаг — увидел данные» не появляется. То есть вы не имеете права рассуждать: «раз ready == true, то message точно уже записано и видно». В каких‑то запусках оно будет видно, в каких‑то — нет, и самое неприятное: оно может «ломаться» только на другом CPU, в релиз‑сборке, ночью, в пятницу.

А где тогда seq_cst

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

Acquire/Release — это более минимальный протокол под конкретную задачу публикации. Он может быть достаточно понятным, если вы держите инварианты и явно строите схему «публикация → подхват».

acq_rel: когда операция и читает, и публикует

Иногда у нас нет отдельного load и отдельного store. Например, мы хотим сделать «инициализацию ровно один раз»: кто первый — тот делает работу, остальные пропускают.

Для этого часто берут RMW‑операцию exchange(), потому что она атомарно:

  • читает старое значение,
  • записывает новое.

В таком случае удобно использовать порядок std::memory_order_acq_rel: он одновременно даёт часть «acquire» для чтения старого состояния и часть «release» для публикации нового.

Сделаем маленький пример: только один поток должен выполнить «дорогую настройку».

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

int main() {
    std::atomic<bool> initialized{false};

    auto init_once = [&] {
        // exchange возвращает старое значение
        if (!initialized.exchange(true, std::memory_order_acq_rel)) {
            std::cout << "Init is done by this thread\n";
        }
    };

    std::jthread t1(init_once);
    std::jthread t2(init_once);
}

Почему это похоже на публикацию? Потому что «инициализация» обычно означает: «я записал какие‑то данные/настройки, и теперь другие потоки могут ими пользоваться». Если вы действительно публикуете рядом какие-то обычные данные, то release‑часть (в acq_rel) помогает «закрепить» порядок: сначала данные, потом флаг/состояние.

Тут важно не превратиться в человека, который ставит acq_rel «на всякий случай». Он нужен тогда, когда вы реально строите протокол «состояние управляет видимостью/доступом к данным».

5. Единый пример: конфиг опубликован — можно читать

Чтобы не прыгать между несвязанными кусками кода, давайте соберём мини‑сюжет: у нас есть «конфиг», который producer готовит (например, загружает параметры), а consumer начинает работу только когда конфиг опубликован.

Мы сделаем структуру Config, обычный объект, и атомарный флаг config_ready.

Пример 2: публикация структуры Config

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

struct Config {
    int port = 0;
    std::string mode;
};

int main() {
    Config cfg;                         // обычные данные
    std::atomic<bool> config_ready{false};

    std::jthread loader([&] {
        cfg.port = 8080;
        cfg.mode = "dev";
        config_ready.store(true, std::memory_order_release);
    });

    std::jthread server([&] {
        while (!config_ready.load(std::memory_order_acquire)) {
            // ждём публикацию конфига
        }
        std::cout << cfg.port << " " << cfg.mode << '\n'; // 8080 dev
    });
}

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

Ещё один важный нюанс: после публикации (config_ready == true) мы предполагаем, что cfg больше не меняется. Если вы хотите «горячую перезагрузку» конфига, то одного булевого флага недостаточно, и в рамках учебного курса правильнее будет выбрать std::mutex и явную критическую секцию, чем пытаться изобрести безблокировочный велосипед с квадратными колёсами.

6. Как проверять протокол рассуждением

В многопоточном коде очень помогает привычка рассуждать не «что я хотел», а «что я гарантировал контрактом». Для Acquire/Release это можно делать почти механически.

Сначала вы находите точку публикации: store(..., release) (или RMW с release‑частью). Потом вы находите точку подхвата публикации: load(..., acquire) (или RMW с acquire‑частью). Затем вы проверяете порядок действий в producer: действительно ли все записи данных, которые consumer будет читать, идут до release‑операции. После этого вы проверяете порядок действий в consumer: действительно ли чтение данных идёт после acquire‑операции, которая увидела опубликованное значение.

Если эти два «до/после» выполнены и вы при этом не допускаете конкурентных записей в данные после публикации, то вы построили понятный протокол: «producer записал → опубликовал; consumer увидел публикацию → читает».

И да, это тот случай, когда аккуратный «ритуал проверки» полезнее, чем героическое «ну я уверен, оно и так нормально».

7. Типичные ошибки при использовании Acquire/Release

Ошибка №1: считать, что атомарный флаг делает потокобезопасными все связанные данные.
Очень популярная ловушка: «ready же atomic, значит data race исчезла». На самом деле исчезает data race только на самом ready. Обычные данные (std::string, struct Config, std::vector) остаются обычными. Протокол acquire/release работает только если вы гарантируете дизайном, что запись данных завершена до публикации и после публикации никто их параллельно не меняет.

Ошибка №2: перепутать порядок действий: сначала ready=true, потом запись данных.
Такой код иногда «вроде работает», но он логически неверен. Вы фактически говорите consumer: «можно читать», хотя ещё не закончили готовить. Даже если вы используете release на ready.store, release не умеет путешествовать во времени и делать будущие записи «уже видимыми». Сначала данные, потом флаг — иначе вы публикуете не данные, а надежду.

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

Ошибка №4: после публикации продолжать менять данные «чуть-чуть, аккуратно».
Это тот случай, когда «чуть-чуть» не считается. Если consumer читает cfg.mode, а producer в это время записывает cfg.mode заново, это конкурентный доступ к одной и той же памяти с записью — классический data race и UB. Для обновляемых данных либо нужен мьютекс, либо намного более сложный протокол, который на учебном уровне лучше не трогать без очень уверенного понимания инвариантов.

Ошибка №5: усложнять протокол, не зафиксировав инварианты.
Acquire/Release прекрасен в простом сценарии «подготовил → опубликовал → прочитал». Но как только вы начинаете добавлять «а ещё второй producer», «а ещё очередь сообщений», «а ещё пусть consumer читает частично», протокол быстро перестаёт быть прозрачным. В этот момент правильное инженерное решение для новичка — откатиться к std::mutex и сделать критическую секцию, а не пытаться выиграть гонку за микросекунды ценой Undefined Behavior.

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