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

В чем разница между использованием явных заграждений и 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, по-видимому, не эквивалентны! (В некоторых тонких обстоятельствах, во всяком случае.)

4b9b3361

Ответ 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<> в первую очередь.