C++11: разница между memory_order_relaxed и memory_order_consume
Сейчас я изучаю модель порядка памяти C++11 и хотел бы понять разницу между memory_order_relaxed
а также memory_order_consume
,
Чтобы быть конкретным, я ищу простой пример, где нельзя заменить memory_order_consume
с memory_order_relaxed
,
Существует отличный пост, который развивает простой, но очень показательный пример, где memory_order_consume
может быть применено. Ниже буквальное копирование-вставка.
Пример:
atomic<int*> Guard(nullptr);
int Payload = 0;
Режиссер:
Payload = 42;
Guard.store(&Payload, memory_order_release);
Потребитель:
g = Guard.load(memory_order_consume);
if (g != nullptr)
p = *g;
Мой вопрос состоит из двух частей:
- Можно ли заменить
memory_order_consume
сmemory_order_relaxed
в приведенном выше примере? - Можно ли предложить аналогичный пример, где
memory_order_consume
не может быть заменено наmemory_order_relaxed
?
1 ответ
Вопрос 1
Нет. memory_order_relaxed
не накладывает порядок памяти вообще:
Упрощенная операция: нет ограничений синхронизации или упорядочения, требуется только атомарность этой операции.
В то время как memory_order_consume
устанавливает порядок в памяти для зависимых от данных чтений (в текущем потоке)
Операция загрузки с этим порядком памяти выполняет операцию потребления в уязвимом месте памяти: никакие операции чтения в текущем потоке, зависящие от загруженного в данный момент значения, не могут быть переупорядочены до этой загрузки.
редактировать
В общем memory_order_seq_cst
сильнее memory_order_acq_rel
сильнее memory_ordering_relaxed
,
Это похоже на лифт А, который может поднимать 800 кг, Лифт С, который поднимает 100 кг.
Теперь, если бы у вас была возможность волшебным образом превратить Лифт А в Лифт С, что бы произошло, если бы в первом было 10 человек со средним весом? Это было бы плохо.
Чтобы увидеть, что может пойти не так с кодом, рассмотрим пример по вашему вопросу:
Thread A Thread B
Payload = 42; g = Guard.load(memory_order_consume);
Guard.store(1, memory_order_release); if (g != 0)
p = Payload;
Этот фрагмент предназначен для зацикливания, нет никакой синхронизации, только упорядочение между двумя потоками.
С memory_order_relaxed
и предполагая, что естественное слово load / store является атомарным, код будет эквивалентен
Thread A Thread B
Payload = 42; g = Guard
Guard = 1 if (g != 0)
p = Payload;
С точки зрения ЦП в потоке А существует два хранилища для двух отдельных адресов, поэтому если Guard
"ближе" к процессору (что означает, что хранилище будет завершено быстрее) от другого процессора, кажется, что поток A выполняет
Thread A
Guard = 1
Payload = 42
И этот порядок исполнения возможен
Thread A Guard = 1
Thread B g = Guard
Thread B if (g != nullptr) p = Payload
Thread A Payload = 42
И это плохо, так как поток B считал не обновленное значение полезной нагрузки.
Однако может показаться, что в потоке B синхронизация будет бесполезной, поскольку ЦП не будет выполнять такой порядок, как
Thread B
if (g != 0) p = Payload;
g = Guard
Но это на самом деле будет.
С его точки зрения есть две несвязанные нагрузки, это правда, что один находится на зависимом пути данных, но процессор все еще может спекулятивно выполнять нагрузку:
Thread B
hidden_tmp = Payload;
g = Guard
if (g != 0) p = hidden_tmp
Это может генерировать последовательность
Thread B hidden_tmp = Payload;
Thread A Payload = 42;
Thread A Guard = 1;
Thread B g = Guard
Thread B if (g != 0) p = hidden_tmp
Упс.
вопрос 2
В общем, это никогда не может быть сделано.
Вы можете заменить memory_order_acquire
с memory_order_consume
когда вы собираетесь сгенерировать адресную зависимость между загруженным значением и значением (ями), доступ к которым необходимо упорядочить.
Чтобы понять memory_order_relaxed
мы можем взять архитектуру ARM в качестве ориентира.
Архитектура ARM требует только слабого упорядочения памяти, что означает, что в общем случае загрузка и сохранение программы могут выполняться в любом порядке.
str r0, [r2]
str r0, [r3]
Во фрагменте над магазином [r3]
можно наблюдать снаружи, перед магазином [r2]
1
Однако процессор не идет так далеко, как процессор Alpha, и налагает два вида зависимостей: зависимость от адреса, когда загрузка значения из памяти используется для вычисления адреса другой загрузки / сохранения, и зависимость управления, когда загрузка значения из память используется для вычисления контрольных флагов другой загрузки / хранения.
При наличии такой зависимости порядок двух операций памяти гарантированно будет виден в программном порядке:
Если есть адресная зависимость, то два обращения к памяти наблюдаются в программном порядке.
Итак, пока memory_order_acquire
будет генерировать барьер памяти, с memory_order_consume
вы говорите компилятору, что способ, которым вы будете использовать загруженное значение, сгенерирует адресную зависимость, и поэтому он может, если это имеет отношение к архитектуре, использовать этот факт и исключить барьер памяти.
1 Если r2
это адрес объекта синхронизации, это плохо.
Можно ли заменить
memory_order_consume
с участиемmemory_order_relaxed
в примере выше?
Безопасно в ISO C++: нет.
На практике в большинстве реализаций для большинства ISA часто да. Обычно он компилируется в asm с зависимостью данных между первым результатом загрузки и адресом второй загрузки, и большинство ISA действительно гарантируют такой порядок. (Это функция HWconsume
должен был разоблачить).
Но поскольку дизайн C++11 для consume
было непрактично для компиляторов реализовать, они все просто отказались и усилили его до acquire
, требующий барьера памяти на большинстве слабоупорядоченных ISA. (например, POWER или ARM, но не x86).
Итак, в реальной жизни, чтобы получить такую отличную производительность для чтения вещей, которые почти никогда не меняются, некоторый реальный код (например, RCU) действительно использует relaxed
осторожно, способами, которые, как мы надеемся, не будут оптимизированы во что-то небезопасное. См. Доклад Пола Маккенни на CppCon 2016: Атомика C++: печальная история memory_order_consume: наконец-то счастливый конец? о том, как Linux использует это, чтобы сделать чтение RCU очень дешевым, без каких-либо препятствий. (В ядре они просто используютvolatile
вместо того _Atomic
с участием memory_order_relaxed
, но они компилируются, по сути, одинаково для чистой загрузки или чистого хранилища.)
Будьте осторожны с тем, как вы используете consume
, и зная, как компиляторы обычно компилируют код, можно получить известные компиляторы, такие как gcc и clang, для достаточно надежной генерации безопасных / правильных и эффективных asm для известных целей, таких как x86, ARM и POWER, которые, как известно, выполняют упорядочение зависимостей на оборудовании.
(x86 делает acquire
в оборудовании для вас, поэтому, если вы заботитесь только о x86, вы ничего не получите от использования relaxed
над consume
или acquire
.)
Можно ли предложить аналогичный пример, где
memory_order_consume
нельзя заменить наmemory_order_relaxed
?
DEC Alpha AXP не гарантирует упорядочение зависимостей в HW, и несколько микроархитектур Alpha действительно могут нарушить причинно-следственную связь, загрузив *g
значение старше, чем g
. См. Раздел " Переупорядочивание зависимых нагрузок в ЦП", а также " Порядок использования памяти" в C11, где приведена цитата Линуса Торвальдса о том, как только несколько машин Alpha могут это сделать.
Или для любого ISA он может сломаться во время компиляции, если компилятор нарушит зависимость данных с помощью зависимости управления. например, если у компилятора есть основания полагать, чтоg
будет иметь определенное значение, можно преобразовать в p = *g
в код вроде
if (g == expected_address)
p = *expected_address;
else
p = *g;
Реальные процессоры используют прогнозирование ветвления, поэтому инструкции после ветвления могут выполняться, даже если g.load()
еще не закончил. Такp = *expected_address
может выполняться без зависимости данных от g
.
Слабо упорядоченные ISA, которые действительно документируют свои гарантии упорядочения зависимостей (POWER, ARM и т. Д.), Не гарантируют его по ветвям, а только истинные зависимости данных. (Было бы хорошо, если бы обе стороны ветки использовали*g
.)
Возможно, это не то, что компиляторы могут делать, но C++ consume
гарантирует, что даже array[foo.load(consume) & 1]
упорядочивается по зависимости после загрузки. Имея только 2 возможных значения, более вероятно, что компилятор будет ветвиться.
(Или в вашем примере, если atomic<int*> Guard(nullptr);
является static
и его адрес не выходит за пределы модуля компиляции, тогда компилятор может увидеть, что единственные 2 значения, которые он может иметь, этоnullptr
или &Payload
, и, следовательно, если он не равен нулю, то это должна быть полезная нагрузка. Так что да, эта оптимизация действительно возможна в вашем случае, дляmo_relaxed
. Я думаю, что текущий gcc / clang, вероятно, никогда не будет делать никаких предположений относительно значения, загруженного из атома (например, они относятся кvolatile
) так что на практике вы, вероятно, в безопасности. Это может измениться, как только C++ получит способ сделать безопасным для компиляторов оптимизацию атомики. Может и оптимизирует ли компилятор две атомные нагрузки?)
Фактически, ISO C++ consume
даже гарантирует упорядочение зависимостей для int dep = foo.load(consume); dep -= dep; p = array[dep];
Вы можете использовать это, чтобы получить упорядочение зависимостей после перехода по флагу, например, даже после уменьшения зависимости до значения, известного во время компиляции1. В этом случае ноль.
Но компиляторы ищут случаи, когда переменная уменьшается только до 1 возможного значения, и изменят это p = array[dep]
в p = array[0]
, убирая зависимость от нагрузки. (Это вид отслеживания зависимостей, позволяющий выяснить, когда было или небезопасно выполнять обычные оптимизации,consume
практически невозможно безопасно реализовать без повсеместного использования компилятора. Элементы carry_dependency и kill_dependency могли ограничить это границами функций, но все равно это оказалось слишком сложно.)
Сноска 1. Вот почему ISA, такие как ARM, не допускаются даже в особых случаях.eor r0, r0
как идиома обнуления, разрушающая зависимости, как x86 делает дляxor eax,eax
. Правила asm действительно гарантируют, что делать что-то подобное в asm безопасно. (И ISA с фиксированной шириной команд в любом случае не используют для обнуления xor;mov r0, #0
имеет тот же размер.) Проблема заключается в том, чтобы заставить компиляторы генерировать asm с зависимостью, которая требуется только потребителю, без выполнения каких-либо обычных преобразований, которые избегают зависимостей данных и создают параллелизм на уровне инструкций для выполнения вне очереди, чтобы найти и эксплуатировать.
См. Также P0371R1: Временно не рекомендуется использовать memory_order_consume и другие документы C++ wg21, связанные с этим, о том, почему потребление не рекомендуется.
Трудности, по-видимому, связаны как с высокой сложностью реализации, так и с тем фактом, что в текущем определении используется довольно общее определение "зависимости", что требует частого и неудобного использования
kill_dependency
звонка, и от частой потребности[[carries_dependency]]
аннотации. Подробности можно найти, например, в P0098R0.