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
Сейчас соберём «канонический» шаблон публикации данных, который стоит выучить как маленькое заклинание. Не потому что «магия», а потому что он действительно повторяется в коде десятилетиями.
Идея такая:
- Поток‑producer записывает обычные (не атомарные) данные.
- Поток‑producer делает ready.store(true, std::memory_order_release).
- Поток‑consumer ждёт ready.load(std::memory_order_acquire) == true.
- Поток‑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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