Как достичь барьера 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.


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 -заборы (см. вариант А).


Не очевидно, что вариант 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или альтернативного атомарного класса. Таким образом, ваш вопрос необоснован.

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.