Чтение общих переменных с расслабленным упорядочением: возможно ли это в теории? Возможно ли это на С++?
Рассмотрим следующий псевдокод:
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
только гарантирует любой порядок в потоке, который его выполняет.)