модель памяти, как на самом деле работает семантика получения нагрузки?
Из очень красивой бумаги и статьи о переупорядочении памяти.
Q1: Я понимаю, чтосогласованность кеша, буфер хранения и очередь недействительности являютсяосновной причиной переупорядочения памяти?
Выпуск магазина вполне понятен, нужно дождаться завершения всей загрузки и сохранения, прежде чем установить флаг в значение true.
Что касается загрузки, типичное использование атомарной загрузки - ожидание флага. Допустим, у нас есть 2 потока:
int x = 0;
std::atomic<bool> ready_flag = false;
// thread-1
if(ready_flag.load(std::memory_order_relaxed))
{
// (1)
// load x here
}
// (2)
// load x here
// thread-2
x = 100;
ready_flag.store(true, std::memory_order_release);
РЕДАКТИРОВАТЬ: в потоке-1 это должен быть цикл while, но я скопировал логику из статьи выше. Итак, предположим, что переупорядочение памяти произошло как раз вовремя.
Q2: Поскольку (1) и (2) зависят от условия if, CPU должен ждать ready_flag, означает ли это, что достаточно записи-освобождения? Как может происходить переупорядочение памяти в этом контексте?
Q3: Очевидно, что у нас естьзагрузка-получение, поэтому я предполагаю, что переупорядочение памяти возможно, тогда где мы должны разместить ограждение (1) или (2)?
3 ответа
Поскольку (1) и (2) зависят от условия if, ЦП должен ждать ready_flag
В этом рассуждении есть 2 очевидных недостатка:
Предсказание переходов + спекулятивное исполнение - это реальная вещь в реальных процессорах. Зависимости элементов управления ведут себя иначе, чем зависимости данных. Спекулятивное исполнение нарушает управляющие зависимости.
В большинстве (но не все) реальные процессоры, зависимости данных делают работу, как C++
memory_order_consume
. Типичный вариант использования - загрузка указателя с последующим разыменованием его. Это все еще небезопасно в очень слабой модели памяти C++, но произойдет компиляция в asm, который работает для большинства ISA, кроме DEC Alpha. Alpha может (на практике на некотором оборудовании) даже нарушить причинно-следственную связь и загрузить устаревшее значение при разыменовании только что загруженного указателя, даже если хранилища были упорядочены правильно.Компиляторы могут нарушить контроль и даже зависимости данных. Логика исходного кода C++ не всегда транслируется напрямую в asm. В этом случае компилятор может выдать asm, который работает следующим образом:
tmp = load(x); // compile time reordering before the relaxed load if (load(ready_flag) actually use tmp;
Это UB-гонка данных в C++ для чтения
x
пока он, возможно, еще пишется, но для большинства конкретных ISA проблем с этим нет. Вам просто нужно избегать фактического использования любых результатов загрузки, которые могут быть ложными.Возможно, это бесполезная оптимизация для большинства ISA, но ничто не исключает ее. Иногда может быть полезно скрыть задержку загрузки на конвейерах в порядке, выполняя загрузку раньше (если она не была написана другим потоком, и компилятор может предположить, что этого не происходит, потому что нет загрузки).
Безусловно, лучше всего использовать ready_flag.load(mo_acquire)
.
Отдельная проблема заключается в том, что вы закомментировали код, читающий x
после того, какif()
, который будет запущен, даже если загрузка не увидела готовых данных. Как объяснил @Nicol в ответе, это означает, что UB-гонка данных возможна, потому что вы, возможно, читаетеx
пока продюсер это пишет.
Возможно, вы хотели написать цикл ожидания с вращением, например while(!ready_flag){ _mm_pause(); }
? Как правило, будьте осторожны, чтобы не тратить огромное количество процессорного времени на спин; если это может быть долгое время, используйте что-то, поддерживаемое библиотекой, например, переменную условия, которая дает вам эффективный откат к режиму сна / пробуждения, поддерживаемого ОС (например, Linuxfutex
) после короткого отжима.
Если вы хотите, чтобы ручной барьер был отделен от груза, он был бы
if (ready_flag.load(mo_relaxed))
atomic_thread_fence(mo_acquire);
int tmp = x; // now this is safe
}
// atomic_thread_fence(mo_acquire); // still wouldn't make it safe to read x
// because this code runs even after ready_flag == false
С помощью if(ready_flag.load(mo_acquire))
приведет к безусловному забору перед переходом на загрузку ready_flag при компиляции для любого ISA, где загрузка-загрузка не была доступна с помощью одной инструкции. (На x86 все нагрузки снимаются, на AArch64ldar
делает приобретение нагрузки. ARM нужна нагрузка +dsb ish
)
Доступ к атомарной переменной не является мьютексной операцией; он просто обращается к сохраненному значению атомарно, без возможности для какой-либо операции ЦП прервать доступ, так что никакие гонки данных не могут произойти в отношении доступа к этому значению (он также может создавать барьеры в отношении других доступов, что и заказывает память предоставлять). Но это все; он не ждет, пока в атомарной переменной появится какое-либо конкретное значение.
Таким образом, ваш if
оператор будет читать любое значение, которое будет там в данный момент. Если вы хотите защитить доступ кx
пока другой оператор не запишется в него и не отправит сигнал атомарному элементу, вы должны:
Запретить чтение кода из
x
пока атомарный флаг не вернет значениеtrue
. Простая проверка значения один раз этого не сделает; вы должны повторять повторные обращения, покаtrue
. Любая другая попытка чтения изx
приводит к гонке данных и, следовательно, является неопределенным поведением.Каждый раз, когда вы обращаетесь к флагу, вы должны делать это таким образом, чтобы сообщить системе, что значения, записанные настройкой потока, должны быть видимы для последующих операций, которые видят установленное значение. Это требует правильного порядка памяти, который должен быть не менее
memory_order_acquire
.Говоря техническим языком, считывание из самого флага не обязательно должно выполнять захват. Вы можете выполнить операцию получения после прочтения правильного значения из флага. Но перед чтением необходимо выполнить операцию, эквивалентную получению.
x
.Оператор записи должен устанавливать флаг, используя порядок освобождения памяти, который должен быть не менее мощным, чем
memory_order_release
.
Стандарт C++ не определяет код, генерируемый какой-либо конкретной конструкцией; Только правильные комбинации инструментов потоковой коммуникации дают гарантированный результат.
Вы не получаете гарантий от ЦП в C++, потому что C++ не является разновидностью (макроса) сборки, даже не "сборкой высокого уровня", по крайней мере, когда не все объекты имеют изменчивый тип.
Атомарные объекты - это средства связи для обмена данными между потоками. Правильное использование для правильной видимости операций с памятью - это либо операция сохранения с (как минимум) выпуском, за которой следует загрузка с получением, то же самое с RMW между ними, либо сохранение (или загрузка), замененное на RMW с (по крайней мере) релиз (или приобретение), в любом варианте с расслабленной работой и отдельным забором.
В любом случае:
- поток, "публикующий" флаг "готово", должен использовать порядок памяти, по крайней мере, для выпуска (то есть: выпуск, выпуск + получение или последовательная согласованность),
- и "подписывающий" поток, тот, который воздействует на флаг, должен использовать по крайней мере получение (то есть: получение, выпуск + получение или последовательная согласованность).
На практике с отдельно скомпилированным кодом могут работать другие режимы, в зависимости от процессора.