Чтение общих переменных с расслабленным упорядочением: возможно ли это в теории? Возможно ли это на С++?

Рассмотрим следующий псевдокод:

expected = null;
if (variable == expected)
{
    atomic_compare_exchange_strong(
        &variable, expected, desired(), memory_order_acq_rel, memory_order_acq);
}
return variable;

Наблюдайте, нет ли семантики "приобретать", когда выполняется проверка variable == expected.

Мне кажется, что desired будет вызываться по крайней мере один раз в общей сложности и не более одного раза за поток.
Кроме того, если desired никогда не возвращает null, тогда этот код будет никогда возвращать null.

Теперь у меня есть три вопроса:

  • Является ли это вышеизложенным обязательно? то есть, действительно ли мы можем иметь упорядоченные чтения общих переменных даже в отсутствие забора на каждом считывании?

  • Возможно ли реализовать это на С++? Если да, то как? Если нет, то почему?
    (Надеюсь, с обоснованием, а не просто "потому что стандарт говорит так".)

  • Если ответ на (2) да, то можно ли реализовать это в С++ без, требуя variable == expected выполнить атомное чтение variable?

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

(Это скорее вопрос "язык-юрист". Поэтому подразумевается, что речь идет не о том, является ли это хорошей или полезной идеей, а скорее о том, можно ли технически это сделать правильно.)

Ответ 1

Что касается вопроса о возможности выполнения ленивой инициализации общей переменной в С++, которая имеет производительность (почти), идентичную производительности не shared-переменной:

Ответ заключается в том, что это зависит от аппаратной архитектуры и реализации компилятора и среды выполнения. По крайней мере, это возможно в некоторых средах. В частности, на x86 с GCC и Clang.

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

std::atomic<int> global_value;
int load_global_value() { return global_value.load(std::memory_order_seq_cst); }

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

load_global_value():
    movl global_value(%rip), %eax
    retq

Я сказал почти идентично, потому что есть другие причины, которые могут повлиять на производительность. Например:

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

Сказав это, рекомендуемым способом реализации ленивой инициализации является использование std::call_once. Это должно дать вам лучший результат для всех компиляторов, сред и целевых архитектур.

std::once_flag _init;
std::unique_ptr<gadget> _gadget;

auto get_gadget() -> gadget&
{
    std::call_once(_init, [this] { _gadget.reset(new gadget{...}); });
    return *_gadget;
}

Ответ 2

Это поведение undefined. Вы изменяете variable, на по крайней мере в каком-то потоке, что означает, что все обращения к переменная должна быть защищена. В частности, когда вы выполняя atomic_compare_exchange_strong в одном потоке, нет ничего, чтобы гарантировать, что другой поток может видеть новое значение variable, прежде чем он увидит записи, которые могут произошли в desired(). (atomic_compare_exchange_strong только гарантирует любой порядок в потоке, который его выполняет.)