Как достичь барьера StoreLoad в C ++ 11? - PullRequest
13 голосов
/ 04 февраля 2020

Я хочу написать переносимый код (Intel, ARM, PowerP C ...), который решает вариант задачи classi c:

Initially: X=Y=0

Thread A:
  X=1
  if(!Y){ do something }
Thread B:
  Y=1
  if(!X){ do something }

, в котором цель чтобы избежать ситуации, в которой оба потока делают something. (Хорошо, если ни одна из них не запускается; это не механизм, запускаемый ровно один раз.) Пожалуйста, исправьте меня, если вы увидите некоторое fl aws в моих рассуждениях ниже.

Я знаю, что могу достигните цели с memory_order_seq_cst atomi c store s и load s следующим образом:

std::atomic<int> x{0},y{0};
void thread_a(){
  x.store(1);
  if(!y.load()) foo();
}
void thread_b(){
  y.store(1);
  if(!x.load()) bar();
}

, которая достигает цели, потому что на
должен быть какой-то один общий заказ {x.store(1), y.store(1), y.load(), x.load()} события, которые должны согласовываться с «гранями» программного порядка:

  • x.store(1) «в TO до» y.load()
  • y.store(1) »в TO до «x.load()

и если был вызван foo(), то у нас есть дополнительное ребро:

  • y.load()» читает значение перед «y.store(1)

и если был вызван bar(), то у нас есть дополнительное ребро:

  • x.load() "читает значение перед" x.store(1)

и все эти ребра, объединенные вместе, образовали бы цикл:

x.store(1) "в TO до" y.load() "считывает значение до" y.store(1) "в TO до" x.load() "читает значение до" x.store(true)

, что нарушает тот факт, что у заказов нет циклов.

Я намеренно использую нестандартные термины «в ТО есть раньше» и «читает значение раньше» в отличие от стандартных терминов, таких как happens-before, потому что я хочу потребовать Обратная связь о правильности моего предположения о том, что эти ребра действительно подразумевают отношение happens-before, может быть объединена в один граф, и цикл в таком объединенном графе запрещен. Я не уверен в этом. Я знаю, что этот код создает правильные барьеры на Intel g cc & clang и на ARM g cc


Теперь моя настоящая проблема немного сложнее, потому что я не контролирую "X" - он скрыт за некоторыми макросами, шаблонами и т. д. c. и может быть слабее, чем seq_cst

Я даже не знаю, является ли «X» единственной переменной или какой-то другой концепцией (например, легкий семафор или мьютекс). Все, что я знаю, это то, что у меня есть два макроса set() и check(), так что check() возвращает true "после" другого потока с именем set(). (Известно, что также известно, что set и check являются поточно-ориентированными и не могут создавать UB для гонки данных.)

Так что концептуально set() чем-то похоже на "X" = 1 "и check() похоже на" X ", но у меня нет прямого доступа к атомам, если таковые имеются.

void thread_a(){
  set();
  if(!y.load()) foo();
}
void thread_b(){
  y.store(1);
  if(!check()) bar();
}

Я беспокоюсь, что set() может быть реализовано внутри как x.store(1,std::memory_order_release) и / или check() может быть x.load(std::memory_order_acquire). Или гипотетически std::mutex, что один поток разблокируется, а другой try_lock ing; в стандарте ISO std::mutex гарантируется только порядок получения и выпуска, но не seq_cst.

Если это так, то check(), если тело можно «переупорядочить» до y.store(true) ( См. Ответ Алекса , где они демонстрируют, что это происходит на PowerP C).
Это было бы очень плохо, так как теперь такая последовательность событий возможна:

  • thread_b() сначала загружает старое значение x (0)
  • thread_a() выполняет все, включая foo()
  • thread_b() выполняет все, включая bar()

Итак, оба foo() и bar() были вызваны, чего я должен был избежать. Какие у меня есть варианты, чтобы предотвратить это?


Опция A

