Стандарт C++: можно ли поднять блокированные атомарные хранилища выше блокировки мьютекса?
Есть ли в стандарте какая-либо формулировка, гарантирующая, что расслабленные хранилища для атомщиков не будут отменены над блокировкой мьютекса? Если нет, то есть ли какая-либо формулировка, в которой прямо говорится, что компилятору или процессору это делать кошерно?
Например, возьмите следующую программу:
std::mutex mu;
int foo = 0; // Guarded by mu
std::atomic<bool> foo_has_been_set{false};
void SetFoo() {
mu.lock();
foo = 1;
foo_has_been_set.store(true, std::memory_order_relaxed);
mu.unlock();
}
void CheckFoo() {
if (foo_has_been_set.load(std::memory_order_relaxed)) {
mu.lock();
assert(foo == 1);
mu.unlock();
}
}
Это возможно для CheckFoo
сбой в вышеупомянутой программе, если другой поток вызывает SetFoo
одновременно, или есть какая-то гарантия, что магазин foo_has_been_set
не может быть поднят над вызовом mu.lock
компилятором и процессором?
Это связано со старым вопросом, но мне не на 100% ясно, что ответ здесь относится к этому. В частности, контрпример в ответе на этот вопрос может относиться к двум одновременным вызовам SetFoo
, но меня интересует случай, когда компилятор знает, что есть один вызов SetFoo
и один звонок CheckFoo
, Это гарантированно безопасно?
Я ищу конкретные цитаты в стандарте. Спасибо!
5 ответов
Я думаю, что я выяснил конкретные ребра частичного порядка, которые гарантируют, что программа не сможет аварийно завершить работу. В ответе ниже я ссылаюсь на версию проекта стандарта N4659.
Код, используемый для потока записи A и потока чтения B:
A1: mu.lock()
A2: foo = 1
A3: foo_has_been_set.store(relaxed)
A4: mu.unlock()
B1: foo_has_been_set.load(relaxed) <-- (stop if false)
B2: mu.lock()
B3: assert(foo == 1)
B4: mu.unlock()
Мы ищем доказательство того, что если B3 выполняется, то A2 предшествует B3, как определено в [intro.races] / 10. Согласно [intro.races]/10.2 достаточно доказать, что межпотоковая обработка A2 происходит до B3.
Поскольку операции блокировки и разблокировки для данного мьютекса выполняются в едином общем порядке ( [thread.mutex.requirements.mutex] / 5), сначала мы должны иметь A1 или B2. Два случая:
Предположим, что A1 происходит до B2. Затем по [thread.mutex.class]/1 и [thread.mutex.requirements.mutex]/25 мы знаем, что A4 будет синхронизироваться с B2. Поэтому, согласно [intro.races] /9.1, межпотоковая обработка A4 происходит до B2. Поскольку B2 секвенируется до B3, с помощью [intro.races]/9.3.1 мы знаем, что межпотоковая обработка A4 происходит до B3. Поскольку A2 секвенируется до A4, по [intro.races] /9.3.2, межпотоковая обработка A2 происходит до B3.
Предположим, что B2 происходит до A1. Тогда по той же логике, что и выше, мы знаем, что B4 синхронизируется с A1. Таким образом, поскольку A1 секвенируется до A3, с помощью [intro.races]/9.3.1, межпотоковая обработка B4 происходит до A3. Поэтому, поскольку B1 упорядочен до B4, согласно [intro.races] /9.3.2, межпотоковая обработка B1 происходит до A3. Следовательно, согласно [intro.races]/10.2, B1 предшествует A3. Но затем, согласно [intro.races] / 16, B1 должен получить свое значение из состояния, предшествующего A3. Поэтому загрузка вернет false, и B2 никогда не будет работать в первую очередь. Другими словами, этот случай не может произойти.
Таким образом, если B3 выполняется вообще (случай 1), A2 происходит раньше, чем B3, и утверждение пройдет. ∎
Никакая операция с памятью внутри защищенной области мьютекса не может "убежать" из этой области. Это относится ко всем операциям с памятью, атомарным и неатомарным.
В разделе 1.10.1:
вызов, который получает мьютекс, будет выполнять операцию получения в местоположениях, составляющих мьютекс. Соответственно, вызов, который освобождает тот же мьютекс, будет выполнять операцию освобождения в тех же самых местоположениях.
Кроме того, в разделе 1.10.1.6:
Все операции с данным мьютексом выполняются в едином общем порядке. Каждое обнаружение мьютекса "читает записанное значение" последним выпуском мьютекса.
И в 30.4.3.1
Объект мьютекса облегчает защиту от скачек данных и обеспечивает безопасную синхронизацию данных между агентами выполнения
Это означает, что получение (блокировка) мьютекса устанавливает односторонний барьер, который предотвращает перемещение операций, которые выполняются после захвата (внутри защищенной области), вверх по блокировке мьютекса.
Освобождение (разблокировка) мьютекса устанавливает односторонний барьер, который предотвращает перемещение операций, выполняемых до освобождения (внутри защищенной области), через разблокировку мьютекса.
Кроме того, операции с памятью, которые освобождаются мьютексом, синхронизируются (видимы) с другим потоком, который получает тот же мьютекс.
В вашем примере foo_has_been_set
проверяется в CheckFoo
.. Если это читает true
Вы знаете, что значение 1 было присвоено foo
от SetFoo
, но это еще не синхронизировано. Последовательная блокировка мьютекса приобретет foo
, синхронизация завершена, и утверждение не может быть запущено.
Стандарт не гарантирует этого напрямую, но вы можете прочитать его между строками [thread.mutex.requirements.mutex].:
В целях определения наличия гонки данных они ведут себя как атомарные операции ([intro.multithread]).
Операции блокировки и разблокировки на одном мьютексе должны выполняться в едином общем порядке.
Второе предложение выглядит как жесткая гарантия, но на самом деле это не так. Единый общий порядок очень хорош, но это означает только то, что существует четко определенный единый общий порядок получения и освобождения одного конкретного мьютекса. Само по себе это не означает, что эффекты любых атомарных операций или связанных неатомарных операций должны или должны быть глобально видимыми в некоторой конкретной точке, связанной с мьютексом. Или что угодно. Единственное, что гарантируется, - это порядок выполнения кода (в частности, выполнение одной пары функций,lock
а также unlock
), ничего не говорится о том, что может или не может произойти с данными или иначе.
Однако между строками можно прочесть, что это, тем не менее, само намерение части "вести себя как атомарные операции".
Из других мест также довольно ясно, что это точная идея и что реализация, как ожидается, будет работать таким образом, без явного указания, что это должно быть. Например, [intro.races] гласит:
[ Примечание: Например, вызов, который получает мьютекс, будет выполнять операцию получения в местах, составляющих мьютекс. Соответственно, вызов, освобождающий один и тот же мьютекс, выполнит операцию освобождения в тех же местах.
Обратите внимание на неудачное маленькое безобидное слово "Примечание:". Примечания не являются нормативными. Таким образом, в то время стало ясно, что это, как это должно быть понятным (блокировка мьютекса = приобретают, разблокировать = релиз), это не на самом деле гарантия.
Я думаю, что лучшая, хотя и непростая гарантия исходит из этого предложения в [thread.mutex.requirements.general]:
Объект мьютекса облегчает защиту от скачков данных и обеспечивает безопасную синхронизацию данных между агентами исполнения.
Вот что делает мьютекс (не говоря уже о том, как именно). Он защищает от гонок данных. Полная остановка.
Таким образом, независимо от того, какие тонкости придумываются и что еще написано или не сказано явно, использование мьютекса защищает от гонок данных (... любого рода, поскольку конкретный тип не указан). Вот что написано. Итак, в заключение, пока вы используете мьютекс, вы можете работать даже с расслабленным упорядочиванием или вообще без атомных операций. Загрузки и хранилища (любого типа) нельзя перемещать, потому что тогда нельзя быть уверенным в отсутствии гонок данных. От чего, однако, и защищает мьютекс.
Таким образом, не говоря об этом, это говорит о том, что мьютекс должен быть полным барьером.
CheckFoo()
не может вызвать сбой программы (т. е. вызвать assert()
) но нет и гарантии assert()
когда-либо будет казнен.
Если условие в начале CheckFoo()
запускает (см. ниже) видимое значение foo
будет 1 из-за барьеров памяти и синхронизации между mu.unlock()
в SetFoo()
а также mu.lock()
в CheckFoo()
,
Я полагаю, что на это распространяется описание мьютекса, приведенное в других ответах.
Однако нет гарантии, что условие if (foo_has_been_set.load(std::memory_order_relaxed))
) когда-нибудь будет правдой. Расслабленный порядок памяти не дает никаких гарантий, и гарантируется только атомарность операции. Следовательно, в отсутствие какого-либо другого барьера нет гарантии, когда расслабленный магазин в SetFoo()
будет виден в CheckFoo()
но если это видно, это будет только потому, что хранилище было выполнено, а затем следуя mu.lock()
должен быть заказан после mu.unlock()
и пишет, прежде чем это видно.
Обратите внимание, что этот аргумент опирается на тот факт, что foo_has_been_set
устанавливается только из false
в true
, Если бы была другая функция с именем UnsetFoo()
который установил его обратно в ложь:
void SetFoo() {
mu.lock();
foo = 0;
foo_has_been_set.store(false, std::memory_order_relaxed);
mu.unlock();
}
Это было вызвано из другого (или еще третьего) потока, тогда нет никакой гарантии, что проверка foo_has_been_set
без синхронизации гарантирует, что foo
установлено.
Быть понятным (и предполагая foo_has_been_set
никогда не сбрасывается)
void CheckFoo() {
if (foo_has_been_set.load(std::memory_order_relaxed)) {
assert(foo == 1); //<- All bets are off.
mu.lock();
assert(foo == 1); //Guaranteed to succeed.
mu.unlock();
}
}
На практике на любой реальной платформе в любом давно работающем приложении, вероятно, неизбежно, что хранилище Relax в конечном итоге станет видимым для другого потока. Но нет никакой официальной гарантии относительно того, произойдет ли это или когда это произойдет, если не существуют другие барьеры для его обеспечения.
Официальные ссылки:
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3690.pdf
См. Примечания в конце стр.13 и начало стр.14, особенно примечания 17 - 20. Они, по сути, обеспечивают согласованность "расслабленных" операций. Их видимость ослаблена, но видимость, которая появляется, будет согласованной, и использование фразы "случается раньше" входит в общий принцип упорядочения программ и, в частности, приобретения и освобождения барьеров мьютексов. Примечание 19 особенно актуально:
Четыре предыдущих требования согласованности эффективно запрещают переупорядочение атомарных операций в одном объекте компилятором, даже если обе операции являются ослабленными нагрузками. Это эффективно обеспечивает гарантию согласованности кэша, предоставляемую большинством аппаратного обеспечения, доступного для атомарных операций C++
Этот заказ возможен:
void SetFoo() {
mu.lock();
// REORDERED:
foo_has_been_set.store(true, std::memory_order_relaxed);
PAUSE(); //imagine scheduler pause here
foo = 1;
mu.unlock();
}
Теперь вопрос CheckFoo
- может читать foo_has_been_set
попасть в замок? Обычно такое чтение может (вещи могут попасть в блокировку, но не в нее), но блокировку никогда не следует брать, если if ложно, так что это будет странный порядок. Что-нибудь говорит, что "спекулятивные блокировки" не разрешены? Или процессор может предположить, что если перед чтением true foo_has_been_set
?
void CheckFoo() {
// REORDER???
mu.lock();
if (foo_has_been_set.load(std::memory_order_relaxed)) {
assert(foo == 1);
}
mu.unlock();
}
Этот порядок, вероятно, не в порядке, но только из-за "логического порядка", а не порядка памяти. Если mu.lock()
был встроен (и стал некоторыми атомными операциями), что мешает им быть переупорядоченным?
Я не слишком беспокоюсь о вашем текущем коде, но я беспокоюсь о любом реальном коде, который использует что-то вроде этого. Это слишком близко к неправильному.
т. е. если бы OP-код был реальным, вы просто изменили бы foo на атомарный и избавились бы от всего остального. Таким образом, реальный код должен быть другим. Сложнее? ...
Ответ, кажется, находится в http://eel.is/c++draft/intro.multithread
Две соответствующие части
[...] Кроме того, существуют расслабленные атомарные операции, которые не являются операциями синхронизации [...]
а также
[...] выполнение операции освобождения A приводит к тому, что предшествующие побочные эффекты в других ячейках памяти становятся видимыми для других потоков, которые позже выполняют операцию потребления или получения для A. [...]
В то время как атомарность ослабленных порядков не считается операцией синхронизации, это все, что стандарт должен сказать о них в этом контексте. Поскольку они по-прежнему являются ячейками памяти, по-прежнему действует общее правило их управления другими операциями синхронизации.
Итак, в заключение, у стандарта, похоже, нет ничего конкретного, чтобы предотвратить переупорядочение, которое вы описали, но формулировка в том виде, в каком она существует, естественным образом помешает этому.
Изменить: Woops, я связан с черновиком. Параграф C++11, охватывающий это 1.10-5, используя тот же язык.