Перейти к содержимому

Атомные операции и заказ памяти

Поймите программирование без блокировки с помощью std::atomic. Мастер-упорядочения памяти (расслабленные, приобретайте, выпускайте, seq cst), реализуйте шпинлок с атомным флагом и изучите шаблон сравнения и замены (CAS).

Что такое атомные операции?

атомная операция неделима — она завершается полностью или не завершается вовсе, без промежуточного состояния, видимого для других нитей. std::atomic обеспечивает атомный доступ к значению типа T ** без необходимости мутекса.

Рассмотрим простой ++counter на обычном int, совместно используемом между потоками. На самом деле это три шага: загрузка, приращение, хранение. Другая нить может переплетаться между этими шагами, вызывая гонку данных (неопределенное поведение). С std::atomic инкремент представляет собой единую атомную операцию — без чередования, без UB.

Но атомы идут глубже, чем просто «неделимые операции». Они также контролируют упорядочение памяти — правила, регулирующие при записи одним потоком, становятся видимыми для других потоков. Именно здесь атомы становятся мощными и тонкими.

std::атомные основы

Наиболее распространенными атомными операциями являются load(), store(), exchange() и fetch_add()/fetch_sub(). Также можно использовать операторы ++, --, += и т.д.

atomic_counter.cpp
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>

std::atomic<int> counter{0};  // atomic integer, initialized to 0

void increment(int n) {
    for (int i = 0; i < n; ++i) {
        counter.fetch_add(1, std::memory_order_relaxed);
        // or simply: ++counter;  (uses seq_cst by default)
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment, 100'000);
    }
    for (auto& t : threads) t.join();

    std::cout << "Counter: " << counter.load() << "\n";
    // Always prints 1000000 — no data race, no mutex needed
    return 0;
}

Заказ памяти объясняется

Современные процессоры и компиляторы упорядочивают операции памяти для обеспечения производительности. На x86 это относительно мягко, но на ARM/RISC-V это агрессивно. Заказы памяти сообщают компилятору и процессору, какие заказы разрешены.

memory_order_seq_cst (по умолчанию) — самый сильный заказ. Все потоки видят все операции seq_cst в одном и том же общем порядке**. Простой и безопасный, но может ограничить оптимизацию.

memory_order_acquire — Используется на нагрузках. Никакая память, считывающая/записывающая в текущем потоке, не может быть переупорядочена до этой нагрузки. Подумайте об этом так: «Все, что я читаю после этого момента, видит, по крайней мере, то, что пишет освободительная нить».

memory_order_release — Используется в магазинах *. Никакая память, читающая/записывающая в текущем потоке, не может быть переупорядочена после ** этого магазина. Подумайте об этом так: «Все, что я написал до этого момента, видно на нити приобретения».

memory_order_acq_rel — Комбинаты приобретают и выпускают. Используется в операциях read-modify-write, таких как compare_exchange.

memory_order_relaxed Никаких гарантий порядка сверх атомарности. Операция атомарная, но может быть переупорядочена свободно. Используйте только для независимых счетчиков или статистики, где заказ не имеет значения.

Приобретение-выдача на практике

Паттерн «приобретение-выпуск» — это рабочая лошадка свободного программирования. Магазин релиза в одном потоке синхронизируется с нагрузкой acquire в другом потоке, устанавливая отношения «случается-до».

acquire_release.cpp
#include <iostream>
#include <atomic>
#include <thread>
#include <cassert>

std::atomic<bool> data_ready{false};
int payload = 0;  // non-atomic — protected by acquire-release

void producer() {
    payload = 42;  // write payload BEFORE the release store
    data_ready.store(true, std::memory_order_release);
    // release: all writes before this store are visible
    // to a thread that does an acquire load of data_ready
}

void consumer() {
    while (!data_ready.load(std::memory_order_acquire)) {
        // spin — acquire: sees all writes before the release store
    }
    // guaranteed to see payload == 42
    assert(payload == 42);  // never fires
    std::cout << "Payload: " << payload << "\n";
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
    return 0;
}

Сравнение и обмен (CAS)

