Приобретать/выпускать семантику с 4 потоками

В настоящее время я читаю C++ "Параллельность в действии" Энтони Уильямса. Один из его списков показывает этот код, и он утверждает, что утверждение, что z != 0 может сработать.

#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool> x,y;
std::atomic<int> z;

void write_x()
{
    x.store(true,std::memory_order_release);
}

void write_y()
{
    y.store(true,std::memory_order_release);
}

void read_x_then_y()
{
    while(!x.load(std::memory_order_acquire));
    if(y.load(std::memory_order_acquire))
        ++z;
}

void read_y_then_x()
{
    while(!y.load(std::memory_order_acquire));
    if(x.load(std::memory_order_acquire))
        ++z;
}

int main()
{
    x=false;
    y=false;
    z=0;
    std::thread a(write_x);
    std::thread b(write_y);
    std::thread c(read_x_then_y);
    std::thread d(read_y_then_x);
    a.join();
    b.join();
    c.join();
    d.join();
    assert(z.load()!=0);
}

Итак, различные пути выполнения, о которых я могу думать, таковы:

1)

Thread a (x is now true)
Thread c (fails to increment z)
Thread b (y is now true)
Thread d (increments z) assertion cannot fire

2)

Thread b (y is now true)
Thread d (fails to increment z)
Thread a (x is now true)
Thread c (increments z) assertion cannot fire

3)

Thread a (x is true)
Thread b (y is true)
Thread c (z is incremented) assertion cannot fire
Thread d (z is incremented)

Может ли кто-нибудь объяснить мне, как это утверждение может срабатывать?

Он показывает этот маленький рисунок: Image

read_x_then_y хранилище для y также не должно синхронизироваться с загрузкой в read_x_then_y, а хранилище для x синхронизироваться с загрузкой в read_y_then_x? Я очень смущен.

РЕДАКТИРОВАТЬ:

Спасибо за ваши ответы, я понимаю, как работает атомика и как использовать Acquire/Release. Я просто не понимаю этот конкретный пример. Я пытался выяснить, ЕСЛИ утверждение срабатывает, то что делал каждый поток? И почему утверждение никогда не срабатывает, если мы используем последовательную согласованность.

Кстати, я рассуждаю об этом так: если thread a (write_x) сохраняет в x то вся работа, которую он проделал до сих пор, синхронизируется с любым другим потоком, который читает x с упорядочением получения. Как только read_x_then_y видит это, он выходит из цикла и читает y. Теперь 2 вещи могут произойти. В одном варианте write_y записал в y, что означает, что этот выпуск будет синхронизироваться с оператором if (load), означающим, что z увеличивается, и утверждение не может быть запущено. Другой вариант - если write_y еще не запущен, то есть условие if не выполняется и z не увеличивается. В этом сценарии только x имеет значение true, а y по-прежнему false. После запуска write_y read_y_then_x прерывает свой цикл, однако оба значения x и y равны true, а z увеличивается, а утверждение не срабатывает. Я не могу думать ни о каком "прогоне" или порядке в памяти, где z никогда не увеличивается. Может кто-нибудь объяснить, где мои рассуждения ошибочны?

Кроме того, я знаю, что чтение цикла всегда будет перед чтением оператора if, потому что операция чтения предотвращает это переупорядочение.

Ответ 1

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

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

Сохранения в x и y происходят в разных потоках без синхронизации между ними. Загрузка x и y происходит в разных потоках без синхронизации между ними. Это значит, что поток c может видеть x && ! y x && ! y и нить d видит y && ! x y && ! x. (Я просто сокращаю здесь значения загрузки, не используйте этот синтаксис для обозначения последовательно согласованных нагрузок.)

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

Теперь, не обожжешься ли ты этим, другой вопрос. Стандарт просто допускает сценарий, в котором утверждение не выполняется, основываясь на абстрактной машине, которая используется для описания стандартных требований. Однако ваш компилятор и/или процессор не могут использовать это разрешение по той или иной причине. Таким образом, вполне возможно, что для данного компилятора и процессора вы никогда не увидите, что утверждение на практике сработало. Имейте в виду, что компилятор или ЦП всегда могут использовать более строгий порядок памяти, чем тот, о котором вы просили, потому что это никогда не может привести к нарушению минимальных требований стандарта. Это может стоить вам только некоторой производительности, но это не распространяется на стандарты в любом случае.

ОБНОВЛЕНИЕ в ответ на комментарий: Стандарт не устанавливает жесткого верхнего предела того, сколько времени требуется одному потоку, чтобы увидеть изменения атома другим потоком. Разработчикам рекомендуется, чтобы значения со временем стали видны.

