Я хочу написать переносимый код (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
атомных 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)
"в то есть раньше"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_fence
s в 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();
}
Если вы думаете, что проблема здесь atomic
s локальная, то представьте, что вы перемещаете их в глобальную область видимости, в следующих рассуждениях это не имеет значения для меня, и я намеренно написал код таким образом, чтобы показать, как это смешно, что 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.
std::atomic_thread_fence(std::memory_order_seq_cst)
компилируется с полным барьером, но, поскольку вся концепция - это деталь реализации, вы не найдете любое упоминание об этом в стандарте. (Модель памяти процессора обычно являются определены в терминах того , что reorerings допускаются по отношению к последовательной последовательности , например х86 сл-сСт + магазин буфер ж / экспедирование.)foo()
и то, иbar()
другое вызвать.compare_exchange_*
для выполнения операции RMW на атомарном буле без изменения его значения (просто установите для ожидаемого и нового значения то же самое).atomic<bool>
имеетexchange
иcompare_exchange_weak
. Последний может быть использован для создания фиктивного RMW путем (попытки) CAS (true, true) или false, false. Он либо не работает, либо атомарно заменяет значение на себя. (В x86-64 asm этот трюкlock cmpxchg16b
заключается в том, как вы делаете гарантированно-атомные 16-байтовые загрузки; неэффективно, но менее вредно, чем отдельная блокировка.)foo()
неbar()
будет вызвано . Я не хотел приводить ко многим элементам кода «реального мира», чтобы избежать ответов «вы думаете, что у вас проблема X, но у вас есть проблема Y». Но если действительно нужно знать, что является второстепенным этажом:set()
действительноsome_mutex_exit()
,check()
естьtry_enter_some_mutex()
,y
«есть официанты»,foo()
это «выйти, не разбудив никого»,bar()
это «ждать пробуждения» ... Но я отказываюсь обсудить этот дизайн здесь - я не могу изменить его на самом деле.Ответы:
Варианты A и B являются действительными решениями.
Однако, вариант C является не действительным! Отношение синхронизации с может быть установлено только с помощью операций получения / выпуска с одним и тем же объектом . В вашем случае у вас есть два совершенно разных и независящих объекта
dummy1
иdummy2
. Но они не могут быть использованы для установления отношения «до того как случится». На самом деле, поскольку атомарные переменные являются чисто локальными (т. Е. Их когда-либо затрагивает только один поток), компилятор может удалить их на основе правила «как будто» .Обновить
Вариант А:
я предполагаю
set()
и работаюcheck()
на некотором атомном значении. Тогда мы имеем следующую ситуацию (-> обозначает sequenced-before ):set()
->fence1(seq_cst)
->y.load()
y.store(true)
->fence2(seq_cst)
->check()
Таким образом, мы можем применить следующее правило:
То есть, либо
check()
видит, что значение хранится вset
, либоy.load()
видит записанное значение бытьy.store()
(операцииy
могут даже использоватьmemory_order_relaxed
).Вариант C:
Стандарт C ++ 17 утверждает [32.4.3, p1347]:
Важное слово здесь - «последовательный». Это означает , что если операция происходит, перед операцией Б , то должно предшествовать B в S . Однако, логический вывод является односторонний улицей, поэтому мы не можем делать вывод обратным: только потому , что некоторые операции C предшествует операцию D в S не означают , что C происходит до D .
В частности, две операции seq-cst над двумя отдельными объектами не могут быть использованы для установления отношения «случается раньше», даже если операции полностью упорядочены в S. Если вы хотите упорядочить операции над отдельными объектами, вы должны обратиться к seq-cst -заборы (см. вариант А).
источник
y.load()
не видит эффектаy.store(1)
, то мы можем доказать из правил, что в S,atomic_thread_fence
thread_a предшествуетatomic_thread_fence
thread_b. Чего я не вижу, так это как сделать вывод, чтоset()
побочные эффекты видныcheck()
.set
иcheck
может безопасно выполняться параллельно, я бы , вероятно , пойти с опцией А, особенно если это производительность критично, так как это позволяет избежать разногласий по общей переменнойy
.В первом примере
y.load()
чтение 0 не означает, чтоy.load()
происходит раньшеy.store(1)
.Это, однако, подразумевает, что это раньше в едином общем порядке благодаря правилу, согласно которому загрузка seq_cst возвращает либо значение последнего хранилища seq_cst в общем порядке, либо значение некоторого не-seq_cst хранилища, которого не было раньше это (которого в этом случае не существует). Так что если бы
y.store(1)
было раньше, чемy.load()
в общем заказе,y.load()
вернул бы 1.Доказательство все еще верно, потому что в одном общем заказе нет цикла.
Как насчет этого решения?
источник
if(false) foo();
но я думаю, что OP тоже этого не хочет: P Интересный момент, но я думаю, что OP хочет, чтобы условные вызовы основывались на заданных ими условиях!check()
(см. Мой комментарий к моему вопросу о реальном значенииset,check,foo,bar
). Я думаю, что это могло бы работатьif(!x2.load()){ if(check())x2.store(0); else bar(); }
вместо этого.@mpoeter объяснил, почему варианты A и B безопасны.
На практике для реальных реализаций я думаю, что Вариант A нуждается только
std::atomic_thread_fence(std::memory_order_seq_cst)
в потоке A, а не в B.Хранилища seq-cst на практике включают в себя полный барьер памяти или, по крайней мере, на AArch64 не могут переупорядочить с последующей загрузкой или загрузкой seq_cst (
stlr
sequential-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
применениям не возникает , даже если это назначение является операцией освобождения.Я не вижу других возможных нарушений правил; Я не могу найти здесь ничего, что требуется для соответствия с
set
Sequenced-Befored1=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 ++ не имеет неявного наблюдателя или требования, чтобы все потоки согласовывали порядок. Он предоставляет некоторые гарантии, основанные на связных кешах, но не требует, чтобы все потоки были видны одновременно.
источник
set()
, поэтому я все равно буду использовать забор в потоке B. Я полагаю, что расслабленный магазин с забором seq-cst сгенерирует почти тот же код, что и seq-cst-store.sync
до магазина, ничего после. godbolt.org/z/mAr72P Но для последовательной загрузки нужны некоторые барьеры с обеих сторон.Но ничто не гарантирует "seq_cst ordering", так как
seq_cst
не является свойством какой-либо операции.seq_cst
является гарантией на все операции данной реализацииstd::atomic
или альтернативного атомарного класса. Таким образом, ваш вопрос необоснован.источник