Как release- и acquire-барьеры могут обеспечить видимость обычных данных между потоками через одну атомарную переменную?
Release-барьер в потоке-производителе и acquire-барьер в потоке-потребителе могут установить отношение happens-before, если между ними есть атомарная переменная, а загрузка потребителя действительно прочитала значение, записанное производителем или входящее в его release-последовательность. Поэтому обычные данные, записанные до release-барьера, можно безопасно читать после acquire-барьера.
Сами барьеры не делают обычные данные атомарными. Они упорядочивают операции и предотвращают ситуацию, при которой потребитель видит сигнал, но не видит предшествующие ему записи данных.
Модель памяти и атомики появились в стандарте C++11, чтобы формально описать взаимодействие потоков независимо от конкретного процессора и компилятора. Барьеры предоставили более низкоуровневый способ выразить порядок операций, когда требуется отделить упорядочивание памяти от выбора порядка конкретной атомарной операции.
На практике чаще используют store с memory_order_release и load с memory_order_acquire: такой вариант проще для чтения и обычно предпочтительнее. Отдельные барьеры полезны в низкоуровневых алгоритмах, где нужно точно контролировать, какие операции выполняются до и после атомарного сигнала.
Пусть один поток заполняет обычный объект, а затем публикует готовность через атомарный флаг. Другой поток проверяет флаг и после этого читает объект. Если между этими действиями нет корректного отношения happens-before, чтение объекта может образовать гонку данных, даже если флаг атомарен.
Ошибочное решение — считать, что любой атомарный флаг автоматически публикует все предыдущие записи. Атомарность гарантирует целостность доступа к самому флагу, но порядок видимости обычных данных зависит от выбранной модели памяти и связи между операциями.
В типичном варианте производитель выполняет записи в данные, затем release-барьер и relaxed-запись в атомарный флаг. Потребитель выполняет relaxed-загрузку флага, убеждается, что прочитал опубликованное значение, и затем acquire-барьер.
Если загрузка потребителя прочитала значение, записанное производителем, стандартная модель памяти связывает release-барьер с acquire-барьером. Записи данных, sequenced-before release-барьера, становятся видимыми операциям, выполняющимся после acquire-барьера в другом потоке.
Минимальный пример:
Здесь ready служит атомарным каналом публикации. Важно, что потребитель должен прочитать именно значение, связанное с публикацией производителя; простого факта, что запись произошла раньше по времени, недостаточно.
Эквивалентный и обычно более понятный вариант — release-запись флага и acquire-чтение флага. Барьеры позволяют выразить тот же порядок с relaxed-доступами к флагу, но повышают риск ошибки при изменении алгоритма и сложнее проверяются при ревью.
Барьеры не устраняют необходимость атомарности там, где несколько потоков одновременно изменяют одну переменную. Они лишь могут обеспечить безопасную публикацию уже подготовленного состояния: после установления happens-before потребитель читает обычные данные, которые больше не изменяются конкурентно.
В lock-free очереди производитель записывает элемент в заранее выделенный слот, а затем публикует индекс готового слота. Рассматривались три варианта: защищать слот мьютексом, использовать release/acquire-операции над индексом или оставить операции relaxed и добавить release- и acquire-барьеры.
Мьютекс был бы самым простым для проверки, но снизил бы масштабируемость и мог бы блокировать производителя. Release/acquire-операции над индекcом дали бы ясный и устойчивый код с небольшой стоимостью, поэтому этот вариант обычно выбирают первым.
Барьеры выбрали бы только при доказанной необходимости тонкой оптимизации и наличии строгих тестов модели памяти. Результат корректен лишь при сохранении связи через тот же атомарный индекс; если потребитель читает другой атомарный объект или не проверяет, какое значение он получил, публикация может не состояться.
Достаточно ли наличия release- и acquire-барьеров, если потребитель не прочитал значение, записанное производителем?
Нет. Барьеры устанавливают межпоточную связь не сами по себе. Для fence-fence-синхронизации нужна атомарная переменная, через которую запись производителя и чтение потребителя связаны отношением read-from либо соответствующей release-последовательностью. Если потребитель прочитал старое значение, отношение happens-before не возникает.
Можно ли заменить атомарный флаг обычной переменной, оставив барьеры?
Нет. Обычная переменная, которую один поток записывает, а другой читает без установленного отношения happens-before, создаёт гонку данных. Барьеры не делают саму переменную атомарной; атомарный объект нужен как безопасный канал, через который потоки согласуют публикацию.
Чем практический вариант с release/acquire на флаге отличается от варианта с relaxed-операциями и двумя барьерами?
При release/acquire-схеме смысл связи виден непосредственно в операциях над флагом: release-публикует предшествующие записи, acquire принимает их. В fence-схеме корректность распределена между порядком обычных операций, барьерами и фактом чтения нужного значения атомика, поэтому она более хрупкая и сложнее для сопровождения. По этой причине барьеры применяют преимущественно в специализированных низкоуровневых алгоритмах, а не как общий стиль синхронизации.