Существуют гарантии последовательности, но те, которые относятся к вашему примеру, не мешают утверждению срабатывать. Основная гарантия приобретения-выпуска заключается в том, что если:

  • Поток e выполняет релиз-хранилище для атомарной переменной x
  • Поток f выполняет загрузку из одной и той же атомарной переменной
  • Затем, если значение, считанное f, является тем, которое было сохранено e, хранилище в e синхронизируется с загрузкой в f. Это означает, что любое (атомарное и неатомарное) хранилище в e, которое в этом потоке было упорядочено перед заданным запоминанием в x, видно любой операции в f, которая в этом потоке упорядочена после заданной загрузки. [Обратите внимание, что нет никаких гарантий в отношении потоков, кроме этих двух!]

Таким образом, нет гарантии, что f будет считывать значение, хранящееся в e, в отличие, например, от некоторого более старого значения x. Если он не читает обновленное значение, то загрузка также не синхронизируется с хранилищем, и нет никаких гарантий последовательности для любой из упомянутых выше зависимых операций.

Я сравниваю атомы с меньшим порядком памяти, чем последовательно согласующийся с теорией относительности, где не существует глобального понятия одновременности.

PS: Тем не менее, атомная нагрузка не может просто прочитать произвольное старое значение. Например, если один поток выполняет периодические приращения (например, с порядком освобождения) atomic<unsigned> переменной atomic<unsigned>, инициализированной в 0, и другой поток периодически загружается из этой переменной (например, с порядком получения), то, за исключением возможного переноса, значения, видимые последним потоком, должны монотонно увеличиваться. Но это следует из данных правил последовательности: как только последний поток читает 5, все, что произошло до увеличения с 4 до 5, находится в относительном прошлом всего, что следует за чтением 5. Фактически, уменьшение, кроме переноса, даже не допускается для memory_order_relaxed, но этот порядок памяти не дает никаких обещаний относительно относительной последовательности (если таковая имеется) доступа к другим переменным.

Ответ 2

Синхронизация с освобождением освобождения (по крайней мере) гарантирует: побочные эффекты до релиза в ячейке памяти видны после приобретения в этой ячейке памяти.

Нет такой гарантии, если место памяти не совпадает. Что еще более важно, нет общей (думаю, глобальной) гарантии порядка.

Посмотрев на пример, поток A заставляет поток C выйти из его цикла, а поток B заставляет поток D выйти из его цикла.

Однако способ, которым релиз может "публиковать" на приобретение (или способ, которым приобретатель может "наблюдать" выпуск) в том же месте памяти, не требует полного упорядочения. Возможно, для резьбы C наблюдать за выпуском и резьбой D, чтобы наблюдать выпуск B, и только где-нибудь в будущем для C наблюдать за выпуском B, а D - наблюдать выпуск A.


В примере есть 4 потока, потому что этот минимальный пример может привести к такому неинтуитивному поведению. Если бы какая-либо из атомных операций была выполнена в том же потоке, был бы порядок, который вы не могли бы нарушить.

Например, если write_x и write_y произошли в одном и том же потоке, потребовалось бы, чтобы любой поток, наблюдавший изменение в y, должен был наблюдать изменение в x.

Аналогично, если read_x_then_y и read_y_then_x произошли в одном и том же потоке, вы заметили бы как измененные в x, так и y по крайней мере в read_y_then_x.

Имея write_x и read_x_then_y в том же потоке, было бы бессмысленно для упражнения, так как стало бы очевидным, что он не синхронизируется правильно, так как будет иметь write_x и read_y_then_x, который всегда читал бы последние x.


EDIT:

Кстати, я рассуждаю об этом, что если thread a (write_x) хранится в x, то вся работа, которую он проделал до сих пор, синхронизируется с любым другим потоком, который читает x с упорядочением заказа.

(...) Я не могу придумать никакого "запуска" или порядка памяти, где z никогда не увеличивается. Может кто-нибудь объяснить, где мои рассуждения испорчены?

Кроме того, я знаю, что чтение цикла всегда будет выполняться до того, как инструкция if будет прочитана, потому что сбор предотвращает это переупорядочение.

Этот последовательный последовательный порядок, который налагает общий порядок. То есть он устанавливает, что write_x и write_y оба видны для всех потоков один за другим; либо x, затем y или y, затем x, но тот же порядок для всех потоков.

С выпуском-освобождением нет общего порядка. Эффекты от выпуска гарантируются только для того, чтобы быть видимым для соответствующего приобретателя в том же месте памяти. При использовании release-получения эффекты write_x гарантированы, что они будут видны тем, кто изменил уведомления x.

Это замечение чего-то изменилось очень важно. Если вы не заметили изменения, вы не синхронизируете. Таким образом, поток C не синхронизируется на y, а поток D не синхронизируется на x.

По существу, проще всего думать о выпуске release как системе уведомлений об изменениях, которая работает только при правильной синхронизации. Если вы не синхронизируете, вы можете наблюдать или не наблюдать побочные эффекты.

Сильные аппаратные архитектуры модели памяти с когерентностью кэш-памяти даже в NUMA или языки/фреймворки, которые синхронизируются в терминах полного порядка, затрудняют думать в этих терминах, поскольку практически невозможно наблюдать этот эффект.