Чем различаются порядки получения и потребления памяти и когда потребление предпочтительнее?

Стандарт C ++ 11 определяет модель памяти (1.7, 1.10), которая содержит порядок памяти, который, грубо говоря, является «последовательно согласованным», «приобретать», «потреблять», «выпускать», и «расслаблен». В равной степени программа является правильной только в том случае, если она не содержит гонок, что происходит, если все действия могут быть расположены в некотором порядке, в котором одно действие происходит до другого. Способ, которым действие X происходит до действия Y, состоит в том, что либо X упорядочивается до Y (в пределах один поток), или X между потоками происходит до Y. Последнее условие дается, среди прочего, когда

  • X синхронизируется с Y или
  • X упорядочивается по зависимости перед Y.

Синхронизация с происходит, когда X - это атомарное хранилище с порядком выпуска для некоторой атомарной переменной, а Y - это атомарная загрузка с "заказ по той же переменной. Быть упорядоченным-зависимостью происходит для аналогичной ситуации, когда Y загружается с упорядочением «потребление» (и подходящим доступом к памяти). Понятие синхронизирует-с транзитивно расширяет связь происходит-до на действия, которые упорядочены-до друг друга в потоке, но являются dependency-order-before транзитивно расширяется только с помощью строгого подмножества sequence-before, называемого supports-dependency, которое следует обширному набору правил, и в частности, может быть прерван с помощью std::kill_dependency.

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

Можете ли вы привести пример программы, в которой переключение с выпуска / получения на выпуск / использование является правильным и дает нетривиальное преимущество? И когда std::kill_dependency обеспечит улучшение? Аргументы высокого уровня были бы хороши, но бонусные баллы за аппаратные различия.


