Как достичь барьера StoreLoad в C ++ 11?

13

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

Initially: X=Y=0

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

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

Я осознаю, что могу достичь цели с помощью memory_order_seq_cstатомных stores и loads следующим образом:

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) "в то есть раньше" y.load()
  • y.store(1) "в то есть раньше" x.load()

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

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

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

  • x.load() «читает значение раньше» x.store(1)

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

x.store(1)«в ТО до» y.load()«читает значение до» y.store(1)«в ТО до» x.load()«читает значение до»x.store(true)

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

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


Теперь моя настоящая проблема немного сложнее, потому что я не могу контролировать «X» - он скрыт за некоторыми макросами, шаблонами и т. Д. И может быть слабее, чем 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процессе; в стандарте ISO std::mutexгарантируется только порядок получения и выпуска, но не seq_cst.

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

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

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


Вариант А

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

  • x86_64: MFENCE
  • PowerPC: hwsync
  • Итануим: мф
  • ARMv7 / ARMv8: dmb ish
  • MIPS64: синхронизация

Проблема с этим подходом состоит в том, что я не смог найти никакой гарантии в правилах C ++, которая std::atomic_thread_fence(std::memory_order_seq_cst)должна переводиться на полный барьер памяти. На самом деле, концепция atomic_thread_fences в 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();
}

Вариант Б

Используйте контроль над Y для достижения синхронизации, используя операции чтения-изменения-записи 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();
}

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

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

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


Вариант С

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

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();
}

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

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

  • dummy1.store(13) "в то есть раньше" y.load()
  • y.store(1) "в то есть раньше" dummy2.load()

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

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

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

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

  • dummy1.store(13) "в то есть раньше" dummy2.load()

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

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

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

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


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

qbolec
источник
1
Модель памяти C ++ не имеет никакой концепции переупорядочения StoreLoad, она только синхронизируется с и происходит раньше. (И UB на гонках данных на неатомарных объектах, в отличие от asm для реального оборудования.) Во всех реальных реализациях, о которых я знаю, std::atomic_thread_fence(std::memory_order_seq_cst)компилируется с полным барьером, но, поскольку вся концепция - это деталь реализации, вы не найдете любое упоминание об этом в стандарте. (Модель памяти процессора обычно являются определены в терминах того , что reorerings допускаются по отношению к последовательной последовательности , например х86 сл-сСт + магазин буфер ж / экспедирование.)
Питер Кордес
@PeterCordes спасибо, возможно, я не совсем понял в своем письме. Я хотел передать то, что вы написали в разделе «Вариант А». Я знаю, что в заголовке моего вопроса используется слово «StoreLoad», а «StoreLoad» - это понятие из совершенно другого мира. Моя проблема заключается в том, как отобразить эту концепцию в C ++. Или, если это не может быть отображено напрямую, то как достичь цели, которую я поставил: предотвратить foo()и то, и bar()другое вызвать.
Qbolec
1
Вы можете использовать compare_exchange_*для выполнения операции RMW на атомарном буле без изменения его значения (просто установите для ожидаемого и нового значения то же самое).
mpoeter
1
@Fareanor и qbolec: atomic<bool>имеет exchangeи compare_exchange_weak. Последний может быть использован для создания фиктивного RMW путем (попытки) CAS (true, true) или false, false. Он либо не работает, либо атомарно заменяет значение на себя. (В x86-64 asm этот трюк lock cmpxchg16bзаключается в том, как вы делаете гарантированно-атомные 16-байтовые загрузки; неэффективно, но менее вредно, чем отдельная блокировка.)
Питер Кордес
1
@PeterCordes да, я знаю, что может случиться так, что ни то, ни другое foo()не bar()будет вызвано . Я не хотел приводить ко многим элементам кода «реального мира», чтобы избежать ответов «вы думаете, что у вас проблема X, но у вас есть проблема Y». Но если действительно нужно знать, что является второстепенным этажом: set()действительно some_mutex_exit(), check()есть try_enter_some_mutex(), y«есть официанты», foo()это «выйти, не разбудив никого», bar()это «ждать пробуждения» ... Но я отказываюсь обсудить этот дизайн здесь - я не могу изменить его на самом деле.
Qbolec

