concurrency — фундаментconcurrency — fundamentals
Формальная модель памяти C++11 существует, потому что современные CPU и компиляторы переупорядочивают операции с памятью ради производительности — без явного контракта о том, что видно другому потоку и когда, многопоточный код был бы недоказуем. Ссылка по глубине этой темы — Anthony Williams, "C++ Concurrency in Action".The formal C++11 memory model exists because modern CPUs and compilers reorder memory operations for performance — without an explicit contract for what's visible to another thread and when, multithreaded code would be unprovable. The reference for depth on this topic is Anthony Williams, "C++ Concurrency in Action."
Эти два термина часто путают, но они означают разное. Race condition — общее понятие: результат программы зависит от непредсказуемого порядка выполнения потоков (может быть логической ошибкой даже без единого data race — например, банковский перевод, где проверка баланса и списание не атомарны как единая операция). Data race — конкретный, формально определённый случай: два потока обращаются к одной и той же памяти без синхронизации, хотя бы одно обращение — запись, и это не atomic-операции. Data race — всегда undefined behavior по стандарту C++, независимо от того, "повезло" ли получить ожидаемый результат на практике.These two terms are often conflated, but they mean different things. A race condition is a general concept: the program's outcome depends on unpredictable thread scheduling (can be a logic bug even with zero data races — e.g., a bank transfer where checking the balance and debiting aren't atomic as one operation). A data race is a specific, formally defined case: two threads access the same memory without synchronization, at least one access is a write, and neither is an atomic operation. A data race is always undefined behavior per the C++ standard, regardless of whether you "got lucky" and saw the expected result in practice.
int counter = 0; // не atomic// not atomic
// поток 1 и поток 2 одновременно:// thread 1 and thread 2 concurrently:
counter++; // чтение-модификация-запись без синхронизации — data race, UB// read-modify-write with no synchronization — a data race, UB
На практике UB здесь проявляется не только как "потерянные" инкременты (что интуитивно ожидаемо), но и в теории может проявляться как что угодно — компилятор формально вправе предполагать отсутствие data race и оптимизировать код исходя из этого допущения, что иногда даёт результаты хуже интуитивных ожиданий.In practice, the UB here shows up not just as "lost" increments (which is intuitively expected), but in theory can manifest as anything — the compiler is formally entitled to assume no data race exists and optimize the code based on that assumption, which sometimes produces results worse than intuition suggests.
std::atomic<T> гарантирует, что операции над значением неделимы (atomic) с точки зрения других потоков — не бывает промежуточного, "разорванного" состояния, видимого извне. Помимо неделимости, каждая atomic-операция несёт memory order — контракт о том, какие ИНЫЕ (не atomic) операции памяти гарантированно видны другим потокам относительно неё.std::atomic<T> guarantees that operations on the value are indivisible (atomic) from other threads' point of view — there's no intermediate, "torn" state visible from outside. Beyond indivisibility, every atomic operation carries a memory order — a contract for which OTHER (non-atomic) memory operations are guaranteed visible to other threads relative to it.
std::atomic<int> counter{0};
// поток 1 и поток 2 одновременно:// thread 1 and thread 2 concurrently:
counter.fetch_add(1, std::memory_order_relaxed); // корректно: сам счётчик неделим// correct: the counter itself is indivisible
Важная тонкость: atomic решает проблему data race на САМОЙ atomic-переменной, но не превращает окружающий код в потокобезопасный автоматически — составные операции из нескольких atomic-чтений/записей (например, "если counter == 5, установить counter = 10") всё равно не атомарны как единое целое без дополнительных примитивов (`compare_exchange`, мьютекса).An important subtlety: atomic solves the data race problem on the atomic variable ITSELF, but doesn't automatically make surrounding code thread-safe — composite operations built from several atomic reads/writes (e.g., "if counter == 5, set counter = 10") still aren't atomic as a whole without additional primitives (`compare_exchange`, a mutex).
memory_order_relaxed — гарантирует только атомарность самой операции, никаких гарантий упорядочивания относительно других операций памяти в этом потоке. Годится там, где важен только сам счётчик (статистика, ref-counting без последующего доступа к разделяемым данным через него), но НЕ годится, если по значению atomic-переменной другой поток должен понять, что некоторые НЕ-atomic данные уже готовы.memory_order_relaxed guarantees only the atomicity of the operation itself, with no ordering guarantee relative to other memory operations in that thread. It's fine where only the counter itself matters (statistics, ref-counting with no subsequent access to shared data through it), but NOT fine if another thread needs to infer from the atomic variable's value that some NON-atomic data is now ready.
memory_order_acquire (на чтении) / memory_order_release (на записи) — образуют пару "публикация/подписка": всё, что было записано (в том числе не-atomic) ДО release-записи в одном потоке, гарантированно видно ПОСЛЕ соответствующей acquire-загрузки в другом потоке. Это ровно та гарантия, что нужна для "safely publish a pointer/data across threads".memory_order_acquire (on load) / memory_order_release (on store) form a "publish/subscribe" pair: everything written (including non-atomic writes) BEFORE the release-store in one thread is guaranteed visible AFTER the matching acquire-load in another thread. This is exactly the guarantee needed to "safely publish a pointer/data across threads."
int data = 0; // не atomic — обычные данные// not atomic — plain data
std::atomic<bool> ready{false};
// поток-производитель:// producer thread:
data = 42; // обычная запись// plain write
ready.store(true, std::memory_order_release); // "публикация": data гарантированно видна после этого// "publish": data is guaranteed visible after this
// поток-потребитель:// consumer thread:
if (ready.load(std::memory_order_acquire)) { // "подписка"// "subscribe"
assert(data == 42); // гарантированно true — никогда не UB, никогда не "недописанное" значение// guaranteed true — never UB, never a "half-written" value
}
memory_order_seq_cst (значение по умолчанию, если order не указан) — самая строгая гарантия: помимо acquire-release семантики, ВСЕ seq_cst-операции во ВСЕХ потоках наблюдаются в одном и том же глобальном порядке, согласованном между потоками. Это самый простой для рассуждений режим, но и самый дорогой (требует полного барьера памяти на большинстве архитектур, включая x86 не только для чтения, но и для более сложных случаев на ARM).memory_order_seq_cst (the default when no order is specified) is the strictest guarantee: beyond acquire-release semantics, ALL seq_cst operations across ALL threads are observed in one consistent global order, agreed upon by every thread. It's the easiest mode to reason about, but also the most expensive (requires a full memory fence on most architectures, including x86 not just for reads but for more elaborate ARM cases).
| memory order | гарантияguarantee | стоимостьcost |
|---|---|---|
| relaxed | только атомарность операцииonly the operation's atomicity | минимальнаяminimal |
| acquire / release | видимость non-atomic записей между парой потоковvisibility of non-atomic writes between a thread pair | средняяmoderate |
| seq_cst | единый глобальный порядок для всех потоковone global order across all threads | наибольшаяhighest |
volatile в C++ гарантирует только запрет компилятору кэшировать значение в регистре и переупорядочивать обращения к нему относительно других volatile-обращений — он ничего не говорит об атомарности или видимости для ДРУГИХ потоков (это его роль в C для отображаемой в память периферии, не для многопоточности). Использование volatile вместо atomic для межпоточной синхронизации — распространённая, но некорректная практика.volatile in C++ only forbids the compiler from caching the value in a register and reordering accesses to it relative to other volatile accesses — it says nothing about atomicity or visibility for OTHER threads (that's its role in C for memory-mapped hardware, not for multithreading). Using volatile instead of atomic for cross-thread synchronization is a common but incorrect practice.
Код может быть полностью защищён мьютексами или atomic-ами (без единого data race) и всё равно содержать логическую гонку — например, если критическая секция слишком узкая и не покрывает всю логически неделимую операцию целиком (check-then-act паттерн без единой блокировки на весь check+act).Code can be fully protected by mutexes or atomics (with zero data races) and still contain a logical race — e.g., if the critical section is too narrow and doesn't cover the entire logically-indivisible operation (a check-then-act pattern without one lock spanning the whole check+act).
На x86/x64 большинство обычных операций памяти уже дают достаточно сильные гарантии упорядочивания на аппаратном уровне, из-за чего код с неправильным (слишком слабым) memory order может годами "работать" в разработке и проде на x86, но сломаться при переносе на ARM (мобильные, Apple Silicon, многие серверные ARM-платформы) с более слабой аппаратной моделью памяти.On x86/x64, most ordinary memory operations already provide fairly strong ordering guarantees at the hardware level, which means code with an incorrect (too weak) memory order can "work" for years in development and production on x86, only to break when ported to ARM (mobile, Apple Silicon, many server ARM platforms) with a weaker hardware memory model.