Попытаться форсировать барьер Store-Load. На практике это может быть достигнуто с помощью std::atomic_thread_fence(std::memory_order_seq_cst); - как объяснил Алекс в другом ответе все протестированные компиляторы выдавали полный забор:

  • x86_64: MFENCE
  • PowerP C: hwsyn c
  • Итануим: mf
  • ARMv7 / ARMv8: dmb i sh
  • MIPS64: syn c

Проблема этого подхода заключается в том, что я не смог найти никакой гарантии в правилах C ++, что std::atomic_thread_fence(std::memory_order_seq_cst) должен переводиться в полный барьер памяти. На самом деле, концепция atomic_thread_fence s в C ++, кажется, находится на другом уровне абстракции, чем концепция ассемблирования барьеров памяти, и имеет дело с такими вещами, как «то, что операция atomi c синхронизирует с чем». Есть ли какие-либо теоретические доказательства того, что приведенная ниже реализация достигает цели?

void thread_a(){
  set();
  std::atomic_thread_fence(std::memory_order_seq_cst)
  if(!y.load()) foo();
}
void thread_b(){
  y.store(true);
  std::atomic_thread_fence(std::memory_order_seq_cst)
  if(!check()) bar();
}

Опция B

Используйте контроль над Y, чтобы добиться синхронизации, используя read-modify -write memory_order_acq_rel Операции с Y:

void thread_a(){
  set();
  if(!y.fetch_add(0,std::memory_order_acq_rel)) foo();
}
void thread_b(){
  y.exchange(1,std::memory_order_acq_rel);
  if(!check()) bar();
}

Идея заключается в том, что доступ к одному атому c (y) должен формировать единый порядок, с которым согласны все наблюдатели, поэтому либо fetch_add предшествует exchange или наоборот.

Если fetch_add предшествует exchange, то часть «release» fetch_add синхронизируется с частью «acqu» в exchange и, таким образом, все побочные эффекты set() должны быть видимы для кода, выполняющего check(), поэтому bar() не будет вызываться.

В противном случае exchange предшествует fetch_add, тогда fetch_add увидит 1 и не звоните foo(). Таким образом, невозможно назвать и foo(), и bar(). Правильно ли это рассуждение?


Опция C

Использовать фиктивную атомарность, чтобы ввести «ребра», которые предотвращают катастрофу. Рассмотрим следующий подход:

void thread_a(){
  std::atomic<int> dummy1{};
  set();
  dummy1.store(13);
  if(!y.load()) foo();
}
void thread_b(){
  std::atomic<int> dummy2{};
  y.store(1);
  dummy2.load();
  if(!check()) bar();
}

Если вы думаете, что проблема здесь в том, что atomic s являются локальными, то представьте, что вы перемещаете их в глобальную область, в следующих рассуждениях это не кажется мне важным и я намеренно написал код таким образом, чтобы показать, как забавно, что dummy1 и dummy2 полностью отделены друг от друга.

Почему, черт возьми, это может сработать? Ну, должен быть какой-то один общий порядок {dummy1.store(13), y.load(), y.store(1), dummy2.load()}, который должен соответствовать программному порядку "ребер":

  • dummy1.store(13) "в TO перед" y.load()
  • y.store(1) "в ТО есть раньше" dummy2.load()

(хранилище seq_cst + загрузка, мы надеемся, образуют эквивалент C ++ полного барьера памяти, включая StoreLoad, как они делают в asm на реальных ISA включая даже AArch64, где не требуются отдельные инструкции по барьеру.)

Теперь у нас есть два случая, чтобы рассмотреть: либо y.store(1) до y.load(), либо после в общем порядке.

Если y.store(1) до y.load(), тогда foo() не будет вызван, и мы в безопасности.

Если y.load() до y.store(1), то объединяем его с двумя ребрами, которые у нас уже есть в программном порядке , мы выводим, что:

  • dummy1.store(13) "в TO до" dummy2.load()

