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

Стандарт С++: могут ли быть восстановлены атомные хранилища выше блокировки мьютекса?

Есть ли какая-либо формулировка в стандарте, которая гарантирует, что расслабленные магазины для атомистики не будут подняты выше блокировки мьютекса? Если нет, есть ли какая-либо формулировка, в которой явно сказано, что это кошерно для компилятора или процессора?

Например, возьмите следующую программу:

std::mutex mu;
int foo = 0;  // Guarded by mu
std::atomic<bool> foo_has_been_set{false};

void SetFoo() {
  mu.lock();
  foo = 1;
  foo_has_been_set.store(true, std::memory_order_relaxed);
  mu.unlock();
}

void CheckFoo() {
  if (foo_has_been_set.load(std::memory_order_relaxed)) {
    mu.lock();
    assert(foo == 1);
    mu.unlock();
  }
}

Возможно ли, что в CheckFoo произойдет сбой в вышеуказанной программе, если другой поток вызывает SetFoo одновременно или есть какая-то гарантия того, что хранилище до foo_has_been_set не может быть поднято над вызовом mu.lock компилятором и процессором?

Это связано с старым вопросом, но мне это не на 100% понятно, что ответ на это применим. В частности, встречный пример в ответе на вопрос может применяться к двум одновременным вызовам SetFoo, но меня интересует случай, когда компилятор знает, что есть один вызов SetFoo и один вызов CheckFoo, Гарантировано ли это безопасно?

Я ищу конкретные цитаты в стандарте. Спасибо!

4b9b3361

Ответ 1

Я думаю, что я разобрался с конкретными краями частичного заказа, которые гарантируют программа не может сбой. В ответе ниже я ссылаюсь на версия N4659 проекта стандарта.

Код, используемый для потока записи A и потока B читателя:

A1: mu.lock()
A2: foo = 1
A3: foo_has_been_set.store(relaxed)
A4: mu.unlock()

B1: foo_has_been_set.load(relaxed) <-- (stop if false)
B2: mu.lock()
B3: assert(foo == 1)
B4: mu.unlock()

Ищем доказательство того, что если B3 выполняется, то A2 происходит до B3, как определено в [intro.races]/10. [intro.races]/10.2, достаточно доказать, что A2 межпотоковая передача до B3.

Поскольку операции блокировки и разблокировки на данном мьютексе происходят в одной общей сумме ([thread.mutex.requirements.mutex]/5), мы должны иметь либо A1, либо B2 прибытие первый. Два случая:

  • Предположим, что A1 происходит до B2. Затем [thread.mutex.class]/1 и [thread.mutex.requirements.mutex]/25, мы знаем, что A4 будет синхронизироваться с B2. Поэтому [intro.races]/9.1, межпотоковая передача A4 происходит до B2. Поскольку B2 является упорядоченно до B3, [intro.races]/9.3.1, мы знаем, что A4-поток происходит до B3. Поскольку A2 секвенирован до A4, [intro.races]/9.3.2, A2 inter-thread происходит до B3.

  • Предположим, что B2 происходит до A1. Тогда по той же логике, что и выше, мы знаем что B4 синхронизируется с A1. Так как A1 секвенирован до A3, [intro.races]/9.3.1, B4 inter-thread происходит до A3. Поэтому, поскольку B1 секвентирован до B4, [intro.races]/9.3.2, межсетевой B1 происходит до A3. Поэтому [intro.races]/10.2, B1 происходит до A3. Но тогда, согласно [intro.races]/16, B1 должен принять свое значение из состояния pre-A3. Поэтому загрузка вернет false, а B2 никогда не будет работать в первую очередь. Другими словами, этого случая не может быть.

Итак, если B3 выполняется вообще (случай 1), A2 происходит до B3, и утверждение будет проходить. ∎

Ответ 2

Операция с памятью внутри защищенной области, помеченной мьютексом, не может "убежать" из этой области. Это относится ко всем операциям памяти, атомарным и неатомным.

В разделе 1.10.1:

вызов, который получает мьютекс, будет выполнять операцию получения в местах, содержащих мьютекс Соответственно, вызов, который освобождает один и тот же мьютекс, будет выполнять операцию освобождения в тех же местах

Кроме того, в разделе 1.10.1.6:

Все операции над данным мьютексом происходят в одном полном порядке. Каждое обнаружение мьютекса "считывает значение, записанное" последним релизом мьютекса.

И в 30.4.3.1

Объект mutex обеспечивает защиту от расчётов данных и позволяет безопасную синхронизацию данных между исполнительными агентами

Это означает, что получение (блокировка) мьютекса устанавливает односторонний барьер, который предотвращает операции, которые будут упорядочены после того, как объект (внутри защищенной области) будет перемещаться по блокировке мьютекса.

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

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

В вашем примере foo_has_been_set отмечен в CheckFoo. Если он читает true, вы знаете, что значение 1 было назначено на foo на SetFoo, но оно еще не синхронизировано. После этого блокировка мьютекса получит foo, синхронизация будет завершена, и assert не сможет запустить.

