Программирование без блокировок: переупорядочивание и семантика порядка памяти

Я пытаюсь найти свои ноги в программировании без блокировки. Прочитав разные объяснения семантики упорядочения памяти, я хотел бы прояснить, какое возможное переупорядочение может произойти. Насколько я понял, инструкции могут быть переупорядочены компилятором (из-за оптимизации при компиляции программы) и процессором (во время выполнения?).

Для упрощенной семантики ссылка на cpp приводит следующий пример:

// Thread 1:
r1 = y.load(memory_order_relaxed); // A
x.store(r1, memory_order_relaxed); // B
// Thread 2:
r2 = x.load(memory_order_relaxed); // C 
y.store(42, memory_order_relaxed); // D

Говорят, что если x и y изначально равны нулю, код может выдать r1 == r2 == 42, потому что, хотя A упорядочено перед B в потоке 1, а C упорядочено перед D в потоке 2, ничто не препятствует появлению D. перед A в порядке изменения y, а B от появления перед C в порядке изменения x. Как это могло случиться? Означает ли это, что C и D переупорядочиваются, поэтому порядок выполнения будет DABC? Можно ли изменить порядок A и B?

Для семантики получения-освобождения есть следующий пример кода:

std::atomic<std::string*> ptr;
int data;

void producer()
{
    std::string* p  = new std::string("Hello");
    data = 42;
    ptr.store(p, std::memory_order_release);
}

void consumer()
{
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_acquire)))
        ;
    assert(*p2 == "Hello"); // never fires
    assert(data == 42); // never fires
}

Мне интересно, что, если бы мы использовали ослабленный порядок памяти вместо получения? Я предполагаю, что значение data может быть прочитано до p2 = ptr.load(std::memory_order_relaxed), но как насчет p2?

Наконец, почему в этом случае можно использовать расслабленный порядок памяти?

template<typename T>
class stack
{
    std::atomic<node<T>*> head;
 public:
    void push(const T& data)
    {
      node<T>* new_node = new node<T>(data);

      // put the current value of head into new_node->next
      new_node->next = head.load(std::memory_order_relaxed);

      // now make new_node the new head, but if the head
      // is no longer what's stored in new_node->next
      // (some other thread must have inserted a node just now)
      // then put that new head into new_node->next and try again
      while(!head.compare_exchange_weak(new_node->next, new_node,
                                        std::memory_order_release,
                                        std::memory_order_relaxed))
          ; // the body of the loop is empty
    }
};

Я имею в виду и head.load(std::memory_order_relaxed), и head.compare_exchange_weak(new_node->next, new_node, std::memory_order_release, std::memory_order_relaxed).

Подводя итог всему вышесказанному, мой вопрос, по сути, заключается в том, когда мне нужно заботиться о потенциальном изменении порядка, а когда нет?