Теперь dummy1.store(13) - это операция освобождения, которая освобождает эффекты set(), а dummy2.load() является операцией получения, поэтому check() должен видеть эффекты set() и, следовательно, bar() wi Меня не вызовут, и мы в безопасности.

Правильно ли здесь думать, что check() увидит результаты set()? Могу ли я так комбинировать "ребра" разных видов ("программный порядок" или "Последовательный до", "общий порядок", "до выпуска", "после приобретения")? У меня есть серьезные сомнения по этому поводу: Кажется, правила C ++ говорят об отношениях «синхронизирует с» между хранилищем и загрузкой в ​​одном и том же месте - здесь такой ситуации нет.

Обратите внимание, что нас беспокоит только случай, когда dumm1.store равен известно (по другим причинам) до dummy2.load в общем порядке seq_cst. Поэтому, если бы они обращались к одной и той же переменной, загрузка увидела бы сохраненное значение и синхронизировалась бы с ним.

(Рассуждение о барьере памяти / переупорядочении для реализаций, где atomi c загружается и хранится, компилируется в Минимальные односторонние барьеры памяти (и операции seq_cst не могут переупорядочиваться: например, хранилище seq_cst не может передать нагрузку seq_cst) состоит в том, что любые загрузки / сохранения после dummy2.load определенно становятся видимыми для других потоков после y.store. И аналогично для другого потока, ... до y.load.)


Вы можете поиграть с моей реализацией опций A, B, C на https://godbolt.org/z/u3dTa8

Ответы [ 4 ]

5 голосов
/ 04 февраля 2020

Опции A и B. являются допустимыми решениями.

  • Вариант A: на самом деле не имеет значения, на что переводится ограждение seq-cst, стандарт C ++ четко определяет, какие гарантии он предоставляет. Я изложил их в этом посте: Когда полезен забор memory_order_seq_cst?
  • Вариант B: да, ваши рассуждения верны. Все модификации какого-либо объекта имеют один общий порядок (порядок изменений), поэтому вы можете использовать его для синхронизации потоков и обеспечения видимости всех побочных эффектов.

Однако опция C не действует! Отношение синхронизации с может быть установлено только с помощью операций получения / освобождения для того же объекта . В вашем случае у вас есть два совершенно разных и независящих объекта dummy1 и dummy2. Но они не могут быть использованы для установления отношения «до того, как случится». Фактически, поскольку переменные atomi c являются чисто локальными (т. Е. Они только когда-либо затрагиваются одним потоком), компилятор может удалить их на основе правила «как будто» .

Обновление

Опция A:
Я полагаю, set() и check() действительно работают с некоторым значением атома c. Тогда мы имеем следующую ситуацию (-> обозначает секвенировано до ):

  • set() -> fence1(seq_cst) -> y.load()
  • y.store(true) -> fence2(seq_cst) -> check()

Таким образом, мы можем применить следующее правило:

Для атомов c Операции A и B на атоме c объект M , где A изменяет M и B занимает его значение, если имеются memory_order_seq_cst заборы X и Y , такие что A секвенируется до X , Y устанавливается до B , а X предшествует Y в S , затем B наблюдает либо эффекты A или более поздняя модификация M в порядке модификации.

Т.е. либо check() видит это значение, сохраненное в set, либо y.load() видит записанное значение y.store() (для операций y можно даже использовать memory_order_relaxed).

Опция C:
C ++ 17 стандарт Дард заявляет [32.4.3, p1347]:

Для всех операций memory_order_seq_cst должен быть единый общий заказ S , в соответствии с "случается раньше" порядок и порядок изменения для всех затронутых локаций [...]

Важное слово здесь "непротиворечивый". Это означает, что если операция A происходит - до операции B , то A должна предшествовать B в S . Однако логическое следствие является улицей с односторонним движением, поэтому мы не можем вывести обратное: просто потому, что какой-то операции C предшествует операции D в S не означает, что C происходит до D .