Ответ 3

Ответ, кажется, лежит в http://eel.is/c++draft/intro.multithread#intro.races-3

Две подходящие части

[...] Кроме того, существуют расслабленные атомные операции, которые не являются операциями синхронизации [...]

и

[...], выполняющий операцию деблокирования на A, принудительно предшествует побочным эффектам в других ячейках памяти, чтобы стать видимыми для других потоков, которые позже выполняют операцию потребления или получения на A. [...]

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

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

Изменить: Входы, я связан с черновиком. Параграф С++ 11, охватывающий это, составляет 1.10-5, используя тот же язык.

Ответ 4

CheckFoo() не может привести к сбою программы (т.е. вызвать assert()), но также не гарантируется выполнение assert().

Если условие в начале триггеров CheckFoo() (см. ниже), видимое значение foo будет равно 1 из-за барьеров памяти и синхронизации между mu.unlock() в SetFoo() и mu.lock() в CheckFoo().

Я считаю, что это описано в описании мьютекса, приведенном в других ответах.

Однако нет гарантии, что условие if (foo_has_been_set.load(std::memory_order_relaxed))) будет когда-либо истинным. Расслабленный порядок памяти не дает никаких гарантий и гарантируется только атомарность операции. Следовательно, при отсутствии какого-либо другого барьера нет гарантии, что в SetFoo() будет наблюдаться ослабленное хранилище в SetFoo(), но если оно видно, это произойдет только потому, что хранилище было выполнено, а затем после mu.lock() должно быть заказано после mu.unlock() и записи до его видимости.

Обратите внимание, что этот аргумент основан на том факте, что foo_has_been_set устанавливается только от false до true. Если бы была другая функция с именем UnsetFoo(), которая вернула бы ее значение false:

void SetFoo() {
  mu.lock();
  foo = 0;
  foo_has_been_set.store(false, std::memory_order_relaxed);
  mu.unlock();
}

Это вызвано из другого (или еще третьего) потока, тогда нет гарантии, что проверка foo_has_been_set без синхронизации гарантирует, что будет установлен foo.

Чтобы быть ясным (и предполагая, что foo_has_been_set никогда не отменяется):

void CheckFoo() {
  if (foo_has_been_set.load(std::memory_order_relaxed)) {
    assert(foo == 1); //<- All bets are off.
    mu.lock();
    assert(foo == 1); //Guaranteed to succeed.
    mu.unlock();
  }
}

На практике на любой реальной платформе в любом длинном приложении, вероятно, неизбежно, что магазин relax в конечном итоге станет видимым для другого потока. Но нет никаких официальных гарантий относительно того, когда или когда это произойдет, если для обеспечения этого не существуют другие барьеры.

Формальные ссылки:

http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3690.pdf

Обратитесь к примечаниям в конце с .13 и началу п. 14, особенно примечаниям 17-20. Они по существу обеспечивают согласованность "расслабленных" операций. Их видимость ослаблена, но видимость, которая происходит, будет последовательной, а использование фразы "происходит до" находится в рамках общего принципа упорядочивания программ и, в частности, для обнаружения и устранения барьеров мьютексов. Примечание 19 особенно актуально:

Четыре предшествующих требования когерентности эффективно запрещают компилятор переупорядочения атомных операций для одного объекта, даже если обе операции - это расслабленные нагрузки. Это эффективно делает кэш гарантированность когерентности, предоставляемая большинством аппаратных средств, доступных для атома С++ операции.

Ответ 5

Это упорядочение возможно:

void SetFoo() {
  mu.lock();
  // REORDERED:
  foo_has_been_set.store(true, std::memory_order_relaxed);
  PAUSE(); //imagine scheduler pause here 
  foo = 1;
  mu.unlock();
}

Теперь вопрос: CheckFoo - может ли чтение foo_has_been_set попасть в замок? Обычно такое чтение может (вещи могут попадать в блокировки, а не на выходе), но блокировка никогда не должна приниматься, если значение if ложно, поэтому это будет странный порядок. Что-нибудь говорит, что "спекулятивные блокировки" не разрешены? Или CPU может предположить, что if является истинным до чтения foo_has_been_set?

void CheckFoo() {
    // REORDER???
    mu.lock();
    if (foo_has_been_set.load(std::memory_order_relaxed)) {
        assert(foo == 1);
    }
    mu.unlock();
}

Это упорядочение, вероятно, не ОК, а только из-за "логического порядка", а не порядка памяти. Если mu.lock() был встроен (и стал некоторым атомарным ops), что мешает им переупорядочиваться?

Я не слишком беспокоюсь о вашем текущем коде, но я беспокоюсь о любом реальном коде, который использует что-то вроде этого. Это слишком близко к неправильному.

т.е. если код OP был реальным кодом, вы просто изменили бы foo на атомный и избавились бы от остальных. Таким образом, реальный код должен быть другим. Сложнее?...