В чем разница между использованием явных заграждений и std:: atomic?
Предполагая, что выровненные указательные нагрузки и хранилища естественно атомарны на целевой платформе, в чем разница между ними:
// Case 1: Dumb pointer, manual fence
int* ptr;
// ...
std::atomic_thread_fence(std::memory_order_release);
ptr = new int(-4);
// Case 2: atomic var, automatic fence
std::atomic<int*> ptr;
// ...
ptr.store(new int(-4), std::memory_order_release);
и это:
// Case 3: atomic var, manual fence
std::atomic<int*> ptr;
// ...
std::atomic_thread_fence(std::memory_order_release);
ptr.store(new int(-4), std::memory_order_relaxed);
У меня создалось впечатление, что все они эквивалентны, однако Relacy обнаруживает гонку данных в
первый случай (только):
struct test_relacy_behaviour : public rl::test_suite<test_relacy_behaviour, 2>
{
rl::var<std::string*> ptr;
rl::var<int> data;
void before()
{
ptr($) = nullptr;
rl::atomic_thread_fence(rl::memory_order_seq_cst);
}
void thread(unsigned int id)
{
if (id == 0) {
std::string* p = new std::string("Hello");
data($) = 42;
rl::atomic_thread_fence(rl::memory_order_release);
ptr($) = p;
}
else {
std::string* p2 = ptr($); // <-- Test fails here after the first thread completely finishes executing (no contention)
rl::atomic_thread_fence(rl::memory_order_acquire);
RL_ASSERT(!p2 || *p2 == "Hello" && data($) == 42);
}
}
void after()
{
delete ptr($);
}
};
Я связался с автором Relacy, чтобы узнать, было ли это ожидаемое поведение; он говорит, что в моей тестовой ситуации действительно есть гонка данных.
Однако у меня проблемы с этим; может кто-нибудь указать мне, что такое гонка?
Самое главное, каковы различия между этими тремя случаями?
Обновление. Мне пришло в голову, что Relacy может просто жаловаться на атомарность (или ее отсутствие), а не на переменную, доступную через потоки... в конце концов, она не знает что я намерен использовать этот код только на платформах, где выровненный целочисленный/указательный доступ естественно атомный.
Другое обновление. Джефф Прешинг написал отличное сообщение в блоге объясняя разницу между явными заборами и встроенными ( "ограждения" против "операций" ). Случаи 2 и 3, по-видимому, не эквивалентны! (В некоторых тонких обстоятельствах, во всяком случае.)
Ответы
Ответ 1
Я считаю, что в коде есть гонка. Случай 1 и случай 2 не эквивалентны.
29.8 [atomics.fences]
-2- Разблокировочный затвор A синхронизируется с приобретающим ограждением B, если существуют атомарные операции X и Y, работающие на каком-либо атомарном объекте M, так что A секвенируется до X, X изменяет M, Y секвенируется до B, и Y считывает значение, записанное X, или значение, записанное любым побочным эффектом в гипотетической последовательности освобождения X, если бы это была операция деблокирования.
В случае 1 ваш забор не синхронизируется с вашим заборным забором, потому что ptr
не является атомарным объектом, а хранилище и загрузка на ptr
не являются атомарными операциями.
Случай 2 и случай 3 эквивалентны (фактически, не совсем, см. комментарии LWimsey и ответьте), потому что ptr
- это атомный объект, а хранилище - атомная операция. (Пункты 3 и 4 [atomic.fences] описывают, как забор синхронизируется с атомной операцией и наоборот.)
Семантика заборов определяется только в отношении атомных объектов и атомных операций. Является ли ваша целевая платформа и ваша реализация более надежными (например, обработка любого типа указателя в виде атомарного объекта) в лучшем случае.
N.B. как для случая 2, так и для случая 3 операция покупки на ptr
может произойти перед хранилищем, и поэтому будет читать мусор из неинициализированного atomic<int*>
. Простое использование операций захвата и освобождения (или ограждений) не гарантирует, что хранилище происходит до загрузки, оно только гарантирует, что если загрузка считывает сохраненное значение, тогда код будет правильно синхронизирован.
Ответ 2
Несколько ссылок:
Некоторые из вышеперечисленных могут заинтересовать вас и других читателей.
Ответ 3
Хотя различные ответы охватывают биты и фрагменты того, что является потенциальной проблемой, и/или предоставляют полезную информацию, ни один ответ правильно не описывает потенциальные проблемы для всех трех случаев.
Чтобы синхронизировать операции памяти между потоками, для указания порядка использования используются ограничения выпуска и получения.
На диаграмме операции A памяти в потоке 1 не могут перемещаться вниз (односторонний) барьер выпуска (независимо от того, является ли эта операция освобождением в хранилище атомов,
или автономный выпускной забор, за которым следует расслабленный атомный магазин). Следовательно, операции памяти A гарантированно выполняются перед хранилищем атомов.
То же самое относится к операциям памяти B в потоке 2, которые не могут перемещаться по захвату барьера; поэтому атомная нагрузка происходит до операций с памятью B.
![введите описание изображения здесь]()
Атомный ptr
сам обеспечивает межпоточное упорядочение на основе гарантии того, что он имеет один порядок модификации. Как только поток 2 видит значение ptr
,
гарантируется, что хранилище (и, следовательно, операции памяти A) произошло до загрузки. Поскольку нагрузка гарантирована, прежде чем операции B памяти,
правила транзитивности говорят, что операции памяти A происходят до B и синхронизация завершена.
С этим давайте посмотрим на ваши 3 случая.
Случай 1 сломан, потому что ptr
, неатомный тип, изменяется в разных потоках. Это классический пример гонки данных и вызывает поведение undefined.
Случай 2 верен.. В качестве аргумента распределение целых чисел с new
секвенируется до операции release. Это эквивалентно:
// Case 2: atomic var, automatic fence
std::atomic<int*> ptr;
// ...
int *tmp = new int(-4);
ptr.store(tmp, std::memory_order_release);
Случай 3 сломан, хотя и тонким способом. Проблема в том, что даже если назначение ptr
правильно упорядочено после автономного забора,
целочисленное распределение (new
) также секвенируется после забора, в результате чего гонка данных находится в ячейке целочисленной памяти.
код эквивалентен:
// Case 3: atomic var, manual fence
std::atomic<int*> ptr;
// ...
std::atomic_thread_fence(std::memory_order_release);
int *tmp = new int(-4);
ptr.store(tmp, std::memory_order_relaxed);
Если вы сопоставляете это с приведенной выше диаграммой, оператор new
должен быть частью операций с памятью А. Будучи упорядоченным ниже забора,
гарантии порядка больше не сохраняются, и целочисленное распределение может быть фактически переупорядочено с операциями памяти B в потоке 2.
Следовательно, a load()
в потоке 2 может возвращать мусор или вызывать другое поведение undefined.
Ответ 4
Память, поддерживающая атомную переменную, может использоваться только для содержимого атома. Однако простая переменная, например, ptr в случае 1, - это другая история. Как только компилятор имеет право писать на него, он может написать что-нибудь к нему, даже значение временного значения, когда у вас закончились регистры.
Помните, что ваш пример патологически чист. Учитывая несколько более сложный пример:
std::string* p = new std::string("Hello");
data($) = 42;
rl::atomic_thread_fence(rl::memory_order_release);
std::string* p2 = new std::string("Bye");
ptr($) = p;
для компилятора совершенно законно выбирать повторное использование указателя
std::string* p = new std::string("Hello");
data($) = 42;
rl::atomic_thread_fence(rl::memory_order_release);
ptr($) = new std::string("Bye");
std::string* p2 = ptr($);
ptr($) = p;
Зачем это делать? Я не знаю, возможно, какой-то экзотический трюк, чтобы сохранить линию кеша или что-то в этом роде. Дело в том, что поскольку ptr не является атомарным в случае 1, существует случай расы между строкой записи 'ptr ($) = p' и read on 'std::string * p2 = ptr ($)', что дает undefined. В этом простом случае компилятор может не использовать это право, и это может быть безопасно, но в более сложных случаях компилятор имеет право злоупотреблять ptr, но это ему нравится, и Relacy ловит это.
Моя любимая статья на тему: http://software.intel.com/en-us/blogs/2013/01/06/benign-data-races-what-could-possibly-go-wrong
Ответ 5
Гонка в первом примере находится между публикацией указателя и материалом, на который он указывает. Причина в том, что у вас есть создание и инициализация указателя после забора (= на той же стороне, что и публикация указателя):
int* ptr; //noop
std::atomic_thread_fence(std::memory_order_release); //fence between noop and interesting stuff
ptr = new int(-4); //object creation, initalization, and publication
Если предположить, что обращение ЦП к правильно выровненным указателям является атомарным, код можно исправить, написав это:
int* ptr; //noop
int* newPtr = new int(-4); //object creation & initalization
std::atomic_thread_fence(std::memory_order_release); //fence between initialization and publication
ptr = newPtr; //publication
Обратите внимание, что хотя это может отлично работать на многих машинах, в стандарте С++ нет абсолютно никакой гарантии на атомарность последней строки. Поэтому лучше использовать переменные atomic<>
в первую очередь.