Я хочу написать переносимый код (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.
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()это «ждать пробуждения» ... Но я отказываюсь обсудить этот дизайн здесь - я не могу изменить его на самом деле.
std::atomic_thread_fence(std::memory_order_seq_cst)компилируется с полным барьером, но, поскольку вся концепция - это деталь реализации, вы не найдете любое упоминание об этом в стандарте. (Модель памяти процессора обычно являются определены в терминах того , что reorerings допускаются по отношению к последовательной последовательности , например х86 сл-сСт + магазин буфер ж / экспедирование.)