В частности, две последовательные операции над двумя отдельными объектами не могут использоваться для установления sh a происходит до отношения, даже если операции полностью упорядочены в S. Если вы хотите упорядочить операции над отдельными объектами, вы должны обратиться к seq-cst-fences (см. Опцию A).

1 голос
/ 13 февраля 2020

в стандарте ISO std :: mutex гарантированно имеет только порядок получения и выпуска, а не seq_cst.

Но ничто не гарантирует "seq_cst ordering", как seq_cst не является свойством какой-либо операции.

seq_cst является гарантией для всех операций данной реализации std::atomic или альтернативного класса atomi c. Таким образом, ваш вопрос не обоснован.

1 голос
/ 05 февраля 2020

@ mpoeter объяснил, почему параметры A и B. безопасны.

На практике на реальных реализациях, я думаю, для варианта A требуется только std::atomic_thread_fence(std::memory_order_seq_cst) в потоке A, а не B.

seq- Хранилища cst на практике включают в себя полный барьер памяти или, по крайней мере, на AArch64 не могут переупорядочить с более поздними операциями захвата или seq_cst (stlr sequential-release должен слить из буфера хранилища, прежде чем ldar сможет прочитать из кэша). 1008 *

C ++ -> сопоставления asm имеют возможность выбора затрат на опустошение буфера хранилища при загрузке атомов c или загрузок атома c. Разумным выбором для реальных реализаций является удешевление загрузки atomi c, поэтому магазины seq_cst включают полный барьер (включая StoreLoad). Несмотря на то, что seq_cst загружается так же, как и нагрузки на большинстве.

(Но не POWER; там даже нагрузкам нужен тяжелый syn c = полный барьер, чтобы остановить пересылку хранилища из других потоков SMT на том же ядре что может привести к переупорядочению IRIW, потому что seq_cst требует, чтобы все потоки были в состоянии согласовать порядок всех операций seq_cst. Будут ли две записи atomi c в разные места в разных потоках всегда рассматриваться в одном и том же порядке другими темы? )

(Конечно, для формальной гарантии безопасности нам нужен забор в обоих случаях, чтобы продвигать набор / выпуск set () -> check () в seq_cst синхронизирует-с. Я бы также работал для расслабленного набора, но расслабленная проверка могла бы изменить порядок с баром из POV других потоков.)


Я думаю, что настоящая проблема с Опция C заключается в том, что от некоторого гипотетического наблюдателя зависит, может ли синхронизироваться с y и фиктивными операциями. И поэтому мы ожидаем, что компилятор сохраняйте этот порядок при создании asm для ISA на основе барьера.

Это будет верно на практике на реальных ISA; оба потока включают полный барьер или эквивалент, а компиляторы (пока) не оптимизируют атомику. Но, конечно, «компиляция в ISA на основе барьера» не является частью стандарта ISO C ++. Когерентный общий кэш - это гипотетический наблюдатель, который существует для рассуждений asm, но не для рассуждений ISO C ++.

Для работы опции C нам нужен порядок, подобный dummy1.store(13); / y.load() / set(); (как видно из потока B) нарушить какое-то правило ISO C ++ .

Поток, выполняющий эти операторы, должен вести себя так, как если бы set() выполняется первым (из-за последовательности перед). Это нормально, упорядочение памяти во время выполнения и / или переупорядочение операций во время компиляции все еще может это сделать.

Два seq_cst ops d1=13 и y соответствуют Sequenced Before (программный порядок). set() не участвует в обязательном существующем глобальном порядке для seq_cst ops, потому что он не seq_cst.

Поток B не синхронизируется с dummy1.store , поэтому требование "не происходит до" на set относительно d1=13 применяется , даже если это назначение является операцией освобождения.

Я не вижу других возможных нарушений правил; Я не могу найти здесь ничего, что требуется для согласования с set Sequenced-Before d1=13.