Ответы:

5

Варианты A и B являются действительными решениями.

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

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

Обновить

Вариант А:
я предполагаю set()и работаю check()на некотором атомном значении. Тогда мы имеем следующую ситуацию (-> обозначает sequenced-before ):

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

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

Для атомарных операций A и B на атомарном объекте 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]:

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

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

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

mpoeter
источник
Не очевидно, что вариант C недействителен. Операции seq-cst даже с частными объектами могут в некоторой степени упорядочивать другие операции. Согласились, что нет синхронизаций, но нам все равно, какие из foo или bar запускаются (или, по-видимому, нет), просто они оба не работают. Я полагаю, что отношение секвенированных до и общий порядок операций seq-cst (которые должны существовать) дают нам это.
Питер Кордес
Спасибо, @mpoeter. Не могли бы вы уточнить вариант А. Какие из трех пунктов вашего ответа применимы здесь? IIUC, если y.load()не видит эффекта y.store(1), то мы можем доказать из правил, что в S, atomic_thread_fencethread_a предшествует atomic_thread_fencethread_b. Чего я не вижу, так это как сделать вывод, что set()побочные эффекты видны check().
Qbolec
1
@qbolec: я обновил свой ответ с более подробной информацией об опции A.
mpoeter
1
Да, локальная операция seq-cst все равно будет частью единого итогового порядка S для всех операций seq-cst. Но S «только» в соответствии с случается, перед заказом и модификации заказов , то есть, если происходит, прежде , чем В , то должно предшествовать B в S . Но обратное не гарантируется, то есть, только потому , что A предшествует B в S , мы можем не выводят , что происходит, прежде , чем B .
mpoeter
1
Ну, если предположить , что setи checkможет безопасно выполняться параллельно, я бы , вероятно , пойти с опцией А, особенно если это производительность критично, так как это позволяет избежать разногласий по общей переменной y.
mpoeter
1

В первом примере 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();
}
Томек Цайка
источник
Проблема OP в том, что я не контролирую «X» - он находится за макросами-обертками или чем-то еще и может не быть seq-cst store / load. Я обновил вопрос, чтобы подчеркнуть это лучше.
Питер Кордес
@PeterCordes Идея заключалась в том, чтобы создать еще один «х», который он контролирует. Я переименую его в «x2» в моем ответе, чтобы сделать его более понятным. Я уверен, что мне не хватает некоторых требований, но если единственное требование - убедиться, что foo () и bar () не вызываются, то это удовлетворяет этому.
Томек Цайка
Так что, if(false) foo();но я думаю, что OP тоже этого не хочет: P Интересный момент, но я думаю, что OP хочет, чтобы условные вызовы основывались на заданных ими условиях!
Питер Кордес
1
Привет @TomekCzajka, спасибо, что нашли время, чтобы предложить новое решение. Это не сработает в моем конкретном случае, поскольку в нем отсутствуют важные побочные эффекты check()(см. Мой комментарий к моему вопросу о реальном значении set,check,foo,bar). Я думаю, что это могло бы работать if(!x2.load()){ if(check())x2.store(0); else bar(); }вместо этого.
Qbolec
1

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

Питер Кордес
источник
Хорошее резюме! Я согласен, что на практике , вероятно, было бы достаточно, если бы только поток A имел ограждение seq-cst. Однако, основываясь на стандарте C ++, у нас не было бы необходимой гарантии того, что мы увидим последнее значение set(), поэтому я все равно буду использовать забор в потоке B. Я полагаю, что расслабленный магазин с забором seq-cst сгенерирует почти тот же код, что и seq-cst-store.
mpoeter
@mpoeter: да, я говорил только на практике, а не формально. Добавлено примечание в конце этого раздела. И да, на практике на большинстве ISA я думаю, что хранилище seq_cst обычно просто хранилище (расслаблено) + барьер. Или нет; на POWER секвенциальный магазин делает (тяжелый) sync до магазина, ничего после. godbolt.org/z/mAr72P Но для последовательной загрузки нужны некоторые барьеры с обеих сторон.
Питер Кордес
1

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

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

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

curiousguy
источник