Параллельные вычисления
Memory Ordering и барьеры
Код работает на машине разработчика. Падает на production ARM сервере раз в неделю - ненадёжно воспроизводится, без явной причины. Причина: store на x86 implicit release, на ARM - нет. Один пропущенный `memory_order_release` превращает lock-free код в бомбу замедленного действия. Memory ordering - это контракт с процессором.
- **Linux kernel:** использует smp_rmb(), smp_wmb(), smp_mb() - обёртки над аппаратными барьерами, critical path в interrupt handling и RCU
- **Java Memory Model (JMM):** happens-before отношения - высокоуровневая абстракция над acquire-release, обязательна для JVM-разработчика
- **ARM vs x86 correctness:** Facebook Folly, Abseil - portability библиотеки явно документируют ordering на каждой атомарной операции для ARM production
Acquire-Release семантика
Процессор и компилятор переупорядочивают инструкции для производительности. На однопоточном коде это незаметно. В многопоточном - катастрофа: поток B может увидеть данные, которые поток A «ещё не записал» с точки зрения B. **Acquire-Release** - минимальный набор гарантий, достаточный для lock-free примитивов: два потока синхронизируются через shared переменную.
**Release** (`memory_order_release`): все записи до release-store становятся видны для потока, который делает acquire-load того же адреса. **Acquire** (`memory_order_acquire`): все записи, сделанные до release-store, видны после acquire-load. Это однонаправленный барьер: Release - «всё до меня уже видно», Acquire - «всё что было до release - вижу».
**Паттерн использования:** mutex.lock() делает acquire, mutex.unlock() делает release - вот почему код внутри критической секции корректен. Спинлок, atomic flag, любой lock-free примитив использует тот же acquire-release паттерн.
Поток A записывает данные, затем делает flag.store(true, release). Поток B в цикле читает flag.load(acquire) до true. Что гарантировано?
Sequential Consistency
Acquire-Release синхронизирует пары потоков. Но есть сценарий, где этого мало: когда нужно гарантировать **единый глобальный порядок** всех атомарных операций для **всех** потоков одновременно. **Sequential Consistency (SC, `memory_order_seq_cst`)** - самый строгий ordering: программа выглядит так, как будто все операции выполняются в некотором глобальном последовательном порядке.
SC - дефолтный ordering в C++ (`std::atomic` без explicit ordering). На x86 это почти бесплатно (hardware уже SC для loads/stores). На ARM/PowerPC - нужен полный барьер `dmb ish` на каждой sc-операции, что дороже acquire-release примерно в 2-5x. В lock-free алгоритмах SC нужен редко - обычно достаточно acquire-release.
**Правило большого пальца:** начни с seq_cst (корректно и понятно), затем профилируй. Если seq_cst операция в hot path - рассмотри acquire-release. Никогда не ослабляй ordering «на глазок» без формального reasoning.
Чем Sequential Consistency отличается от Acquire-Release?
Relaxed ordering
На другом конце спектра - **`memory_order_relaxed`**: атомарность гарантирована (нет torn reads/writes), но никакой синхронизации между потоками. Компилятор и CPU могут переупорядочивать такие операции как угодно относительно других. Звучит страшно - но есть сценарии, где именно это нужно.
Правило: relaxed безопасен, когда атомарная переменная **не используется как синхронизационный примитив** между потоками - то есть мы не говорим «если ты видишь это значение, то данные готовы». Счётчики статистики, reference counts (кроме последнего release), флаги без data dependency - кандидаты для relaxed.
**Ошибка:** использовать relaxed для «флага готовности данных». Типично: `data = 42; flag.store(1, relaxed)`. Consumer читает flag=1, но data может быть ещё не видна. Это undefined behavior в C++ memory model. Нужен как минимум release.
Какой из сценариев БЕЗОПАСНО использует memory_order_relaxed?
Memory Fences и барьеры
Ordering на атомарных операциях - это «встроенный барьер» для конкретной операции. Но иногда нужно синхронизировать **группу** relaxed-операций за раз, или поставить барьер для неатомарных accesses. **`std::atomic_thread_fence`** - отдельный, явный memory барьер, независимый от конкретных атомарных переменных.
**Compiler barrier:** `std::atomic_signal_fence(seq_cst)` - барьер только для компилятора, без CPU инструкции. Используется для signal handlers (не threads): запрещает компилятору переставлять код, но не нужен аппаратный барьер (signal handler запускается на том же ядре).
volatile в C++ дает те же гарантии что std::atomic
volatile запрещает компилятору оптимизировать обращения к переменной, но не даёт никаких гарантий видимости между потоками и не является атомарным
volatile разработан для memory-mapped I/O (hardware registers), где каждое чтение/запись имеет side effect. В многопоточном коде volatile без atomic - data race и undefined behavior
Когда `atomic_thread_fence(release)` предпочтительнее release на каждой атомарной операции?
Memory Ordering: иерархия гарантий
- Relaxed: атомарность без синхронизации - для счётчиков и reference counts
- Acquire-Release: однонаправленная синхронизация пар потоков через shared переменную
- Sequential Consistency: глобальный порядок всех операций для всех потоков - дефолт, дороже на ARM
- Fence: явный барьер для группы relaxed-операций или неатомарных accesses
- volatile != atomic: volatile для hardware registers, atomic для межпоточной синхронизации
Связанные темы
Memory ordering лежит в основе всех lock-free структур и правильного использования std::atomic.
- Lock-Free структуры данных — CAS в lock-free коде требует корректного memory ordering на каждой операции
- Атомарные операции и std::atomic — std::atomic предоставляет ordering как параметр каждой операции
- Cache Coherence и MESI — Memory ordering реализуется через cache coherence протоколы на уровне железа
Вопросы для размышления
- На x86 store имеет implicit release, load - implicit acquire. Значит ли это что relaxed и seq_cst одинаковы на x86, и можно писать код только под x86?
- Java Memory Model определяет happens-before через synchronized, volatile, Thread.join. Как это соотносится с C++ acquire-release? Что они гарантируют одинаково, а что нет?
- Двойная проверка блокировки (DCLP) - известный антипаттерн в Java до JMM 2005. В C++11 он корректен с atomic. Почему именно C++11 изменил ситуацию?