Подтвердить что ты не робот

Каковы гарантии упорядочения памяти С++ 11 в этом случае?

Я пишу какой-то незакрепленный код, и у меня появился интересный образец, но я не уверен, будет ли он вести себя так, как ожидалось, при упорядоченном упорядочении памяти.

Самый простой способ объяснить это - пример:

std::atomic<int> a, b, c;

auto a_local = a.load(std::memory_order_relaxed);
auto b_local = b.load(std::memory_order_relaxed);
if (a_local < b_local) {
    auto c_local = c.fetch_add(1, std::memory_order_relaxed);
}

Обратите внимание, что во всех операциях используется std::memory_order_relaxed.

Очевидно, что в потоке, который выполняется, нагрузки для a и b должны выполняться до того, как будет оценено условие if.

Аналогично, операция чтения-изменения-записи (RMW) в c должна выполняться после того, как условие будет оценено (поскольку оно обусловлено этим условием...).

Что я хочу знать, этот код гарантирует, что значение c_local не менее актуально, чем значения a_local и b_local? Если да, то как это возможно, учитывая расслабленный порядок памяти? Является ли зависимость управления вместе с операцией RWM действующей как своего рода забор? (Обратите внимание, что там даже не существует соответствующего релиза.)

Если это верно, я считаю, что этот пример также должен работать (при условии, что он не переполняется) - я прав?

std::atomic<int> a(0), b(0);

// Thread 1
while (true) {
    auto a_local = a.fetch_add(1, std::memory_order_relaxed);
    if (a_local >= 0) {    // Always true at runtime
        b.fetch_add(1, std::memory_order_relaxed);
    }
}

// Thread 2
auto b_local = b.load(std::memory_order_relaxed);
if (b_local < 777) {
    // Note that fetch_add returns the pre-incrementation value
    auto a_local = a.fetch_add(1, std::memory_order_relaxed);
    assert(b_local <= a_local);    // Is this guaranteed?
}

В потоке 1 есть зависимость управления, которая, как я подозреваю, гарантирует, что a всегда увеличивается до того, как b будет увеличиваться (но каждый из них будет увеличивать шейку и шею). В потоке 2 существует другая управляющая зависимость, которая, как я подозреваю, гарантирует, что b загружается в b_local до того, как a будет увеличено. Я также думаю, что значение, возвращаемое из fetch_add, будет, по крайней мере, столь же новым, как любое наблюдаемое значение в b_local, и поэтому assert должно сохраняться. Но я не уверен, так как это значительно отличается от обычных примеров упорядочения памяти, и мое понимание модели памяти С++ 11 не является совершенным (у меня есть проблемы с рассуждением об этих эффектах упорядочения памяти с какой-либо степенью определенности). Любые идеи будут оценены!


Обновление. Как было сказано в комментариях к bames53, учитывая достаточно умный компилятор, возможно, что if может быть полностью оптимизирован при правильных обстоятельствах, и в этом случае расслабленная нагрузки могут быть переупорядочены после RMW, в результате чего их значения будут более актуальными, чем возвращаемое значение fetch_add (assert может срабатывать в моем втором примере). Однако, что, если вместо if вставлен a atomic_signal_fence (not atomic_thread_fence)? Это, конечно, не может быть проигнорировано компилятором независимо от того, какие оптимизации сделаны, но гарантирует ли он, что код ведет себя так, как ожидалось? Разрешено ли в этом случае процессору делать переупорядочивание?

Затем второй пример выглядит следующим образом:

std::atomic<int> a(0), b(0);

// Thread 1
while (true) {
    auto a_local = a.fetch_add(1, std::memory_order_relaxed);
    std::atomic_signal_fence(std::memory_order_acq_rel);
    b.fetch_add(1, std::memory_order_relaxed);
}

// Thread 2
auto b_local = b.load(std::memory_order_relaxed);
std::atomic_signal_fence(std::memory_order_acq_rel);
// Note that fetch_add returns the pre-incrementation value
auto a_local = a.fetch_add(1, std::memory_order_relaxed);
assert(b_local <= a_local);    // Is this guaranteed?

Еще одно обновление. После прочтения всех ответов до сих пор и расчёта по стандарту самостоятельно, я не думаю, что можно показать, что код правильный, используя только стандарт. Итак, может ли кто-нибудь придумать встречный пример теоретической системы, которая соответствует стандарту, а также срабатывает утверждение?

4b9b3361

Ответ 1