Аргументация "dummy1.store Release set ()" является недостатком. Этот порядок применяется только к реальному наблюдателю, который синхронизируется с ним или в asm. Как ответил @mpoeter, существование общего порядка seq_cst не создает и не подразумевает отношения «происходит до», и это единственное, что формально гарантирует порядок за пределами seq_cst.

Любой тип "нормального" ЦП с согласованным общим кешем, где это переупорядочение действительно может произойти во время выполнения, не представляется правдоподобным. (Но если компилятор может удалить dummy1 и dummy2, тогда, очевидно, у нас возникнет проблема, и я думаю, что это разрешено стандартом.)

Но поскольку модель памяти C ++ не определена в С точки зрения буфера хранилища, общего связного кэша или лакмусовых тестов разрешенного переупорядочения, вещи, требуемые здравомыслием, формально не требуются правилами C ++. Возможно, это сделано специально для того, чтобы позволить оптимизировать даже переменные seq_cst, которые оказываются частными. (Текущие компиляторы, конечно, не делают этого или любой другой оптимизации объектов atomi c.)

Реализация, в которой один поток действительно мог видеть set() последним, а другой мог видеть set() первым звучит неправдоподобно. Даже СИЛА не могла этого сделать; и seq_cst load и store включают в себя полные барьеры для POWER. (Я предложил в комментариях, что переупорядочение IRIW может быть уместным здесь; правила acq / rel в C ++ достаточно слабы, чтобы приспособиться к этому, но полное отсутствие гарантий вне ситуаций синхронизации с другими ситуациями и до того, как это происходит, намного слабее, чем у любого HW. )

C ++ не гарантирует ничего для non-seq_cst, если только на самом деле не является наблюдателем, и тогда только для этого наблюдателя. Без него мы находимся в Шредингере кошачья территория. Или, если два дерева упали в лесу, одно упало раньше другого? (Если это большой лес, общая теория относительности говорит, что это зависит от наблюдателя и что универсальной концепции одновременности не существует.)


@ mpoeter предположил, что компилятор может даже удалить фиктивную загрузку и сохранить операции, даже на seq_cst объектах.

Я думаю, что это может быть правильно, когда они могут доказать, что ничто не может синхронизироваться с операцией. например, компилятор, который может видеть, что dummy2 не выходит за пределы функции, может, вероятно, удалить эту загрузку seq_cst.

Это имеет по крайней мере одно реальное следствие: если компилируется для AArch64, это позволило бы более раннее seq_cst хранить, чтобы переупорядочить на практике с более поздними расслабленными операциями, что было бы невозможно при seq_cst store + load, освобождающей буфер хранилища до того, как могли быть выполнены любые более поздние загрузки.

Конечно, современные компиляторы не оптимизируют атомарные операции все, хотя ISO C ++ не запрещает этого; это нерешенная проблема для комитета по стандартам.

Это разрешено, я думаю, потому что модель памяти C ++ не имеет неявного наблюдателя или требования, чтобы все потоки согласовывали порядок. Он предоставляет некоторые гарантии, основанные на связном кэше, но не требует одновременной видимости для всех потоков.

1 голос
/ 05 февраля 2020

В первом примере y.load() чтение 0 не означает, что y.load() происходит раньше y.store(1).

Это означает, однако, что оно раньше в едином общем порядке благодаря правилу, что загрузка seq_cst возвращает либо значение последнего хранилища seq_cst в общем заказе, либо значение некоторого не-seq_cst хранилища, которого не было до этого (которого в этом случае не существует). Таким образом, если бы y.store(1) было раньше, чем y.load() в общем заказе, y.load() вернул бы 1.

Доказательство все еще верно, потому что в одном общем заказе нет цикла.

Как насчет этого решения?

std::atomic<int> x2{0},y{0};

void thread_a(){
  set();
  x2.store(1);
  if(!y.load()) foo();
}

void thread_b(){
  y.store(1);
  if(!x2.load()) bar();
}
...