5.3.5. Барьеры
Библиотека атомарных операций была бы неполна без набора барьеров. Это операции, которые налагают ограничения на порядок доступа к памяти без модификации данных. Обычно они используются в сочетании с атомарными операциями, помеченными признаком memory_order_relaxed. Барьеры — это глобальные операции, они влияют на упорядочение других атомарных операций в том потоке, где устанавливается барьер. Своим названием барьеры обязаны тому, что устанавливают в коде границу, которую некоторые операции не могут пересечь. В разделе 5.3.3 мы говорили, что компилятор или сам процессор вправе изменять порядок ослабленных операций над различными переменными. Барьеры ограничивают эту свободу и вводят отношения происходит-раньше и синхронизируется-с, которых до этого не было.
В следующем листинге демонстрируется добавление барьера между двумя атомарными операциями в каждом потоке из листинга 5.5.
Листинг 5.12. Ослабленные операции можно упорядочить с помощью барьеров
#include <atomic>
#include <thread>
#include <assert.h>
std::atomic<bool> x, y;
std::atomic<int> z;
void write_x_then_y() {
x.store(true, std::memory_order_relaxed); ← (1)
std::atomic_thread_fence(std::memory_order_release);← (2)
y.store(true, std::memory_order_relaxed); ← (3)
}
void read_y_then_x() {
while (!y.load(std::memory_order_relaxed)); ← (4)
std::atomic_thread_fence(std::memory_order_acquire);← (5)
if (x.load(std::memory_order_relaxed)) ← (6)
++z;
}
int main() {
x = false;
y = false;
z = 0;
std::thread a(write_x_then_y);
std::thread b(read_y_then_x);
a.join();
b.join();
assert(z.load() != 0); ← (7)
}
Барьер освобождения (2) синхронизируется-с барьером захвата (5), потому что операция загрузки y в точке (4) читает значение, сохраненное в точке (3). Это означает, что сохранение x (1) происходит-раньше загрузки x (6), поэтому прочитанное значение должно быть равно true, и утверждение (7) не сработает. Здесь мы наблюдаем разительное отличие от исходного случая без барьеров, когда сохранение и загрузка x не были упорядочены, и утверждение могло сработать. Отметим, что оба барьера обязательны: чтобы получить отношение синхронизируется-с необходимо освобождение в одном потоке и захват в другом.
В данном случае барьер освобождения (2) оказывает такой же эффект, как если бы операция сохранения y (3) была помечена признаком memory_order_release, а не memory_order_relaxed. Аналогично эффект от барьера захвата (5) такой же, как если бы операция загрузки y (4) была помечена признаком memory_order_acquire. Это общее свойство всех барьеров: если операция захвата видит результат сохранения, имевшего место после барьера освобождения, то барьер синхронизируется-с этой операцией захвата. Если же операция загрузки, имевшая место до барьера захвата, видит результат операции освобождения, то операция освобождения синхронизируется-с барьером захвата. Разумеется, можно поставить барьеры по обе стороны, как в примере выше, и в таком случае если загрузка, которая имела место до барьера захвата, видит значение, записанное операцией сохранения, имевшей место после барьера освобождения, то барьер освобождения синхронизируется-с барьером захвата.
Хотя барьерная синхронизация зависит от значений, прочитанных или записанных операциями до и после барьеров, важно отметить, что точкой синхронизации является сам барьер. Если взять функцию write_x_then_y из листинга 5.12 и перенести запись в x после барьера, как показано ниже, то уже не гарантируется, что условие в утверждение будет истинным, несмотря на то что запись в x предшествует записи в y:
void write_x_then_y() {
std::atomic_thread_fence(std::memory_order_release);
x.store(true, std::memory_order_relaxed);
y.store(true, std::memory_order_relaxed);
}
Эти две операции больше не разделены барьером и потому не упорядочены. Барьер обеспечивает упорядочение только тогда, когда находится между сохранением x и сохранением y. Конечно, наличие или отсутствие барьера не влияет на упорядочения, обусловленные отношениями происходит-раньше, которые существуют благодаря другим атомарным операциям.
Данный пример, как и почти все остальные в этой главе, целиком построен на переменных атомарных типов. Однако реальная польза от применения атомарных операций для навязывания упорядочения проистекает из того, что они могут упорядочивать неатомарные операции и тем самым предотвращать неопределенное поведение из-за гонок за данными, как мы видели в листинге 5.2.