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;

Мой вопрос состоит из двух частей:

  1. Можно ли заменить memory_order_consume с memory_order_relaxed в приведенном выше примере?
  2. Можно ли предложить аналогичный пример, где 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.

Другие вопросы по тегам