Программирование C++МногопоточностьРазработчик высокопроизводительных многопоточных систем

Каким образом операция RMW с порядком relaxed может продолжить release последовательность атомика?

Каким образом операция RMW с порядком relaxed может продолжить release-последовательность атомика?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

В стандарте C++20 и новее операция атомарного чтения-изменения-записи (RMW) с порядком relaxed может продолжить release-последовательность, начатую предыдущей release-операцией над тем же атомиком. Если acquire-загрузка прочитала значение, записанное такой RMW, она синхронизируется с исходной release-операцией и получает видимость всех записей, выполненных до неё.

Это работает только для RMW: обычная relaxed-запись не продолжает release-последовательность в современном определении стандарта.

Исторический контекст

Модель памяти C++11 формализовала способы публикации данных между потоками без обязательной блокировки каждого доступа. Пара release/acquire решает задачу публикации: один поток завершает подготовку обычных данных, а другой после acquire-загрузки безопасно их читает.

Release-последовательность нужна для цепочек атомарных переходов состояния. Промежуточному потоку не требуется повторно публиковать уже подготовленные данные операцией release: достаточно выполнить RMW над тем же атомиком, сохранив связь с исходной публикацией.

Постановка проблемы

Пусть один поток заполняет обычные данные и release-записью переводит атомарное состояние из 0 в 1. Затем другой поток увеличивает это состояние до 2 через relaxed-RMW, а третий поток acquire-загрузкой видит 2.

Неверно считать, что relaxed-операция сама по себе публикует обычные данные. Результат обусловлен тем, что RMW находится в release-последовательности исходной release-записи. Если связь будет нарушена, чтение обычных данных может стать гонкой данных или наблюдать неактуальное состояние.

Подробное решение

В порядке модификаций одного атомика операции образуют единую последовательность. Release-операция становится головой release-последовательности, а следующие за ней атомарные RMW над тем же объектом входят в эту последовательность, независимо от потока, который их выполняет.

Acquire-загрузка синхронизируется с release-операцией, если она прочитала значение, записанное самой release-операцией или одной из операций её release-последовательности. Поэтому все действия, предшествовавшие release в исходном потоке, происходят до действий после acquire в потоке-получателе (happens-before).

Минимальный пример:

#include <atomic> #include <thread> #include <cassert> int data = 0; std::atomic<int> state{0}; int main() { std::thread producer([] { data = 42; state.store(1, std::memory_order_release); }); std::thread relay([] { while (state.load(std::memory_order_relaxed) < 1) {} state.fetch_add(1, std::memory_order_relaxed); }); while (state.load(std::memory_order_acquire) < 2) {} assert(data == 42); producer.join(); relay.join(); }

Здесь store(1, release) публикует data, а fetch_add(1, relaxed) создаёт следующее значение в release-последовательности. Загрузка, прочитавшая 2, получает синхронизацию с исходной release-записью.

Важное ограничение: acquire-загрузка должна прочитать значение от release-операции или от RMW в её последовательности. Само наличие более поздней операции в реальном времени недостаточно. Также обычная relaxed-запись между release и acquire не обладает свойством продолжения последовательности и может разорвать требуемую связь.

Компромисс состоит в том, что RMW часто дешевле полноценной блокировки и позволяет строить атомарные счётчики, очереди состояний и промежуточные этапы протокола. Однако такой код сложнее проверять: нужно доказать непрерывность release-последовательности, правильный выбор атомика и отсутствие доступа к данным до acquire-события.

Ситуация из практики

В конвейере обработки один поток записывает пакет в заранее выделенный буфер и публикует состояние готов. Служебный поток должен увеличить счётчик этапов, не изменяя сам пакет, после чего потребитель начинает обработку.

Рассматривались следующие варианты:

  • Мьютекс: проще всего доказать корректность, но он добавляет блокировку и может ограничить пропускную способность.
  • Повторная release-запись каждым этапом: явно выражает публикацию, но требует дисциплины от всех участников и усложняет протокол состояний.
  • RMW с relaxed: минимизирует требования к промежуточному потоку, но корректен только при сохранении release-последовательности.

Был выбран третий вариант: исходный поток выполняет release-публикацию, промежуточный — relaxed-RMW, а потребитель использует acquire-загрузку. При этом команда зафиксировала правило: никакие обычные записи в атомарное состояние не допускаются между публикацией и завершающим RMW.

Что кандидаты часто упускают

  1. Достаточно ли любой relaxed-операции, чтобы продолжить release-последовательность?

Нет. В C++20 и новее продолжение обеспечивают именно атомарные операции RMW: например, fetch_add, exchange или успешный compare_exchange. Обычная store(relaxed) не выполняет чтение-изменение-запись и не продолжает последовательность исходной release-операции.

  1. Синхронизируется ли acquire-загрузка, прочитавшая исходное значение release-записи?

Да. Release-последовательность включает саму операцию, которая её возглавляет. Поэтому acquire-загрузка, прочитавшая значение непосредственно от release-записи, синхронизируется с ней так же, как загрузка значения от последующего RMW.

  1. Публикует ли relaxed-RMW данные, записанные непосредственно перед ним в его собственном потоке?

Не обязательно. Relaxed-RMW не является release-операцией и сам по себе не устанавливает связь с предшествующими обычными записями. Видимость таких данных появляется в описанной схеме только потому, что RMW входит в release-последовательность, начатую более ранней release-операцией; без этой головы нужен release-порядок или другая синхронизация.