person mentalmushroom    schedule 19.10.2016    source источник
comment
Обратите внимание, что № 2 будет иметь Undefined Behavior в соответствии со стандартом C++, если int data будет прочитан без гарантии синхронизации с, которая не позволяет потоку чтения просмотреть его, пока он все еще записывается. Это была бы гонка данных типа UB, поскольку это не тип std::atomic. std::atomic<int> использование memory_order_relaxed позволит избежать UB data и просто оставит вам действующую (но менее полезную) программу с обычной ошибкой гонки данных. Но сравнение строк после разыменования ptr все равно будет гонкой данных, потому что std::string не является атомарным/свободным от блокировки контейнером.   -  person Peter Cordes    schedule 28.11.2016
comment
@PeterCordes Я действительно не понимаю, почему сравнение строк может быть здесь гонкой данных. В потребителе ждем пока инициализируется p2. Затем мы сравниваем строку. Из-за правила не нарушать однопоточный код этот порядок должен быть сохранен. Как только мы получим что-то в p2, мы гарантированно увидим указанные данные (как вы упомянули, на всех процессорах, кроме Alpha). Данные должны быть не менее новыми, чем указатель, поэтому, если указатель инициализирован, данные также должны быть уже инициализированы. Или я вас неправильно понял?   -  person mentalmushroom    schedule 28.11.2016
comment
Я имел в виду, что это гонка данных, если писатель или читатель использует memory_order_relaxed, потому что любой из них позволит считывать указатель как не NULL, пока данные в классе std::string все еще записываются. (Обратите внимание, что компиляция для процессора, отличного от Alpha, не имеет значения в отношении UB или нет. Точно так же, как переполнение целого числа со знаком является UB, даже при компиляции для машины с дополнением до 2, где она имеет очень четко определенную семантику.)   -  person Peter Cordes    schedule 28.11.2016
comment
Правило «не нарушать однопоточный код» означает только то, что поток, который его написал, может прочитать его без проверки, но каждый другой поток может увидеть переупорядочение. то есть один поток видит свои собственные операции по порядку, но другие потоки видят, что они происходят не по порядку.   -  person Peter Cordes    schedule 28.11.2016
comment
любой из них позволил бы указателю читаться как не-NULL, в то время как данные в классе std::string все еще записывались - разве это не противоречит утверждению о том, что загрузка указателя, а затем его разыменование гарантирует, что данные, которые вы получаете, по крайней мере, как новый, как указатель?   -  person mentalmushroom    schedule 28.11.2016
comment
Это относится только к типичному слабоупорядоченному аппаратному обеспечению ЦП, а НЕ к модели памяти C++. И, во-вторых, новейшие средства в порядке глобальной видимости, а не в порядке программы для потока, выполняющего сохранение. Вот почему производитель должен использовать хранилище выпуска, чтобы убедиться, что обновленный указатель не станет видимым до тех пор, пока не будут обновлены данные.   -  person Peter Cordes    schedule 28.11.2016
comment
Другими словами: mo_consume дает вам порядок LoadLoad для зависимых загрузок (что, как вы думаете, будет происходить бесплатно, потому что как может произойти разыменование до того, как значение указателя будет прочитано, но какое-то конкретное оборудование Alpha нашло способ сделать это в редких случаях). ). Это ничего не делает с упорядочением StoreStore для производителя, которое вам также нужно и которое не происходит бесплатно на слабо упорядоченных архитектурах (и что компилятор может сделать во время компиляции, если вы не скажете ему не делать этого). Я думаю, по крайней мере, так же нов, как способ указания указателя, который просто сбивал с толку, извините.   -  person Peter Cordes    schedule 28.11.2016


Ответы (1)


Для № 1 компилятор может выдать сохранение в y перед загрузкой из x (нет зависимостей), и даже если это не так, загрузка из x может быть отложена на уровне процессора/памяти.

Для #2 p2 будет ненулевым, но ни *p2, ни данные не обязательно будут иметь значимое значение.

Для № 3 есть только один акт публикации неатомарных хранилищ, сделанный этим потоком, и это выпуск

Вы всегда должны заботиться о переупорядочивании или, лучше, не предполагать никакого порядка: ни C++, ни аппаратное обеспечение не выполняют код сверху вниз, они учитывают только зависимости.

person Cubbi    schedule 19.10.2016
comment
Не могли бы вы уточнить пункт № 2: в каком сценарии *p2 будет бессмысленным? Что касается пункта № 3, я понимаю, что он использует выпуск для публикации, но мой вопрос был больше о нагрузках (начальная загрузка на new_node->next и загрузка в случае сбоя compare_exchange_weak). Почему бы нам не навести здесь порядок? - person mentalmushroom; 19.10.2016
comment
@mentalmushroom *p2 не был опубликован для ветки расслабленных читателей. Он был опубликован для тех, кто приобретает. В № 3 нет ничего неатомарного, что считывалось бы из памяти, так что нечего приобретать. - person Cubbi; 19.10.2016
comment
Если это не было опубликовано для расслабленного читателя, почему ptr.load() вернул ненулевое значение? В #3 new_node и new_node->next не являются атомарными, верно? - person mentalmushroom; 19.10.2016
comment
@mentalmushroom p2.load вернул ненулевое значение, потому что это то, что было в p2. Это не связано с *p2. Да, new_node и new_node->next не являются атомарными. Они не читаются ниоткуда, нечего. - person Cubbi; 20.10.2016
comment
Я думаю, вы имели в виду, что ptr.load() вернул то, что было в ptr. Но, согласно документам, std::atomic<std::string*> ptr будет инициализирован нулем, поэтому он всегда должен возвращать nullptr, пока там не будет сохранено что-то еще. Когда p сохраняется, он уже инициализируется new string("hello"), и, насколько я понимаю, инициализация p не может быть переупорядочена после вызова хранилища, пока p является аргументом (т. е. вызов хранилища зависит от p). Или я ошибаюсь? В № 3 единственное неатомарное хранилище, кажется, находится в head->next, но мы его не приобретаем, так зачем нам здесь релиз? - person mentalmushroom; 20.10.2016
comment
Порядок событий @mentalmushroom в потоке производителя предназначен только для этого потока. Что касается № 3, соответствующий запрос будет в функции-члене pop(). - person Cubbi; 20.10.2016