std::atomic и барьеры памятиПо умолчанию все операции над std::atomic в C++ выполняются с упорядочением memory_order_seq_cst. Это самый строгий и понятный режим: все потоки видят атомарные операции в едином глобальном порядке, как будто они выполняются строго одна за другой. Такой подход гарантирует наименьшее количество сюрпризов, но может ограничивать производительность из-за дополнительных барьеров памяти на аппаратном уровне
Всего режимов 4:
memory_order_relaxed - минимальный уровень гарантий. Операция остаётся атомарной, но не создаёт никаких синхронизационных барьеров. Компилятор и процессор могут свободно переупорядочивать окружающие инструкции, не связанные с этой атомарной переменной. Такой режим подходит для счётчиков, где важен только конечный результат, а не момент наблюдения из другого потока, и где не передаётся информация о других участках памяти между потоками
Пример счётчика посещений, где не требуется мгновенная видимость:
std::atomic<int> visits{0};
void increment_visit() {
visits.fetch_add(1, std::memory_order_relaxed);
}
memory_order_acquire используется при чтении атомарной переменной. Он гарантирует, что все последующие операции с памятью (не только атомарной) не будут переставлены компилятором или процессором до этой точки чтения. Это создаёт барьер “снизу”: после acquire нельзя поднять более ранние чтения/записи выше него
Такой режим применяется, когда поток должен увидеть все изменения, сделанные другим потоком до соответствующей release-записи, например:
std::atomic<bool> ready{false};
int data = 0;
// Поток 1
data = 42;
ready.store(true, std::memory_order_release);
// Поток 2
while (!ready.load(std::memory_order_acquire));
// Здесь data гарантированно равно 42
memory_order_release используется при записи. Он гарантирует, что все предыдущие операции с памятью не будут переставлены после этой точки записи. Это барьер “сверху”: release не даёт опустить более поздние инструкции ниже себя
Такой режим вместе с acquire образует пару синхронизации: если поток, выполнивший release, записывает значение, а другой поток то же значение читает через acquire, то все записи перед release становятся видимыми читающему потоку
std::atomic<bool> flag{false};
std::string message;
// Поток-публикатор
message = "Hello";
flag.store(true, std::memory_order_release);
// Поток-потребитель
if (flag.load(std::memory_order_acquire)) {
// message гарантированно содержит "Hello"
}
memory_order_seq_cst сочетает свойства acquire и release, но добавляет требование единого глобального порядка всех seq_cst-операций. Это значит, что все потоки согласны на одну и ту же последовательность seq_cst-событий, даже если они не связаны явными отношениями синхронизации. В большинстве случаев это поведение соответствует интуитивным ожиданиям, но может быть дороже из-за полных барьеров памяти
Пример, где seq_cst даёт предсказуемый результат даже при неочевидных перестановках:
std::atomic<bool> x{false}, y{false};
std::atomic<int> z{0};
// Поток 1
x.store(true, std::memory_order_seq_cst);
// Поток 2
y.store(true, std::memory_order_seq_cst);
// Поток 3
while (!x.load(std::memory_order_seq_cst));
if (y.load(std::memory_order_seq_cst)) ++z;
// Поток 4
while (!y.load(std::memory_order_seq_cst));
if (x.load(std::memory_order_seq_cst)) ++z;
// z никогда не будет 0, порядок записи x и y однозначен для всех
Барьеры памяти в C++ представлены функцией std::atomic_thread_fence. В отличие от упорядочения, привязанного к конкретной атомарной переменной, барьер действует как самостоятельная инструкция синхронизации для текущего потока. Есть два режима работы:
std::atomic_thread_fence(std::memory_order_acquire) - все чтения после барьера не поднимутся выше негоstd::atomic_thread_fence(std::memory_order_release) - все записи до барьера не опустятся ниже негоБарьеры полезны, когда синхронизация должна охватить несколько переменных или когда переменная не объявлена атомарной, но доступ к ней защищён отдельным флагом
Пример с флагом, где данные не атомарны, но защищены барьером:
bool ready = false;
int data = 0;
// Поток 1
data = 42;
std::atomic_thread_fence(std::memory_order_release);
ready = true;
// Поток 2
if (ready) {
std::atomic_thread_fence(std::memory_order_acquire);
int v = data; // гарантированно 42
}
SPSC-очередь (Single Producer Single Consumer) - это свободная от блокировок структура данных для передачи сообщений между двумя потоками, где один только пишет, а второй только читает. Она строится на кольцевом буфере фиксированного размера и двух атомарных индексах: head (потребитель) и tail (производитель). Каждый поток модифицирует только свой индекс, поэтому для них достаточно memory_order_relaxed, но передача полезных данных требует синхронизации acquire-release
Производитель:
tailtail вперёд с memory_order_release, чтобы потребитель увидел готовность данныхПотребитель:
tail с memory_order_acquire, чтобы увидеть все произведённые записи до текущего моментаheadhead с memory_order_relaxed (очистка места не требует синхронизации с производителем, кроме случая, когда буфер пуст)Пример минимальной реализации на массиве:
template<typename T, size_t N>
class SPSCQueue {
std::array<T, N> buffer;
std::atomic<size_t> head{0}, tail{0};
public:
bool push(const T& item) {
size_t t = tail.load(std::memory_order_relaxed);
size_t next = (t + 1) % N;
if (next == head.load(std::memory_order_acquire)) return false; // буфер полон
buffer[t] = item;
tail.store(next, std::memory_order_release);
return true;
}
bool pop(T& item) {
size_t h = head.load(std::memory_order_relaxed);
if (h == tail.load(std::memory_order_acquire)) return false; // буфер пуст
item = buffer[h];
head.store((h + 1) % N, std::memory_order_relaxed);
return true;
}
};
В этом коде проверка на пустоту/полноту и перемещение индексов используют минимально необходимые барьеры: acquire для чтения чужого индекса, release для публикации обновлённого индекса, relaxed для собственного индекса. Такой дизайн обеспечивает корректную синхронизацию данных без лишних накладных расходов