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

Могут ли изменчивые, но необоснованные чтения давать неопределенные значения? (на реальном оборудовании)

Отвечая на этот вопрос, возник еще один вопрос о ситуации с OP, о котором я не знал: в основном это вопрос архитектуры процессора, но с вопросом о том, о модели памяти С++ 11.

В принципе, код OP бесконечно зацикливался на более высоких уровнях оптимизации из-за следующего кода (слегка измененного для простоты):

while (true) {
    uint8_t ov = bits_; // bits_ is some "uint8_t" non-local variable
    if (ov & MASK) {
        continue;
    }
    if (ov == __sync_val_compare_and_swap(&bits_, ov, ov | MASK)) {
        break;
    }
}

где __sync_val_compare_and_swap() является встроенным GCC атомарным CAS. GCC (разумно) оптимизировал это на бесконечный цикл в случае, когда bits_ & mask был обнаружен как true перед входом в цикл, полностью пропустив операцию CAS, поэтому я предложил следующее изменение (которое работает):

while (true) {
    uint8_t ov = bits_; // bits_ is some "uint8_t" non-local variable
    if (ov & MASK) {
        __sync_synchronize();
        continue;
    }
    if (ov == __sync_val_compare_and_swap(&bits_, ov, ov | MASK)) {
        break;
    }
}

После того, как я ответил, OP отметил, что смена bits_ на volatile uint8_t также работает. Я предложил не идти по этому маршруту, так как volatile обычно не следует использовать для синхронизации, и, похоже, в любом случае нет возможности использовать забор здесь.

Однако я думал об этом больше, и в этом случае семантика такова, что на самом деле не имеет значения, если проверка ov & MASK основана на устаревшем значении, если она не основана на неопределенно устаревшем (т.е. до тех пор, пока цикл прерывается), поскольку фактическая попытка обновления bits_ синхронизируется. Таким образом, существует volatile достаточно, чтобы гарантировать, что этот цикл завершается, если bits_ обновляется другим потоком, таким как bits_ & MASK == false, для любого существующего процессора? Другими словами, при отсутствии явного забора памяти практически невозможно, чтобы критические данные, оптимизированные компилятором, не были оптимизированы процессором вместо этого, на неопределенный срок? ( EDIT:. Чтобы быть ясным, я задаю здесь вопрос о том, какое современное оборудование могло бы действительно дать предположение о том, что чтения испускаются в цикле компилятором, поэтому это не технически вопрос языка, хотя его выражают в терминах семантики С++ удобно.)

Это аппаратный угол для него, но немного обновить его и сделать его также ответственным вопросом о модели памяти С++ 11, рассмотрите следующую вариацию кода выше:

// bits_ is "std::atomic<unsigned char>"
unsigned char ov = bits_.load(std::memory_order_relaxed);
while (true) {
    if (ov & MASK) {
        ov = bits_.load(std::memory_order_relaxed);
        continue;
    }
    // compare_exchange_weak also updates ov if the exchange fails
    if (bits_.compare_exchange_weak(ov, ov | MASK, std::memory_order_acq_rel)) {
        break;
    }
}

cppreference утверждает, что std::memory_order_relaxed подразумевает "никаких ограничений на переупорядочение доступа к памяти вокруг атомной переменной", поэтому независимо от того, что фактическое оборудование будет или не будет делать, подразумевает, что bits_.load(std::memory_order_relaxed) может технически никогда читать обновленное значение после того, как bits_ обновляется в другом потоке в соответствующей реализации?

EDIT: Я нашел это в стандарте (29.4 p13):

Реализации должны сделать атомные хранилища видимыми для атомных нагрузок в течение разумного промежутка времени.

Таким образом, очевидно, что ожидание "бесконечно долго" для обновленного значения (в основном?) не может быть и речи, но нет никакой надежной гарантии какого-либо определенного временного интервала свежести, кроме этого, должно быть "разумным"; тем не менее, возникает вопрос о фактическом аппаратном поведении.

4b9b3361

Ответ 1

