В последнее время, чем больше я читаю о порядке памяти в C++, тем больше он запутывается. Надеюсь, вы поможете мне прояснить это (в чисто теоретических целях). Предположим, у меня есть следующий код:
std::atomic<int> val = { 0 };
std::atomic<bool> f1 = { false };
std::atomic<bool> f2 = { false };
void thread_1() {
f1.store(true, std::memory_order_relaxed);
int v = 0;
while (!val.compare_exchange_weak(v, v | 1,
std::memory_order_release));
}
void thread_2() {
f2.store(true, std::memory_order_relaxed);
int v = 0;
while (!val.compare_exchange_weak(v, v | 2,
std::memory_order_release));
}
void thread_3() {
auto v = val.load(std::memory_order_acquire);
if (v & 1) assert(f1.load(std::memory_order_relaxed));
if (v & 2) assert(f2.load(std::memory_order_relaxed));
}
Возникает вопрос: может ли какое-либо из утверждений быть ложным? С одной стороны, cppreference утверждает, что std::memory_order_release
запрещает переупорядочивать оба хранилища после обмена в потоках 1-2, а std::memory_order_acquire
в потоке 3 запрещает переупорядочивать оба чтения до первой загрузки. Таким образом, если поток 3 увидел первый или второй установленный бит, это означает, что сохранение в соответствующее логическое значение уже произошло, и оно должно быть истинным.
С другой стороны, поток 3 синхронизируется с тем, кто освободил значение, полученное от val
. Может ли так случиться (в теории, если не на практике), что поток 3 "приобрел" обмен "1 -> 3" потоком 2 (и поэтому f2 load возвращает true), но не "0 -> 1" потоком 1 (таким образом, срабатывает первое утверждение)? Эта возможность не имеет для меня смысла, учитывая понимание «переупорядочения», но я не могу найти никаких подтверждений того, что это не может произойти нигде.
val.load
может возвращать только 0,1,2 или 3. С последующими проверками во время выполнения порядок f1
и f2
(относительно их магазинов) гарантирован.
Ни одно из утверждений никогда не может потерпеть неудачу благодаря правилам ISO C++ «последовательности выпуска». Это формализм, который обеспечивает гарантию, что вы предположили, что она должна существовать в вашем последнем абзаце.
Единственными сохранениями в val
являются выпускные хранилища с соответствующими установленными битами, выполняемые после соответствующего сохранения в f1
или f2
. Так что, если thread_3
видит значение с установленным 1 битом, оно определенно синхронизировано с записывающим устройством, которое установило соответствующую переменную.
И что особенно важно, каждая из них является частью RMW и, таким образом, формирует выпуск-последовательность, который позволяет загрузке в thread_3
синхронизироваться с записью CAS оба, если это произойдет val == 3
.
(Даже relaxed
RMW может быть частью последовательности релиза, хотя в этом случае не будет гарантии «происходит до» для вещей до ослабленного RMW, только для других операций релиза этим или другими потоками для этой атомарной переменной. Если бы thread_2
использовал mo_relaxed
, утверждение для f2
могло бы потерпеть неудачу, но оно все равно не могло бы сломать что-либо, поэтому утверждение для f1
могло бы когда-либо потерпеть неудачу. См. Также Что означает «последовательность выпуска»? и https://en.cppreference.com/w/cpp/atomic/memory_order)
Если это поможет, я считать, эти циклы CAS полностью эквивалентны val.fetch_or(1, release)
. Определенно именно так компилятор реализует fetch_or на машине с CAS, но не с атомарным примитивом ИЛИ. IIRC, в модели ISO C++, сбой CAS - это только нагрузка, а не RMW. Не то чтобы это имело значение; расслабленный RMW без операций все равно будет распространять последовательность выпуска.
(Забавный факт: x86 asm lock cmpxchg
всегда является реальным RMW, даже в случае неудачи, по крайней мере, на бумаге. Но это также полный барьер, поэтому в основном не имеет отношения к любым рассуждениям о слабо упорядоченных RMW.)
Я считаю, что утверждения не могут сработать по причинам, которые вы указали в первом абзаце.