В этом примере рассматривается вариант поведения чтения-от-тонкого воздуха. Соответствующее обсуждение в спецификации приведено в разделе 29.3p9-11. Поскольку текущая версия стандарта C11 не гарантирует соответствия зависимостей, модель памяти должна позволять обнулять утверждение. Наиболее вероятная ситуация заключается в том, что компилятор оптимизирует проверку, что a_local >= 0. Но даже если вы замените эту проверку сигнальным заграждением, процессоры могут свободно изменять порядок этих инструкций. Вы можете протестировать такие примеры кода в моделях памяти C/С++ 11 с помощью инструмента CDSChecker с открытым исходным кодом. Интересная проблема с вашим примером заключается в том, что для выполнения, чтобы нарушить утверждение, должен быть цикл зависимостей. Более конкретно:

b.fetch_add в потоке зависит от a.fetch_add в той же итерации цикла из-за условия if. A.fetch_add в потоке 2 зависит от b.load. Для нарушения утверждения мы должны иметь T2 b.load, прочитанный из b.fetch_add в более поздней итерации цикла, чем T2 a.fetch_add. Теперь рассмотрим b.fetch_add, который читает b.load, и назовите его # для дальнейшего использования. Мы знаем, что b.load зависит от #, так как он принимает значение из #.

Мы знаем, что # должен зависеть от T2 a.fetch_add, поскольку T2 a.fetch_add атомарно считывает и обновляет предыдущий файл a.fetch_add из T1 в той же итерации цикла, что и #. Таким образом, мы знаем, что # зависит от a.fetch_add в потоке 2. Это дает нам цикл в зависимостях и является довольно странным, но допускается моделью памяти C/С++. Наиболее вероятным способом создания этого цикла является (1) компилятор, который показывает, что a.local всегда больше 0, нарушая зависимость. Затем он может выполнять разворот цикла и переупорядочивать T1 fetch_add, но он хочет.

Ответ 2

Сигнальные ограждения не обеспечивают необходимых гарантий (ну, если только "поток 2" не является сигнальным устройством, которое фактически работает на "потоке 1" ).

Чтобы гарантировать правильное поведение, нам нужна синхронизация между потоками и забор, который делает это std::atomic_thread_fence.


Обозначим эти утверждения так, чтобы мы могли диаграммы различных исполнений (с помощью затворов резьбы, заменяющих сигнальные заграждения, если требуется):

while (true) {
    auto a_local = a.fetch_add(1, std::memory_order_relaxed); // A
    std::atomic_thread_fence(std::memory_order_acq_rel);      // B
    b.fetch_add(1, std::memory_order_relaxed);                // C
}


auto b_local = b.load(std::memory_order_relaxed);             // X
std::atomic_thread_fence(std::memory_order_acq_rel);          // Y
auto a_local = a.fetch_add(1, std::memory_order_relaxed);     // Z


Итак, сначала допустим, что X загружает значение, написанное C. В следующем параграфе указывается, что в этом случае устанавливаются заграждения и происходит связь между событиями и событиями.

29.8/2:

Заблокировочный затвор A синхронизируется с приобретающим ограждением B, если существуют атомарные операции X и Y, работающие на каком-либо атомарном объекте M, так что A секвенируется до X, X изменяет M, Y секвенируется до B и Y считывает значение, записанное X, или значение, записанное любым побочным эффектом в гипотетической последовательности X освобождения, если бы это была операция деблокирования.

А вот возможный порядок выполнения, где происходят стрелки - перед отношениями.

Thread 1: A₁ → B₁ → C₁ → A₂ → B₂ → C₂ → ...
                ↘
Thread 2:    X → Y → Z

Если побочный эффект X на атомном объекте M происходит до вычисления B значений M, тогда оценка B должна принимать свое значение от X или от побочного эффекта Y, следующего за X, в порядке модификации M. — [С++ 11 1.10/18]

Таким образом, нагрузка на Z должна принимать свое значение от A₁ или от последующей модификации. Следовательно, утверждение выполняется потому, что значение, записанное в A₁ и во всех последующих модификациях, больше или равно значению, записанному в C₁ (и читается X).


Теперь посмотрим на случай, когда ограждения не синхронизируются. Это происходит, когда нагрузка b не загружает значение, записанное потоком 1, но вместо этого считывает значение, которое инициализируется b. Там все еще синхронизация, где начинается нить:

30.3.1.2/5

Синхронизация: завершение вызова конструктора синхронизируется с началом вызова копии f.

Это указывает поведение конструктора std::thread. Таким образом (при условии, что создание потока корректно упорядочивается после инициализации a), значение, считываемое Z, должно принимать свое значение от инициализации a или от одной из последующих модификаций в потоке 1, что означает, что утверждения все еще сохраняются имеет место.