Я хочу написать переносимый код (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.
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 сл-сСт + магазин буфер ж / экспедирование.)