C++ 11 атомизация имеет дело с тремя проблемами:

  • гарантировать, что полное значение будет считано или записано без переключателя потока; это предотвращает разрывы.

  • обеспечение того, чтобы компилятор не перенаправлял инструкции в потоке через атомарное чтение или запись; это гарантирует упорядочение внутри потока.

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

Когда вы используете memory_order_relaxed, вы не получаете гарантии видимости из расслабленного магазина или загрузки. Вы получаете первые две гарантии.

Реализации "должны" (т.е. рекомендуется) делать заметки о записи в течение разумного промежутка времени даже при расслабленном порядке. Это о лучшем, что можно сказать; рано или поздно эти вещи должны появиться.

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

Что касается volatile, обратитесь к своему поставщику компилятора. Это до реализации.

Ответ 2

Технически законно для нагрузок std::memory_order_relaxed никогда, никогда не возвращать новое значение для нагрузки. Что касается того, сделает ли это какая-либо реализация, я не знаю.

Ссылка: http://www.developerfusion.com/article/138018/memory-ordering-for-atomic-operations-in-c0x/" Единственное требование заключается в том, что обращения к одной атомной переменной из одного потока могут быть переупорядочены: поток видел определенное значение атомной переменной, последующее чтение этим потоком не может получить более раннее значение переменной.

Ответ 3

Если у процессоров нет протокола кеш-когерентности или он очень прост, он может "оптимизировать" нагрузки, извлекающие устаревшие данные из кеша. В настоящее время большинство современных многоядерных процессоров реализуют протокол когерентности кэширования. Однако у ARM перед A9 этого не было. Архитектуры без процессора также могут не иметь кэш-когерентности (хотя они, вероятно, не будут придерживаться модели памяти С++).

Другая проблема заключается в том, что многие архитектуры (включая ARM и x86) позволяют переупорядочивать доступ к памяти. Я не знаю, достаточно ли процессоры достаточно внимательны, чтобы заметить повторный доступ к одному и тому же адресу, но я сомневаюсь в этом (он требует пространства и времени для редкого случая, поскольку компилятор должен уметь его заметить, с небольшими преимуществами, поскольку более поздние обращения будут, скорее всего, быть хитом L1), но технически он может предположить, что ветвь будет принята, и она может изменить порядок второго доступа до первого (маловероятно, но если я правильно прочитал руководство Intel и ARM, это разрешено).

Наконец, есть внешние устройства, которые не придерживаются когерентности кеша. Если CPU взаимодействует с отображением IO/DMA с памятью, тогда страница должна быть помечена как некашируемая (иначе в кеше L1/L2/L3/... будут зафиксированы данные). В таких случаях процессор обычно не переупорядочивает чтение и запись (подробности см. В руководстве к процессору - он может иметь более мелкозернистый контроль) - компилятор может так использовать volatile. Однако, поскольку атомы обычно основаны на кеше, они не нужны или могут их использовать.

Я боюсь, что я не могу ответить, если такая мощная кеширующая связность будет доступна в будущих процессорах. Я бы предложил строго следовать спецификации ( "Что неправильно в хранении указателя в int? Конечно, никто никогда не будет больше пользователя, чем 4GiB, поэтому адрес 32b достаточно большой".). На правильность ответили другие, поэтому я не буду включать его.

Ответ 4

Здесь я беру на себя это, хотя у меня мало знаний по этой теме, поэтому возьмите его с солью.

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

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

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

Я думаю, что ответ заключается в том, что ЦП не может предположить, что ячейка памяти не изменилась с ее последнего чтения. Доступ к памяти, даже в одной основной системе, строго не зарезервирован для ЦП. Многие другие подсистемы могут получить к нему доступ к чтению-записи (что принцип DMA).

Самая безопасная оптимизация, которую может сделать CPU, - проверить, изменилось ли значение в кеше или нет, и использовать это как подсказку состояния v в памяти. Кэши следует синхронизировать. с памятью благодаря механизмам недействительности кэша, связанным с DMA. При этом условии проблема возвращается к кэш-когерентности в многоядерном режиме и "write after write" для многопоточных ситуаций. Эта последняя проблема не может эффективно обрабатываться с помощью простых переменных volatile, поскольку их операция модификации не является атомарной, как вы уже знаете.