Рассмотрим следующий код:
#include <atomic>
#include <thread>
#include <cassert>
#include <memory>
int i = 0;
std::atomic_int a{0};
int main()
{
std::thread thr1{[]
{
i = 1; // A
a.store(1, std::memory_order::release); // B
}};
std::thread thr2{[]
{
while (a.load(std::memory_order::relaxed) != 1); // C
a.store(2, std::memory_order::release); // D
}};
std::thread thr3{[]
{
while (a.load(std::memory_order::acquire) != 2); // E
assert(i == 1); // F
}};
thr1.join();
thr2.join();
thr3.join();
}
Я предполагаю, что утверждение может потерпеть неудачу, а может и не потерпеть неудачу, и поведение здесь неопределенно.
Хотя у нас есть отношения «произошло до», такие как A->B, C->D, D->E, E->F, у нас нет таких отношений для B->C из-за ослабленной нагрузки в C. .
С другой стороны, https://en.cppreference.com/w/cpp/atomic/memory_order говорит, что
Вся память пишет (в том числе неатомная и релаксированная атомарная), что произошло — до атомарного хранилища с точки зрения потока А, станут видимыми побочные эффекты в потоке B. То есть, как только атомная загрузка завершено, поток B гарантированно увидит все, что написал поток A на память. Это обещание выполняется только в том случае, если B действительно возвращает значение. который A сохранен, или значение, полученное позже в последовательности выпуска.
Но мы не можем сказать, что B->D — это последовательность освобождения, возглавляемая B, потому что на a
вообще нет операций чтения-изменения-записи, поэтому этот абзац здесь не работает.
Прав ли я в своем понимании?
@MichaëlRoy, да, я согласен, что его можно протестировать только после кода в двух предыдущих тредах, но как это гарантирует, что результат i = 1
будет там виден?
Это перевернуто. Утверждение об отсутствии срабатывания (упорядочение) — это то, что должно быть доказано. Отсутствие этого означает, что утверждение может сработать в модели памяти.
В любой типичной реализации утверждение должно быть истинным.
Компилятор должен добавить 2 барьера памяти,
B
будет сброшен в основную память после i
в связи с выпуском.i
можно загрузить только после E
из-за приобретения.Если мы сможем доказать, что E
произойдет после B
, то это утверждение всегда будет истинным.
В потоке 2 предсказание перехода не может быть сохранено в основной памяти до того, как переход будет выполнен, поэтому на любом типичном оборудовании эффект D
будет виден после того, как станет виден эффект B
.
поэтому утверждение всегда будет True для любой типичной реализации.
речь идет только о типичных реализациях, я не могу доказать, всегда ли модель памяти C++ будет делать это утверждение верным или нет.
Обновлено: судя по комментариям, это не переносимо на все платформы.
Я согласен, что это то, что делает типичная реализация, но я не уверен, что стандарт C действительно требует этого. Помните, что стандартная модель памяти не работает с точки зрения «барьеров памяти», «предсказания ветвей» или даже «после», только с точки зрения событий «до», и, как и ОП, я не вижу никакого способа установить «происходит-до». перед заказом по этому коду.
Я имею в виду C++, а не C выше.
@Nate прав, и это не просто гипотетическая проблема, если вы хотите полную переносимость. Только RMW продолжает последовательность релизов; чистый магазин этого не делает. Таким образом, загрузка не обязательно синхронизируется со всеми предыдущими хранилищами в порядке модификации. (Это происходит на большинстве ISA, но по крайней мере одна основная ISA может нарушить это, вероятно, PowerPC. C++ 20 даже еще больше ослабил правила; чистые сохранения из того же потока используются для продолжения последовательности выпуска, как и RMW из любого потока. )
Ты прав.
A << B
, C << D
, E << F
.B << C
, C << D
, D << E
B ~ E
Существует два вида упорядочения: происходит раньше и согласованность, упорядоченная раньше. Происходит раньше, это соответствует порядку программы, и именно так мы рассуждаем о синхронизации выпуска-получения. Ранее упорядоченная когерентность согласуется с упорядоченной когерентностью, порядком модификации одного атома и всех последовательно согласованных атомарных операций.
Эти два порядка не согласуются. И это намеренно. См. P0668, чтобы узнать о проблемах с их согласованием и о том, как это даже вызывало проблемы на некоторых реальных архитектурах.
Рассуждения, которые хотелось бы применить в программе OP, по существу смешивают два: порядок модификации a
плюс выпуск-получение и порядок программы. Вот почему невозможно собрать воедино порядок.
Спасибо за ответ. Единственное, чего я не понимаю: 1). Незаданная ранее согласованность вступает в игру только тогда, когда включен режим Memory_order_seq_cst и 2). Если B все-таки синхронизируется с E, то, видимо, я не прав :(
Ты прав. Либо утверждение выполнено успешно, либо поведение не определено (хотя на практике это означает, что утверждение должно завершиться неудачей).
Есть интуитивный способ объяснить это и формальный способ.
В модели получения/освобождения памяти каждый поток рассматривает изменения памяти, выполненные другими потоками, в виде временной шкалы и наблюдает за некоторой точкой на этой временной шкале. Потоки могут не соглашаться по поводу значения, которое хранят объекты, поскольку каждый из них видит свою собственную временную точку для временной шкалы изменений других потоков.
Хотя thr3
может достичь assert(i == 1);
только после того, как thr2
завершит всю свою работу из-за ожидания занятости в thr3
, в момент утверждения thr3
все еще может иметь устаревшее представление thr1
(где установлен i
).
Это устаревшее представление старше, чем существующее thr2
.
Обратите внимание, что thr1
должен завершить всю свою работу, прежде чем thr2
сможет выполнить свою работу, а thr2
должен завершить всю свою работу, прежде чем thr3
сможет выполнить свою работу.
Однако это ограничение на последовательность событий не ограничивает то, какие операции с памятью становятся видимыми между потоками.
Даже если бы thr2
имел a.load(std::memory_order::acquire)
, это не навязывало бы, что thr3
должен иметь более свежее представление о thr1
.
i = 1;
и assert(i == 1);
, возможно, являются гонкой данных.
Либо утверждение выполнено успешно, либо поведение не определено.
Проблема в том, что неизвестно, синхронизируется ли a.store(1, std::memory_order::release);
с a.load(std::memory_order::acquire)
.
Условие для этого можно найти в [atomics.order] p2
Атомарная операция A, выполняющая операцию освобождения атомарного объекта M, синхронизируется с атомарной операцией B, которая выполняет операцию получения M и получает свое значение из любого побочного эффекта в последовательности освобождения, возглавляемой A.
Если load
принимает свое значение от store(1)
в какой-то момент, то a происходит до того, как формируется связь, потому что i = 1
упорядочивается до этого store(1)
и store(1)
синхронизируется с load()
в thr3
.
Однако нет никакой гарантии, что это произойдет ([intro.races] стр.14):
Значение атомарного объекта M, определенное оценкой B, — это значение, сохраненное неким неопределенным побочным эффектом A, который модифицирует M, где B не происходит раньше A.
Вероятная последовательность событий такова, что thr2
завершает всю свою работу до того, как thr3
начнётся.
thr3
затем загружает свое значение для a
из побочного эффекта a.store(2, std::memory_order::release); // D
, поэтому он никогда не берет свое значение из store
в thr1
.
В этом случае происходит гонка данных (между i = 1
и i == 1
), а поведение не определено.
На большинстве аппаратных средств загрузка получения синхронизируется со всеми предыдущими хранилищами в этом месте. (т.е. чистое хранилище может продолжить последовательность релизов). Это естественное следствие MESI для процессоров, где единственный способ увидеть хранилище другим потоком — это предоставить писателю эксклюзивное право владения MESI строкой кэша, а затем читателю получить его копию. Но некоторые ISA слабее, что позволяет реализовать аппаратные реализации, которые могут, например. пересылаемые сохранения между логическими ядрами одного и того же физического ядра (например, POWER); тот же механизм позволяет изменять порядок IRIW.
Итак, правила C++ настолько слабы из-за нескольких ISA, таких как графические процессоры POWER и NVidia; большинство из них сильнее, включая x86 и ARMv8. (И никакие реальные процессоры ARMv7 не были такими слабыми, как ARMv7, разрешенный на бумаге, поскольку не было ARM с SMT (несколько логических ядер на физическое устройство).) См. Почему последовательность выпуска может содержать только чтение-изменение-запись, но не чисто пиши / Что значит "последовательность релизов"?
Ваше предположение неверно. Из-за циклов
while
переменнуюi
можно проверить только после выполнения кода в потоках 1 и 2. Утверждение никогда не может потерпеть неудачу. Это даже не зависит от используемых порядков памяти.