Compare-and-swap (compare_exchange_weak/compare_exchange_strong) является основным строительным блоком структур данных без блокировки. Он атомарно проверяет, равна ли величина ожидаемому значению, и, если да, заменяет его желаемым значением.

cas_max.cpp
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>

std::atomic<int> max_value{0};

void update_max(int new_val) {
    int current = max_value.load(std::memory_order_relaxed);
    // CAS loop: keep trying until we succeed or new_val is not bigger
    while (new_val > current) {
        if (max_value.compare_exchange_weak(
                current,    // expected (updated on failure)
                new_val,    // desired
                std::memory_order_relaxed)) {
            break;  // success
        }
        // On failure, 'current' is updated with the actual value
        // Loop re-checks if new_val > current (maybe another thread won)
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 100; ++i) {
        threads.emplace_back(update_max, i);
    }
    for (auto& t : threads) t.join();

    std::cout << "Max: " << max_value.load() << "\n";  // always 99
    return 0;
}

Spinlock для std::atomic flag

std::atomic_flag — самый примитивный атомный тип — гарантированно свободный от блокировок на каждой платформе. Он поддерживает только test_and_set() и clear(), что делает его идеальным для простого spinlock.

spinlock.cpp
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>

class SpinLock {
    std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
public:
    void lock() {
        while (flag_.test_and_set(std::memory_order_acquire)) {
            // spin — consider adding a pause/yield hint:
            // __builtin_ia32_pause() or std::this_thread::yield()
        }
    }
    void unlock() {
        flag_.clear(std::memory_order_release);
    }
};

SpinLock spin;
int shared_data = 0;

void work(int n) {
    for (int i = 0; i < n; ++i) {
        spin.lock();
        ++shared_data;  // protected by spinlock
        spin.unlock();
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back(work, 100'000);
    }
    for (auto& t : threads) t.join();
    std::cout << "shared_data: " << shared_data << "\n";
    // prints 400000
    return 0;
}
Подводный камень

memory_order_relaxed предоставляет никаких гарантий заказа за пределами самой атомности. Использование его для защиты общих неатомных данных (например, флага, охраняющего полезную нагрузку) является ошибкой:

// BUG: relaxed store/load does not synchronize payload
payload = 42;
ready.store(true, memory_order_relaxed);  // might be reordered BEFORE payload=42!

// Consumer:
while (!ready.load(memory_order_relaxed)) {}
assert(payload == 42);  // MAY FAIL on ARM/POWER

Используйте memory_order_relaxed только тогда, когда атомная переменная стоит в одиночестве и не нуждается в синхронизации доступа к другим данным. Для шаблонов публикации и потребления используйте release/acquire.

Лучшая практика

Начните с memory_order_seq_cst (по умолчанию) и расслабьтесь до более слабых заказов, когда профилирование покажет, что это необходимо. Неправильные упорядочения памяти создают ошибки, которые практически невозможно воспроизвести на x86, но проявляются на ARM. Предпочитают мутексы для сложного общего состояния — атомы для простых общих переменных и построения примитивов без замков. Всегда проверяйте предположения без блокировки с помощью std::atomic::is_lock_free().

Ключевые выводы
  • std::atomic обеспечивает неделимые операции без мутексов — безопасны для одновременного чтения и записи
  • Упорядочение памяти контролирует, как записи в одном потоке становятся видимыми для других потоков - это * * не * * просто об атомарности.
  • Приобретение-выпуск является ключевым шаблоном: релиз-магазин публикует данные, приобретаемая нагрузка потребляет их.
  • compare_exchange_weak/strong (CAS) является строительным блоком для алгоритмов без блокировки.
  • std::atomic_flag является единственным типом, который гарантирует отсутствие блокировки на всех платформах.
  • По умолчанию seq_cst и ослабевает только тогда, когда этого требует профилирование — тонкие баги чрезвычайно трудно отлаживать.

Тест — проверьте свои знания

(25 XP)

1. Что гарантирует `memory_order_acquire` при нагрузке?

2. Почему `compare_exchange_weak` обычно используется в петле?

3. Когда использовать `memory_order_relaxed` безопасно?