person Kerrek SB    schedule 26.10.2013    source источник
comment
Отказ от ответственности: я только что смотрел atomic<> Разговоры об оружии, и он сказал, что не будет обсуждать потребление, потому что никто этого не понимает.   -  person Kerrek SB    schedule 26.10.2013
comment
@ Энтони Уильямс: Не могли бы вы хоть немного разобраться в этом? :-)   -  person Kerrek SB    schedule 27.10.2013
comment
И когда std :: kill_dependency обеспечит улучшение? Связанные: stackoverflow.com/q/14779518/420683 и stackoverflow.com/q/7150395/420683; также обратите внимание на утверждения cppreference На всех основных процессорах, кроме DEC Alpha, порядок зависимостей автоматически, никакие дополнительные инструкции ЦП не выдаются для этого режима синхронизации [...], тогда как это не выполняется для упорядочивания выпуска-получения (я думаю, что примером является ARM).   -  person dyp    schedule 28.10.2013
comment
Возможный реальный вариант использования - с одним производителем / одним потребителем очередь. Во время операции push вы просто присоединяете новый узел к голове с помощью memory_order_release. Во время pop вы хотите иметь и tail, и tail->value, где загрузка tail несет-a-dependency-to tail->value, но вам все равно, поэтому вы можете использовать memory_order_consume вместо memory_order_acquire.   -  person Evgeny Panasyuk    schedule 28.10.2013
comment
@EvgenyPanasyuk: Очень интересно, хотя, насколько я могу судить, все до релиза в этом коде зависело от переменной синхронизации (указателя), поэтому он выглядит практически идентично тому, если бы вы использовали приобретать для нагрузки, нет?   -  person Kerrek SB    schedule 28.10.2013
comment
@DyP: Спасибо - страница cppreference действительно очень хороша. Когда вы говорите, что это не относится к порядку выпуска / получения, рассматриваемый порядок является просто общим упорядоченным до упорядочения, что действительно не является автоматическим на современных процессорах (и не только на ARM), и, по сути, имеет возможность переупорядочить это то, что делает современные процессоры быстрыми.   -  person Kerrek SB    schedule 28.10.2013
comment
@KerrekSB Основная цель этого примера - показать реализацию очереди SPSC. Пользователи этой очереди могут иметь свои собственные данные, которые не требуется синхронизировать во время синхронизации внутренних компонентов очереди. Так что memory_order_acquire - может быть лишним.   -  person Evgeny Panasyuk    schedule 28.10.2013
comment
@KerrekSB: Саттер не понимает, что потребление - это то же самое, что и получение, за исключением того, что для несвязанных, неатомарных общих данных нет никаких гарантий, что произойдет раньше? Шутки в сторону?   -  person Damon    schedule 30.10.2013
comment
@Damon: Нет, он сказал, что никто не понимает, что это значит и как его использовать. Одно дело иметь абстрактное описание, а другое - иметь глубокое понимание того, как оно используется правильно и эффективно. Согласны ли вы, что очень мало людей понимают, как правильно писать безблокирующий код? И это гораздо более простая проблема.   -  person Kerrek SB    schedule 30.10.2013
comment
@KerrekSB: Ну, правда. Хотя большая проблема, на мой взгляд, заключается в непонимании того, что это означает, и в том, чтобы хорошо знать, как правильно его использовать (я полагаю, большинство людей действительно понимают эту часть правильно, это не так уж сложно), но на самом деле найти < i> полезный не полностью надуманный пример, когда он вам действительно нужен. :-) Обычно вам либо нужна эта гарантия, так что вы используете приобретение, либо вас не интересуют никакие гарантии (просто нужно, чтобы одно значение было атомарным), и используйте расслабленно. Точно так же сложно придумать бесполезный пример, где вы действительно не можете обойтись без seq_cst.   -  person Damon    schedule 30.10.2013
comment
@ Деймон: Вот почему я подумал, что задам этот вопрос :-)   -  person Kerrek SB    schedule 30.10.2013
comment
'черт побери это голосование ...:' (   -  person    schedule 31.10.2013
comment
Я думаю, что этот поток по SO и предложению N2664 содержат ответы на ваш вопрос.   -  person MWid    schedule 01.11.2013
comment
Для тех, кто читает здесь, одна ключевая деталь заключается в том, что потребление не является транзитивным, что означает, что если T2 потребляет изменения T1, а T3 потребляет изменения T2, T3 МОЖЕТ не видеть все изменения T1! При получении / освобождении это переходное поведение действительно работает, и T3 будет видеть изменения T1. Для большинства разработчиков это гораздо более интуитивно понятно, чем потреблять. Однако на нескольких ОЧЕНЬ больших компьютерах (1024+ ядер) стоимость синхронизации большего объема памяти, чем необходимо, может быть очень высокой. Consume хорошо справилась с тем, что было необходимо в этих случаях.   -  person Cort Ammon    schedule 11.09.2014
comment
@CortAmmon затраты на синхронизацию дополнительной памяти Что вы имеете в виду? Что такое синхронизация?   -  person curiousguy    schedule 14.06.2018
comment
@curiousguy Я имею в виду действия, указанные в memory_order. Например, все побочные эффекты, которые происходят перед атомарной операцией с memory_order_release, должны быть видны любому потоку, который выполняет атомарную операцию с той же атомарной переменной с memory_order_acquire. Это может потребовать от ЦП выполнения таких действий, как очистка кешей, хотя обычно разработчики ЦП пытаются найти более эффективные решения, чем это. Для больших суперкомпьютеров стоит обратить внимание на пропускную способность соединений, используемых в этом процессе.   -  person Cort Ammon    schedule 14.06.2018
comment
@CortAmmon Какой процессор очистил кеш для операции выпуска?   -  person curiousguy    schedule 14.06.2018
comment
@curiousguy Это вопрос об архитектуре процессора, но я считаю, что общий ответ заключается в том, что очистка кеша выполняется процессором, который при необходимости получает. Я знаю, что многие современные процессоры также делают некоторые хитрые трюки, чтобы заглянуть в кэш ядер на том же процессоре, чтобы избежать сброса кеша (поскольку это может быть дорогостоящей операцией).   -  person Cort Ammon    schedule 14.06.2018
comment
@CortAmmon AFAIK, любая нагрузка на x86 - это операция получения.   -  person curiousguy    schedule 14.06.2018
comment
@curiousguy Почти. Вам по-прежнему нужно использовать префикс lock для поддержки шаблонов чтения-изменения-обновления, таких как atomic<int>::operator++, которые включают как чтение, так и запись. Простые операции чтения или записи действительно являются атомарными (при разумных обстоятельствах). Вот чей-то ответ, который углубляется в это. Поведение архитектур, отличных от x86, - это, очевидно, совсем другой вопрос.   -  person Cort Ammon    schedule 14.06.2018
comment
@CortAmmon Я имею в виду, что на x86 достаточно простого слова load для упорядочивания. (Загрузка или сохранение выровненных слов являются атомарными. Конечно, комбинированные операции не являются атомарными по умолчанию.)   -  person curiousguy    schedule 15.06.2018
comment
@curiousguy Да, если вы ограничиваетесь простыми загрузками и простыми хранилищами на x86, а компилятор не выполняет никаких оптимизаций, которые могли бы помешать, процессор предоставит атомарную гарантию. Если я помню, реализация статических переменных в функции GCC использует эту гарантию.   -  person Cort Ammon    schedule 15.06.2018
comment
@CortAmmon Какие виды оптимизации компилятора нарушили бы атомарность?   -  person curiousguy    schedule 15.06.2018
comment
@curiousguy Я бы сказал иначе. Вместо того, чтобы сказать, что некоторые оптимизации нарушают атомарность, я бы сказал, что атомарность - это то, что не гарантируется, если вы намеренно не используете функции компилятора, которые гарантируют конкретные гарантии, необходимые для этой конкретной операции (volatile является одной из таких функций). Это сообщение в блоге - мой любимый способ объяснить, насколько много может сделать компилятор, если вы явно не используете эти функции для ограничения его оптимизаций.   -  person Cort Ammon    schedule 15.06.2018


Ответы (4)


Упорядочивание зависимостей данных было введено N2492 со следующим обоснованием:

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

  • доступ для чтения к редко записываемым параллельным структурам данных

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

  • семантика публикации-подписки для публикации, опосредованной указателем

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

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

акцент мой

представлен мотивирующий вариант использования rcu_dereference() из ядра Linux

person Cubbi    schedule 31.10.2013

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

Ключевой вариант использования заключается в том, что писатель последовательно создает структуру данных, а затем передает общий указатель на новую структуру (используя атомарный release или acq_rel). Читатель использует загрузку для чтения указателя и разыменовывает его, чтобы перейти к структуре данных. Разыменование создает зависимость данных, поэтому читатель гарантированно увидит инициализированные данные.

std::atomic<int *> foo {nullptr};
std::atomic<int> bar;

void thread1()
{
    bar = 7;
    int * x = new int {51};
    foo.store(x, std::memory_order_release);
}

void thread2()
{
    int *y = foo.load(std::memory_order_consume)
    if (y)
    {
        assert(*y == 51); //succeeds
        // assert(bar == 7); //undefined behavior - could race with the store to bar 
        // assert(kill_dependency(*y) + bar == 58) // undefined behavior (same reason)
        assert(*y + bar == 58); // succeeds - evaluation of bar pulled into the dependency 
    }
}

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

Заключение значения в kill_dependency позволяет вычислить выражение, которое зависит от значения, которое нужно переместить перед потреблением нагрузки. Это полезно, например, когда значение является индексом в массиве, который был ранее прочитан.

Обратите внимание, что использование потребления приводит к тому, что отношение происходит раньше, которое больше не является транзитивным (хотя оно по-прежнему гарантированно является ациклическим). Например, сохранение в bar происходит перед сохранением в foo, что происходит перед разыменованием y, что происходит перед чтением bar (в закомментированном утверждении), но сохранение в bar не происходит до чтения из bar. Это приводит к довольно сложному определению «происходит до», но вы можете представить, как это работает (начните с последовательности «до», а затем распространите через любое количество ссылок «release-consumer-dataDependency» или «release-take-sequenceBefore»).

person user2949652    schedule 05.11.2013
comment
Интересно. Значит, компилятор обнаруживает (*y + bar) и извлекает bar из кеша? Почему потоку записи не нужно сигнализировать, что bar также выпускается? - person RunHolt; 04.08.2014
comment
когда значение является индексом в массиве, который был ранее прочитан я не понимаю - person curiousguy; 14.06.2018
comment
Вторая причина заключается в том, что компилятор может перемещать последующие операции без зависимости от данных до момента потребления, чего он не может сделать для получения. Есть ли какой-нибудь практический случай, когда это делается и полезно? - person curiousguy; 23.06.2018

У Джеффа Прешинга есть отличный пост в блоге, отвечающий на этот вопрос. Сам я не могу ничего добавить, но думаю, что любой, кто интересуется потреблением и приобретением, должен прочитать его пост:

http://preshing.com/20140709/the-purpose-of-memory_order_consume-in-cpp11/

Он показывает конкретный пример C ++ с соответствующим протестированным кодом сборки для трех разных архитектур. По сравнению с memory_order_acquire, memory_order_consume потенциально предлагает 3-кратное ускорение на PowerPC, 1,6-кратное ускорение на ARM и незначительное ускорение на x86, которое в любом случае имеет сильную согласованность. Загвоздка в том, что на момент написания только GCC фактически обрабатывал семантику потребления иначе, чем получение, и, вероятно, из-за ошибки. Тем не менее, это демонстрирует, что ускорение доступно, если разработчики компилятора могут выяснить, как этим воспользоваться.

person user3188445    schedule 11.10.2015
comment
Другая ссылка, если хотите: open-std. org / jtc1 / sc22 / wg21 / docs / paper / 2015 / p0098r0.pdf - person Marc Glisse; 11.10.2015

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

Посмотрев какое-то время на 1.10 и, в частности, на очень полезную заметку в параграфе 11, я думаю, что это на самом деле не так уж и сложно. Большая разница между синхронизирует-с (далее: s / w) и dependency-order-before (dob) заключается в том, что происходит-до связь может быть установлена ​​путем произвольного объединения последовательность-перед (s / b) и s / w, но не так для dob. Обратите внимание, что одно из определений для межпотока происходит раньше:

A синхронизируется - с X и X упорядочивается до B

Но аналогичный оператор для A упорядочивается по зависимости до того, как X отсутствует!

Таким образом, с выпуском / получением (т.е. s / w) мы можем заказывать произвольные события:

A1    s/b    B1                                            Thread 1
                   s/w
                          C1    s/b    D1                  Thread 2

Но теперь рассмотрим произвольную последовательность событий, подобную этой:

A2    s/b    B2                                            Thread 1
                   dob
                          C2    s/b    D2                  Thread 2

В этом случае по-прежнему верно, что A2 происходит-раньше C2 (потому что A2 - это s / b B2, а B2 межпотоковое взаимодействие происходит раньше C2 из-за dob; но мы могли бы возразить, что вы никогда не сможете точно сказать!). Однако неверно, что A2 происходит раньше D2. События A2 и D2 не упорядочены относительно друг друга, если на самом деле не установлено, что C2 несет зависимость от D2. Это более строгое требование, и без этого требования A2-to-D2 не может быть заказано "по" паре "выпуск / потребление".

Другими словами, пара выпуск / потребление только распространяет порядок действий, которые несут зависимость от одного к другому. Все, что не является зависимым, не упорядочивается по паре выпуск / потребление.

Кроме того, обратите внимание, что порядок восстанавливается, если мы добавляем последнюю, более сильную пару выпуск / получение:

A2    s/b    B2                                                         Th 1
                   dob
                          C2    s/b    D2                               Th 2
                                             s/w
                                                    E2    s/b    F2     Th 3

Теперь, согласно указанному правилу, D2 межпоточное взаимодействие происходит до F2, и, следовательно, то же самое происходит с C2 и B2, и поэтому A2 происходит до F2. Но обратите внимание, что между A2 и D2 по-прежнему нет упорядочивания, это только между A2 и более поздними событиями.

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


Может быть, вот пример, который имеет смысл?

std::atomic<int> foo(0);

int x = 0;

void thread1()
{
    x = 51;
    foo.store(10, std::memory_order_release);
}

void thread2()
{
    if (foo.load(std::memory_order_acquire) == 10)
    {
        assert(x == 51);
    }
}

Как написано, код свободен от гонок, и утверждение останется в силе, потому что пара освобождение / получение упорядочивает хранилище x = 51 перед загрузкой в ​​утверждении. Однако, если изменить «приобретение» на «потребление», это больше не будет правдой, и программа будет иметь гонку данных на x, поскольку x = 51 не несет зависимости в хранилище от foo. Смысл оптимизации в том, что это хранилище можно свободно переупорядочивать, не обращая внимания на то, что делает foo, потому что здесь нет зависимости.

person Kerrek SB    schedule 27.10.2013
comment
Возможно, вам следует добавить в свой пример что-нибудь, показывающее, где на самом деле работает consume. Может что-то вроде foo.load(memory_order_consume)->value. - person Evgeny Panasyuk; 28.10.2013
comment
@EvgenyPanasyuk: Я не смог придумать полезного примера. Тем не менее, атомарный указатель кажется очевидным. - person Kerrek SB; 28.10.2013
comment
Во втором примере (выпуск / потребление) он должен читать, если только он не утверждает, что C2 имеет зависимость от D2. Потому что тогда B2 упорядочен по зависимостям перед D2, следовательно, межпоток происходит до D2, а A2 упорядочивается до B2, что подразумевает A2 между потоками происходит до D2. - person MWid; 31.10.2013
comment
Насколько мне известно, нет никакой разницы в реализации выпуска / получения и выпуска / потребления на архитектурах x86. Но в Power, например, получение нагрузки реализуется ld; cmp; bc; isync;, в то время как потребление нагрузки только ld;, и (слабая) модель памяти гарантирует соблюдение определенных зависимостей. - person MWid; 31.10.2013
comment
Обратите внимание, что ваше определение происходит до, как указано в вопросе, неверно, поскольку оно всегда подразумевает, что A2 происходит до D2. - person MWid; 31.10.2013
comment
@MWid: Спасибо за все, я обновил и вопрос, и этот пост! - person Kerrek SB; 31.10.2013