Параллельные вычисления

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 изменил ситуацию?

Связанные уроки

  • arch-08-memory-hierarchy
  • arch-09-cache
Memory Ordering и барьеры

0

1

Войти