std:: memory_order
|
Определено в заголовочном файле
<atomic>
|
||
|
enum
memory_order
{
|
(начиная с C++11)
(до C++20) |
|
|
enum
class
memory_order
:
/* unspecified */
{
|
(начиная с C++20) | |
std::memory_order
определяет, каким образом операции с памятью, включая обычные неатомарные обращения к памяти, должны упорядочиваться относительно атомарной операции. При отсутствии ограничений в многопроцессорной системе, когда несколько потоков одновременно читают и записывают несколько переменных, один поток может наблюдать изменение значений в порядке, отличном от порядка, в котором другой поток их записывал. Более того, видимый порядок изменений может различаться даже между несколькими потоками-читателями. Некоторые схожие эффекты могут возникать даже в однопроцессорных системах из-за преобразований компилятора, разрешённых моделью памяти.
Поведение по умолчанию всех атомарных операций в библиотеке обеспечивает
последовательно согласованное упорядочение
(см. обсуждение ниже). Это поведение по умолчанию может снижать производительность, но атомарным операциям библиотеки может быть передан дополнительный аргумент
std::memory_order
для указания точных ограничений (помимо атомарности), которые компилятор и процессор должны соблюдать для данной операции.
Константы
|
Определено в заголовочном файле
<atomic>
|
|
| Название | Значение |
memory_order_relaxed
|
Расслабленная операция: не накладывает ограничений синхронизации или упорядочивания на другие операции чтения или записи, гарантируется только атомарность данной операции (см. Расслабленное упорядочивание ниже). |
memory_order_consume
(устарело в C++26) |
Операция загрузки с этим порядком памяти выполняет операцию потребления для затронутой области памяти: никакие операции чтения или записи в текущем потоке, зависящие от загруженного значения, не могут быть переупорядочены перед этой загрузкой. Записи в зависимые переменные в других потоках, которые освобождают ту же атомарную переменную, видны в текущем потоке. На большинстве платформ это влияет только на оптимизации компилятора (см. Упорядочивание "освобождение-потребление" ниже). |
memory_order_acquire
|
Операция загрузки с этим порядком памяти выполняет операцию захвата для затронутой области памяти: никакие операции чтения или записи в текущем потоке не могут быть переупорядочены перед этой загрузкой. Все записи в других потоках, которые освобождают ту же атомарную переменную, видны в текущем потоке (см. Упорядочивание "освобождение-захват" ниже). |
memory_order_release
|
Операция сохранения с этим порядком памяти выполняет операцию освобождения : никакие операции чтения или записи в текущем потоке не могут быть переупорядочены после этой операции сохранения. Все записи в текущем потоке видны в других потоках, которые захватывают ту же атомарную переменную (см. Упорядочивание "освобождение-захват" ниже), и записи, которые несут зависимость в атомарную переменную, становятся видны в других потоках, которые потребляют ту же атомарную переменную (см. Упорядочивание "освобождение-потребление" ниже). |
memory_order_acq_rel
|
Операция чтения-изменения-записи с этим порядком памяти является одновременно и операцией захвата , и операцией освобождения . Никакие операции чтения или записи памяти в текущем потоке не могут быть переупорядочены перед загрузкой или после сохранения. Все записи в других потоках, которые освобождают ту же атомарную переменную, видны до модификации, и модификация видна в других потоках, которые захватывают ту же атомарную переменную. |
memory_order_seq_cst
|
Операция загрузки с этим порядком памяти выполняет операцию захвата , операция сохранения выполняет операцию освобождения , а операция чтения-изменения-записи выполняет и операцию захвата , и операцию освобождения , плюс существует единый общий порядок, в котором все потоки наблюдают все модификации в одном и том же порядке (см. Последовательно-согласованное упорядочивание ниже). |
Формальное описание
Межпоточная синхронизация и упорядочение памяти определяют, как вычисления и побочные эффекты выражений упорядочиваются между различными потоками выполнения. Они определяются следующими терминами:
Упорядочено-перед
В рамках одного потока вычисление A может быть sequenced-before вычислением B, как описано в разделе evaluation order .
Перенос зависимостиВ пределах одного потока вычисление A, которое упорядочено-перед вычислением B, также может переносить зависимость в B (то есть B зависит от A), если выполняется любое из следующих условий:
1)
Значение A используется в качестве операнда B,
за исключением
a)
если B является вызовом
std::kill_dependency
,
b)
если A является левым операндом встроенных операторов
&&
,
||
,
?:
, или
,
.
2)
A записывает значение в скалярный объект M, B читает из M.
3)
A переносит зависимость в другое вычисление X, и X переносит зависимость в B.
|
(до C++26) |
Порядок модификации
Все изменения любого конкретного атомарного переменного происходят в полном порядке, который специфичен именно для этой одной атомарной переменной.
Следующие четыре требования гарантируются для всех атомарных операций:
Последовательность освобождения
После выполнения операции освобождения A над атомарным объектом M, самая длинная непрерывная подпоследовательность порядка модификации M, состоящая из:
|
1)
Записи, выполненные тем же потоком, который выполнил A.
|
(until C++20) |
Известно как release sequence headed by A .
Синхронизируется с
Если атомарная запись в потоке A является операцией освобождения (release operation) , атомарное чтение в потоке B из той же переменной является операцией захвата (acquire operation) , и чтение в потоке B получает значение, записанное операцией записи в потоке A, тогда запись в потоке A синхронизируется-с (synchronizes-with) чтением в потоке B.
Кроме того, некоторые вызовы библиотечных функций могут быть определены для синхронизации-с другими вызовами библиотечных функций в других потоках.
Упорядочено по зависимости передМежду потоками, вычисление A упорядочено по зависимости перед вычислением B, если выполняется любое из следующих условий:
1)
A выполняет
release операцию
на некотором атомарном M, и, в другом потоке, B выполняет
consume операцию
на том же атомарном M, и B читает значение, записанное
любой частью релиз последовательности, возглавляемой
(до C++20)
A.
2)
A упорядочено по зависимости перед X и X переносит зависимость в B.
|
(до C++26) |
Межпоточное отношение happens-before
Между потоками, вычисление A межпоточно предшествует вычислению B, если выполняется любое из следующих условий:
Happens-beforeНезависимо от потоков, вычисление A happens-before вычисление B, если выполняется любое из следующих условий:
1)
A является
sequenced-before
B.
2)
A
inter-thread happens before
B.
Реализация обязана обеспечивать ацикличность отношения happens-before , вводя дополнительную синхронизацию при необходимости (это может потребоваться только при участии операции consume, см. Batty et al ). Если одно вычисление изменяет область памяти, а другое читает или изменяет ту же область памяти, и если хотя бы одно из вычислений не является атомарной операцией, поведение программы не определено (программа имеет data race ), если между этими двумя вычислениями не существует отношения happens-before .
|
(until C++26) | ||
Happens-beforeНезависимо от потоков, вычисление A happens-before вычисление B, если выполняется любое из следующих условий:
1)
A является
sequenced-before
B.
2)
A
synchronizes-with
B.
3)
A
happens-before
X, и X
happens-before
B.
|
(since C++26) |
Сильно предшествует (Strongly happens-before)
Независимо от потоков, вычисление A строго предшествует вычислению B, если выполняется любое из следующих условий:
|
1)
A
sequenced-before
B.
2)
A
synchronizes-with
B.
3)
A
strongly happens-before
X, и X
strongly happens-before
B.
|
(до C++20) | ||
|
1)
A
sequenced-before
B.
2)
A
synchronizes with
B, и оба A и B являются последовательно согласованными атомарными операциями.
3)
A
sequenced-before
X, X
simply
(до C++26)
happens-before
Y, и Y
sequenced-before
B.
4)
A
strongly happens-before
X, и X
strongly happens-before
B.
Примечание: неформально, если A strongly happens-before B, то A выглядит так, как будто вычисляется до B во всех контекстах.
|
(начиная с C++20) |
Видимые побочные эффекты
Побочный эффект A над скаляром M (запись) является видимым по отношению к вычислению значения B над M (чтение), если выполняются оба следующих условия:
Если побочный эффект A виден относительно вычисления значения B, то наибольшее непрерывное подмножество побочных эффектов в M, в порядке модификации , где B не происходит-до него, известно как видимая последовательность побочных эффектов (значение M, определяемое B, будет значением, сохранённым одним из этих побочных эффектов).
Примечание: синхронизация между потоками сводится к предотвращению состояний гонки (путем установления отношений happens-before) и определению того, какие побочные эффекты становятся видимыми при каких условиях.
Операция потребления
Атомарная загрузка с
memory_order_consume
или более строгим порядком является операцией потребления. Заметьте, что
std::atomic_thread_fence
накладывает более строгие требования синхронизации, чем операция потребления.
Операция Acquire
Атомарная загрузка с
memory_order_acquire
или более строгим порядком является операцией захвата. Операция
lock()
на
Mutex
также является операцией захвата. Обратите внимание, что
std::atomic_thread_fence
накладывает более строгие требования синхронизации, чем операция захвата.
Операция освобождения
Атомарная запись с
memory_order_release
или более строгим порядком является операцией освобождения. Операция
unlock()
на
Mutex
также является операцией освобождения. Обратите внимание, что
std::atomic_thread_fence
накладывает более строгие требования синхронизации, чем операция освобождения.
Объяснение
Ослабленное упорядочение
Атомарные операции с тегом memory_order_relaxed не являются операциями синхронизации; они не накладывают порядок на параллельные обращения к памяти. Они гарантируют только атомарность и согласованность порядка модификаций.
Например, при x и y изначально равных нулю,
// Поток 1: r1 = y.load(std::memory_order_relaxed); // A x.store(r1, std::memory_order_relaxed); // B // Поток 2: r2 = x.load(std::memory_order_relaxed); // C y.store(42, std::memory_order_relaxed); // D
разрешено получать r1 == r2 == 42 потому что, хотя A sequenced-before B в потоке 1 и C sequenced before D в потоке 2, ничто не мешает D появиться перед A в порядке модификации y , а B появиться перед C в порядке модификации x . Побочный эффект D на y может быть видим для загрузки A в потоке 1, в то время как побочный эффект B на x может быть видим для загрузки C в потоке 2. В частности, это может произойти, если D завершается до C в потоке 2, либо из-за переупорядочивания компилятором, либо во время выполнения.
|
Даже при ослабленной модели памяти, значения "из ниоткуда" (out-of-thin-air) не могут циклически зависеть от собственных вычислений, например, при x и y изначально равных нулю, // Thread 1: r1 = y.load(std::memory_order_relaxed); if (r1 == 42) x.store(r1, std::memory_order_relaxed); // Thread 2: r2 = x.load(std::memory_order_relaxed); if (r2 == 42) y.store(42, std::memory_order_relaxed); не допускается получение r1 == r2 == 42 , поскольку сохранение 42 в y возможно только если сохранение в x сохраняет 42 , что циклически зависит от сохранения в y значения 42 . Отметим, что до C++14 это технически допускалось спецификацией, но не рекомендовалось для реализаторов. |
(since C++14) |
Типичное использование ослабленного порядка памяти — инкрементирование счетчиков, таких как счетчики ссылок
std::shared_ptr
, поскольку это требует только атомарности, но не упорядочивания или синхронизации (обратите внимание, что декрементирование
std::shared_ptr
счетчиков требует синхронизации acquire-release с деструктором).
#include <atomic> #include <iostream> #include <thread> #include <vector> std::atomic<int> cnt = {0}; void f() { for (int n = 0; n < 1000; ++n) cnt.fetch_add(1, std::memory_order_relaxed); } int main() { std::vector<std::thread> v; for (int n = 0; n < 10; ++n) v.emplace_back(f); for (auto& t : v) t.join(); std::cout << "Final counter value is " << cnt << '\n'; }
Вывод:
Final counter value is 10000
Упорядочение Release-Acquire
Если атомарная запись в потоке A помечена тегом memory_order_release , атомарное чтение в потоке B из той же переменной помечено тегом memory_order_acquire , и чтение в потоке B получает значение, записанное операцией записи в потоке A, тогда запись в потоке A синхронизируется-с чтением в потоке B.
Все записи в память (включая неатомарные и релаксированные атомарные), которые happened-before атомарной записи с точки зрения потока A, становятся visible side-effects в потоке B. То есть, как только атомарное чтение завершено, поток B гарантированно видит всё, что поток A записал в память. Это обещание действует только если B фактически возвращает значение, которое сохранил A, или значение из более поздней части последовательности выпуска.
Синхронизация устанавливается только между потоками, которые освобождают и захватывают одну и ту же атомарную переменную. Другие потоки могут видеть иной порядок операций доступа к памяти, чем один или оба синхронизированных потока.
На системах с сильной упорядоченностью — x86, SPARC TSO, мейнфреймы IBM и т.д. — упорядочение release-acquire является автоматическим для большинства операций. Для этого режима синхронизации не генерируются дополнительные инструкции процессора; затрагиваются только определенные оптимизации компилятора (например, компилятору запрещено перемещать неатомарные записи после атомарной store-release или выполнять неатомарные чтения раньше атомарной load-acquire). На системах со слабой упорядоченностью (ARM, Itanium, PowerPC) используются специальные инструкции процессора для загрузки или барьеров памяти.
Мьютексы, такие как std::mutex или атомарный спинлок , являются примером синхронизации по схеме освобождение-захват: когда блокировка освобождается потоком A и захватывается потоком B, все, что происходило в критической секции (до освобождения) в контексте потока A, должно быть видно потоку B (после захвата), который выполняет ту же критическую секцию.
#include <atomic> #include <cassert> #include <string> #include <thread> std::atomic<std::string*> ptr; int data; void producer() { std::string* p = new std::string("Hello"); data = 42; ptr.store(p, std::memory_order_release); } void consumer() { std::string* p2; while (!(p2 = ptr.load(std::memory_order_acquire))) ; assert(*p2 == "Hello"); // никогда не срабатывает assert(data == 42); // никогда не срабатывает } int main() { std::thread t1(producer); std::thread t2(consumer); t1.join(); t2.join(); }
Следующий пример демонстрирует транзитивное упорядочение release-acquire между тремя потоками с использованием release sequence.
#include <atomic> #include <cassert> #include <thread> #include <vector> std::vector<int> data; std::atomic<int> flag = {0}; void thread_1() { data.push_back(42); flag.store(1, std::memory_order_release); } void thread_2() { int expected = 1; // memory_order_relaxed is okay because this is an RMW, // and RMWs (with any ordering) following a release form a release sequence while (!flag.compare_exchange_strong(expected, 2, std::memory_order_relaxed)) { expected = 1; } } void thread_3() { while (flag.load(std::memory_order_acquire) < 2) ; // if we read the value 2 from the atomic flag, we see 42 in the vector assert(data.at(0) == 42); // will never fire } int main() { std::thread a(thread_1); std::thread b(thread_2); std::thread c(thread_3); a.join(); b.join(); c.join(); }
Упорядочение Release-Consume
|
Если атомарная запись в потоке A помечена тегом memory_order_release , атомарное чтение в потоке B из той же переменной помечено тегом memory_order_consume , и чтение в потоке B получает значение, записанное операцией записи в потоке A, тогда запись в потоке A является упорядоченной по зависимостям перед чтением в потоке B. Все операции записи в память (неатомарные и релаксированные атомарные), которые произошли-до атомарной записи с точки зрения потока A, становятся видимыми побочными эффектами в тех операциях потока B, в которые операция чтения переносит зависимость , то есть, как только атомарное чтение завершено, гарантируется, что те операторы и функции в потоке B, которые используют значение, полученное из операции чтения, увидят то, что поток A записал в память. Синхронизация устанавливается только между потоками, освобождающими и потребляющими одну и ту же атомарную переменную. Другие потоки могут видеть иной порядок обращений к памяти, чем один или оба синхронизированных потока. На всех основных CPU, кроме DEC Alpha, упорядочивание по зависимостям происходит автоматически, для этого режима синхронизации не выдаются дополнительные инструкции CPU, затрагиваются только определенные оптимизации компилятора (например, компилятору запрещено выполнять спекулятивные загрузки объектов, участвующих в цепочке зависимостей).
Типичные случаи использования этого упорядочивания включают чтение редко изменяемых конкурентных структур данных (таблицы маршрутизации, конфигурации, политики безопасности, правила брандмауэра и т.д.) и ситуации издатель-подписчик с публикацией через указатель, то есть когда производитель публикует указатель, через который потребитель может получить доступ к информации: нет необходимости делать видимыми для потребителя все остальные записи в память, выполненные производителем (что может быть дорогой операцией на слабо упорядоченных архитектурах). Примером такого сценария является
Смотрите также
std::kill_dependency
и
Обратите внимание, что в настоящее время (2/2015) ни один известный промышленный компилятор не отслеживает цепочки зависимостей: операции consume повышаются до операций acquire. |
(до C++26) |
|
Спецификация упорядочения release-consume пересматривается, и использование
|
(since C++17)
(until C++26) |
|
Упорядочение release-consume имеет тот же эффект, что и упорядочение release-acquire, и является устаревшим. |
(since C++26) |
Этот пример демонстрирует синхронизацию с упорядочением по зависимостям для публикации через указатель: целочисленные данные не связаны с указателем на строку отношением зависимости по данным, поэтому их значение в потребителе не определено.
#include <atomic> #include <cassert> #include <string> #include <thread> std::atomic<std::string*> ptr; int data; void producer() { std::string* p = new std::string("Hello"); data = 42; ptr.store(p, std::memory_order_release); } void consumer() { std::string* p2; while (!(p2 = ptr.load(std::memory_order_consume))) ; assert(*p2 == "Hello"); // never fires: *p2 carries dependency from ptr assert(data == 42); // may or may not fire: data does not carry dependency from ptr } int main() { std::thread t1(producer); std::thread t2(consumer); t1.join(); t2.join(); }
Последовательно-согласованное упорядочение
Атомарные операции с тегом memory_order_seq_cst не только упорядочивают память так же, как упорядочение release/acquire (все, что happened-before сохранения в одном потоке, становится видимым побочным эффектом в потоке, выполнившем загрузку), но также устанавливают единый общий порядок модификаций всех атомарных операций, помеченных этим тегом.
|
Формально,
каждая операция B с
Если существовала операция
Для пары атомарных операций над M, называемых A и B, где A записывает, а B читает значение M, если существуют два
Для пары атомарных модификаций M, называемых A и B, B происходит после A в порядке модификации M, если:
Отметим, что это означает:
1)
как только в игру вступают атомарные операции без тега
memory_order_seq_cst
, последовательная согласованность теряется,
2)
последовательно-согласованные барьеры устанавливают общий порядок только для самих барьеров, а не для атомарных операций в общем случае (
sequenced-before
не является межпоточным отношением, в отличие от
happens-before
).
|
(until C++20) |
|
Формально,
атомарная операция A над некоторым атомарным объектом M является упорядоченной-по-когерентности-перед другой атомарной операцией B над M, если выполняется любое из следующих условий:
1)
A является модификацией, и B читает значение, сохранённое A,
2)
A предшествует B в
порядке модификаций
M,
3)
A читает значение, сохранённое атомарной модификацией X, X предшествует B в
порядке модификаций
, и A и B не являются одной и той же атомарной операцией чтения-модификации-записи,
4)
A является
упорядоченной-по-когерентности-перед
X, и X является
упорядоченной-по-когерентности-перед
B.
Существует единый полный порядок S для всех операций
1)
если A и B являются операциями
memory_order_seq_cst
, и A
сильно-происходит-перед
B, то A предшествует B в S,
2)
для каждой пары атомарных операций A и B над объектом M, где A
упорядочена-по-когерентности-перед
B:
a)
если A и B обе являются операциями
memory_order_seq_cst
, то A предшествует B в S,
b)
если A является операцией
memory_order_seq_cst
, и B
происходит-перед
барьером
memory_order_seq_cst
Y, то A предшествует Y в S,
c)
если барьер
memory_order_seq_cst
X
происходит-перед
A, и B является операцией
memory_order_seq_cst
, то X предшествует B в S,
d)
если барьер
memory_order_seq_cst
X
происходит-перед
A, и B
происходит-перед
барьером
memory_order_seq_cst
Y, то X предшествует Y в S.
Формальное определение гарантирует, что:
1)
единый полный порядок согласован с
порядком модификаций
любого атомарного объекта,
2)
загрузка
memory_order_seq_cst
получает своё значение либо из последней модификации
memory_order_seq_cst
, либо из некоторой модификации не-
memory_order_seq_cst
, которая не
происходит-перед
предшествующими модификациями
memory_order_seq_cst
.
Единый полный порядок может быть не согласован с
происходит-перед
. Это позволяет более эффективную реализацию
Например, при
// Thread 1: x.store(1, std::memory_order_seq_cst); // A y.store(1, std::memory_order_release); // B // Thread 2: r1 = y.fetch_add(1, std::memory_order_seq_cst); // C r2 = y.load(std::memory_order_relaxed); // D // Thread 3: y.store(3, std::memory_order_seq_cst); // E r3 = x.load(std::memory_order_seq_cst); // F
может дать результат
r1
==
1
&&
r2
==
3
&&
r3
==
0
, где A
происходит-перед
C, но C предшествует A в едином полном порядке C-E-F-A операций
Важно отметить:
1)
как только в программе появляются атомарные операции, не помеченные
memory_order_seq_cst
, гарантия последовательной согласованности для программы теряется,
2)
во многих случаях атомарные операции
memory_order_seq_cst
могут быть переупорядочены относительно других атомарных операций, выполняемых тем же потоком.
|
(since C++20) |
Последовательное упорядочивание может быть необходимо в ситуациях с несколькими производителями и несколькими потребителями, где все потребители должны наблюдать действия всех производителей, происходящие в одном и том же порядке.
Полное последовательное упорядочивание требует инструкции полного барьера памяти на всех многопроцессорных системах. Это может стать узким местом производительности, поскольку принудительно распространяет затрагиваемые обращения к памяти на каждое ядро.
Этот пример демонстрирует ситуацию, когда необходимо последовательное упорядочение. Любой другой порядок может вызвать срабатывание assert, поскольку потоки
c
и
d
могут наблюдать изменения атомарных переменных
x
и
y
в противоположном порядке.
#include <atomic> #include <cassert> #include <thread> std::atomic<bool> x = {false}; std::atomic<bool> y = {false}; std::atomic<int> z = {0}; void write_x() { x.store(true, std::memory_order_seq_cst); } void write_y() { y.store(true, std::memory_order_seq_cst); } void read_x_then_y() { while (!x.load(std::memory_order_seq_cst)) ; if (y.load(std::memory_order_seq_cst)) ++z; } void read_y_then_x() { while (!y.load(std::memory_order_seq_cst)) ; if (x.load(std::memory_order_seq_cst)) ++z; } int main() { std::thread a(write_x); std::thread b(write_y); std::thread c(read_x_then_y); std::thread d(read_y_then_x); a.join(); b.join(); c.join(); d.join(); assert(z.load() != 0); // will never happen }
Связь с volatile
В потоке выполнения обращения (чтения и записи) через volatile glvalues не могут быть переупорядочены относительно наблюдаемых побочных эффектов (включая другие volatile-обращения), которые sequenced-before или sequenced-after в том же потоке, однако этот порядок не гарантирован для наблюдения из другого потока, поскольку volatile-доступ не устанавливает межпоточную синхронизацию.
Кроме того, обращения к volatile не являются атомарными (параллельное чтение и запись представляет собой data race ) и не упорядочивают память (не-volatile обращения к памяти могут свободно переупорядочиваться вокруг volatile обращения).
Одним заметным исключением является Visual Studio, где при настройках по умолчанию каждая запись в volatile имеет семантику освобождения, а каждое чтение volatile — семантику захвата (
Microsoft Docs
), поэтому volatile может использоваться для межпоточной синхронизации. Стандартная
volatile
семантика не применима к многопоточному программированию, хотя её достаточно для, например, взаимодействия с обработчиком
std::signal
, который выполняется в том же потоке, когда применяется к переменным типа
sig_atomic_t
. Опция компилятора
/volatile:iso
может использоваться для восстановления поведения, соответствующего стандарту, что является настройкой по умолчанию при целевой платформе ARM.
Смотрите также
|
Документация C
для
memory order
|
Внешние ссылки
| 1. | MOESI protocol |
| 2. | x86-TSO: A Rigorous and Usable Programmer’s Model for x86 Multiprocessors P. Sewell и др., 2010 |
| 3. | A Tutorial Introduction to the ARM and POWER Relaxed Memory Models P. Sewell и др., 2012 |
| 4. | MESIF: A Two-Hop Cache Coherency Protocol for Point-to-Point Interconnects J.R. Goodman, H.H.J. Hum, 2009 |
| 5. | Memory Models Russ Cox, 2021 |
|
Этот раздел не завершён
Причина: Нужно найти хорошие источники по QPI, MOESI и, возможно, Dragon. |