В C ++ 11 обычно никогда не используйте volatile
для потоковой передачи, только для MMIO
Но TL: DR, он действительно "работает" как атомарный mo_relaxed
на оборудовании с согласованными кэшами (то есть со всем); достаточно, чтобы компиляторы не сохраняли вары в регистрах. atomic
не нужны барьеры памяти для создания атомарности или видимости между потоками, только для того, чтобы текущий поток ожидал до / после операции, чтобы создать порядок между доступами этого потока к различным переменным. mo_relaxed
никогда не нуждается в каких-либо барьерах, просто загружайте, храните или RMW.
Для рулонного своего собственного Атомикса с volatile
(и инлайн-ассемблер для барьеров) в старые времена до C ++ 11 std::atomic
, volatile
был только хорошим способом получить некоторые вещи для работы . Но это зависело от множества предположений о том, как работают реализации, и никогда не гарантировалось никакими стандартами.
Например, ядро Linux по-прежнему использует собственный атомарный аппарат с ручным управлением. volatile
, но поддерживает только несколько конкретных реализаций C (GNU C, clang и, возможно, ICC). Частично это связано с расширениями GNU C и встроенным синтаксисом и семантикой asm, но также потому, что это зависит от некоторых предположений о том, как работают компиляторы.
Для новых проектов это почти всегда неправильный выбор; вы можете использовать std::atomic
(with std::memory_order_relaxed
), чтобы компилятор генерировал такой же эффективный машинный код, как и вы volatile
. std::atomic
с mo_relaxed
устаревшими volatile
для потоковой передачи. (за исключением, возможно, работы с ошибками упущенной оптимизации atomic<double>
в некоторых компиляторах .)
Внутренняя реализация std::atomic
основных компиляторов (таких как gcc и clang) используется не только для volatile
внутренних целей ; компиляторы напрямую предоставляют атомарные функции загрузки, хранения и встроенные функции RMW. (например, встроенные функции GNU C,__atomic
которые работают с "простыми" объектами.)
Volatile можно использовать на практике (но не делайте этого)
Тем не менее, он volatile
может использоваться на практике для таких вещей, как exit_now
флаг на всех (?) Существующих реализациях C ++ на реальных процессорах, из-за того, как работают процессоры (согласованные кеши) и общих предположений о том, как volatile
должны работать. Но больше нечего и не рекомендуется. Цель этого ответа - объяснить, как на самом деле работают существующие процессоры и реализации C ++. Если вас это не волнует, все, что вам нужно знать, это то, что std::atomic
mo_relaxed устарел volatile
для многопоточности.
(Стандарт ISO C ++ об этом довольно расплывчатый, просто говорится, что volatile
доступы должны оцениваться строго в соответствии с правилами абстрактной машины C ++, а не оптимизироваться. Учитывая, что реальные реализации используют адресное пространство памяти машины для моделирования адресного пространства C ++, это означает, что volatile
операции чтения и присваивания должны компилироваться для загрузки / сохранения инструкций для доступа к объектному представлению в памяти.)
Как указывает другой ответ, exit_now
флаг - это простой случай межпотокового взаимодействия, который не требует никакой синхронизации : он не публикует, что содержимое массива готово или что-то в этом роде. Просто магазин, который сразу замечается неоптимизированной загрузкой в другом потоке.
// global
bool exit_now = false;
// in one thread
while (!exit_now) { do_stuff; }
// in another thread, or signal handler in this thread
exit_now = true;
Без volatile или atomic правило as-if и предположение об отсутствии UB-гонки данных позволяет компилятору оптимизировать его в asm, который проверяет флаг только один раз , прежде чем войти (или нет) в бесконечный цикл. Именно это и происходит в реальной жизни с настоящими компиляторами. (И обычно оптимизируют большую часть do_stuff
из-за того, что цикл никогда не завершается, поэтому любой последующий код, который мог бы использовать результат, недоступен, если мы войдем в цикл).
// Optimizing compilers transform the loop into asm like this
if (!exit_now) { // check once before entering loop
while(1) do_stuff; // infinite loop
}
Многопоточная программа застряла в оптимизированном режиме, но нормально работает в -O0 - это пример (с описанием вывода asm GCC) того, как именно это происходит с GCC на x86-64. Также программирование MCU - оптимизация C ++ O2 прерывается, а цикл на электронике. SE показывает другой пример.
Обычно нам нужна агрессивная оптимизация, которая позволяет CSE и поднимать нагрузки за пределы петель, в том числе для глобальных переменных.
До C ++ 11 это volatile bool exit_now
был один из способов заставить эту работу работать должным образом (в обычных реализациях C ++). Но в C ++ 11 UB-гонка данных по-прежнему применяется, volatile
поэтому стандарт ISO на самом деле не гарантирует , что он будет работать везде, даже при условии согласованного кеширования HW.
Обратите внимание, что для более широких типов это volatile
не гарантирует отсутствия разрывов. Я проигнорировал это различие здесь, bool
потому что это не проблема для обычных реализаций. Но это также одна из причин, почему volatile
все еще подвержен гонке данных UB вместо того, чтобы быть эквивалентом расслабленного атомарного.
Обратите внимание, что «как задумано» не означает, что выполняющий поток exit_now
ожидает фактического завершения другого потока. Или даже то, что он ждет, пока изменчивое exit_now=true
хранилище даже не станет глобально видимым, прежде чем продолжить последующие операции в этом потоке. ( atomic<bool>
со значением по умолчанию он mo_seq_cst
будет ждать, по крайней мере, до любой последующей загрузки seq_cst. На многих ISA вы просто получите полный барьер после store).
C ++ 11 предоставляет способ, отличный от UB, который компилирует то же самое
Флаг "продолжить работу" или "выйти сейчас" следует использовать std::atomic<bool> flag
сmo_relaxed
С помощью
flag.store(true, std::memory_order_relaxed)
while( !flag.load(std::memory_order_relaxed) ) { ... }
предоставит вам тот же самый asm (без дорогостоящих инструкций по барьерам), который вы бы получили volatile flag
.
Помимо отсутствия разрыва, он atomic
также дает вам возможность хранить в одном потоке и загружать в другом без UB, поэтому компилятор не может поднять нагрузку из цикла. (Предположение об отсутствии UB-гонки данных - это то, что позволяет проводить агрессивную оптимизацию, которую мы хотим для неатомарных энергонезависимых объектов.) Эта функция atomic<T>
почти такая же, как volatile
и для чистых загрузок и чистых хранилищ.
atomic<T>
также make +=
и т. д. в атомарных RMW-операциях (значительно дороже, чем атомарная загрузка во временное, операционное, затем отдельное атомарное хранилище. Если вам не нужен атомарный RMW, напишите свой код с локальным временным хранилищем).
С seq_cst
порядком по умолчанию, который вы бы получили while(!flag)
, он также добавляет гарантии заказа по отношению к. неатомарные обращения и другие атомарные обращения.
(Теоретически стандарт ISO C ++ не исключает оптимизацию атомики во время компиляции. Но на практике компиляторы этого не делают, потому что нет способа контролировать, когда это будет плохо. Есть несколько случаев, когда даже volatile atomic<T>
не может иметь достаточный контроль над оптимизацией атомики, если компиляторы оптимизировали, поэтому пока компиляторы этого не делают. См. Почему компиляторы не объединяют избыточные записи std :: atomic? Обратите внимание, что wg21 / p0062 не рекомендует использовать volatile atomic
в текущем коде для защиты от оптимизации атомарный.)
volatile
действительно работает для этого на реальных процессорах (но все же не используйте его)
даже со слабоупорядоченными моделями памяти (не x86) . Но на самом деле не использовать его, использовать atomic<T>
с mo_relaxed
вместо !! Целью этого раздела является устранение неправильных представлений о том, как работают настоящие процессоры, а не оправдание volatile
. Если вы пишете код без блокировки, вы, вероятно, заботитесь о производительности. Понимание кешей и затрат на межпотоковое взаимодействие обычно важно для хорошей производительности.
Реальные процессоры имеют согласованные кеши / разделяемую память: после того, как хранилище одного ядра становится глобально видимым, никакое другое ядро не может загрузить устаревшее значение. (См. Также Мифы, которые верят программистам о кэшах ЦП, в котором рассказывается о нестабильности Java, эквивалентной C ++ atomic<T>
с порядком памяти seq_cst.)
Когда я говорю « загрузка» , я имею в виду инструкцию asm, которая обращается к памяти. Это то, что volatile
обеспечивает доступ, и это не то же самое, что преобразование lvalue-to-rvalue неатомарной / энергонезависимой переменной C ++. (например, local_tmp = flag
или while(!flag)
).
Единственное, что вам нужно победить, - это оптимизации времени компиляции, которые вообще не перезагружаются после первой проверки. Достаточно любой нагрузки + проверки на каждой итерации, без упорядочивания. Без синхронизации между этим потоком и основным потоком не имеет смысла говорить о том, когда именно произошло хранилище или порядок загрузки wrt. другие операции в цикле. Только тогда, когда он виден этой теме, имеет значение. Когда вы видите установленный флаг exit_now, вы выходите. Межъядерная задержка на типичном x86 Xeon может составлять примерно 40 нс между отдельными физическими ядрами .
Теоретически: потоки C ++ на оборудовании без согласованных кешей
Я не вижу никакого способа, которым это могло бы быть удаленно эффективным, используя только чистый ISO C ++, не требуя от программиста явного сброса исходного кода.
Теоретически у вас может быть реализация C ++ на машине, которая не похожа на эту, требуя генерируемых компилятором явных сбросов, чтобы сделать вещи видимыми для других потоков на других ядрах . (Или для чтения, чтобы не использовать возможно устаревшую копию). Стандарт C ++ не делает это невозможным, но модель памяти C ++ спроектирована так, чтобы быть эффективной на машинах с согласованной общей памятью. Например, стандарт C ++ даже говорит о «согласованности чтения-чтения», «согласованности чтения-записи» и т. Д. Одно примечание в стандарте даже указывает на связь с оборудованием:
http://eel.is/c++draft/intro.races#19
[Примечание: четыре предшествующих требования согласованности эффективно запрещают компилятор переупорядочивать атомарные операции для одного объекта, даже если обе операции являются ослабленными нагрузками. Это фактически обеспечивает гарантию согласованности кеша, предоставляемую большинством оборудования, доступного для атомарных операций C ++. - конец примечания]
Нет никакого механизма, чтобы release
хранилище только очищало себя и несколько выбранных диапазонов адресов: ему пришлось бы синхронизировать все, потому что он не знал бы, что другие потоки могли бы захотеть прочитать, если бы их загрузка-загрузка увидела это хранилище релизов (формируя Release-sequence, которая устанавливает связь между потоками «происходит до», гарантируя, что более ранние неатомарные операции, выполненные потоком записи, теперь безопасны для чтения. Если только он не выполняет дальнейшую запись в них после хранилища релизов ...) Или компиляторы будут иметь быть действительно умным, чтобы доказать, что только несколько строк кэша нуждаются в очистке.
Связанный: мой ответ на вопрос "Безопасно ли mov + mfence на NUMA"? подробно рассказывает об отсутствии систем x86 без согласованной разделяемой памяти. Также связано: переупорядочивание загрузок и хранилищ на ARM для получения дополнительной информации о загрузках / хранилищах в том же месте.
Там являются Я думаю , что кластеры с некогерентного общей памяти, но они не одной системы изображения машины. Каждый домен когерентности запускает отдельное ядро, поэтому вы не можете запускать потоки одной программы C ++ через него. Вместо этого вы запускаете отдельные экземпляры программы (каждый со своим адресным пространством: указатели в одном экземпляре недействительны в другом).
Чтобы заставить их взаимодействовать друг с другом посредством явного сброса, вы обычно используете MPI или другой API передачи сообщений, чтобы программа указала, какие диапазоны адресов нуждаются в сбросе.
Настоящее оборудование не std::thread
выходит за рамки согласованности кеша:
Существуют некоторые асимметричные чипы ARM с общим физическим адресным пространством, но без внутренних общих кеш-доменов. Так что не связно. (например, комментарии к ядру A8 и Cortex-M3, например TI Sitara AM335x).
Но на этих ядрах будут работать разные ядра, а не единый образ системы, который мог бы запускать потоки на обоих ядрах. Я не знаю никаких реализаций C ++, которые запускают std::thread
потоки через ядра ЦП без согласованных кешей.
В частности, для ARM GCC и clang генерируют код, предполагая, что все потоки выполняются в одном внутреннем разделяемом домене. Фактически, в руководстве ARMv7 ISA сказано:
Эта архитектура (ARMv7) написана с расчетом на то, что все процессоры, использующие одну и ту же операционную систему или гипервизор, находятся в одном и том же домене внутреннего совместного использования.
Таким образом, некогерентная разделяемая память между отдельными доменами - это только вещь для явного специфичного для системы использования областей разделяемой памяти для связи между различными процессами под разными ядрами.
См. Также это обсуждение CoreCLR о создании кода, использующем dmb ish
(Внутренний разделяемый барьер) и dmb sy
(Системные) барьеры памяти в этом компиляторе.
Я утверждаю, что никакая реализация C ++ для других ISA не работает std::thread
через ядра с некогерентными кешами. У меня нет доказательств того, что такой реализации не существует, но это кажется маловероятным. Если вы не нацеливаетесь на конкретную экзотическую часть HW, которая работает таким образом, ваши размышления о производительности должны предполагать MESI-подобную когерентность кеша между всеми потоками. (Однако желательно использовать atomic<T>
способы, гарантирующие правильность!)
Согласованные кеши упрощают
Но в многоядерной системе с согласованными кэшами реализация хранилища релизов просто означает упорядочивание фиксации в кеше для хранилищ этого потока, а не выполнение какой-либо явной очистки. ( https://preshing.com/20120913/acquire-and-release-semantics/ и https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/ ). (А загрузка-получение означает упорядочение доступа к кешу в другом ядре).
Команда барьера памяти просто блокирует загрузку и / или сохранение текущего потока до тех пор, пока буфер хранения не иссякнет; это всегда происходит как можно быстрее само по себе. ( Обеспечивает ли барьер памяти целостность кэша? Устраняет это заблуждение). Так что, если вам не нужен заказ, просто укажите видимость в других потоках, mo_relaxed
это нормально. (И так и есть volatile
, но не делайте этого.)
См. Также сопоставления C / C ++ 11 с процессорами
Интересный факт: на x86 каждое хранилище asm является хранилищем выпуска, потому что модель памяти x86 в основном представляет собой seq-cst плюс буфер хранилища (с пересылкой хранилища).
Наполовину связанный буфер re: store, глобальная видимость и согласованность: C ++ 11 гарантирует очень мало. Большинство реальных ISA (кроме PowerPC) действительно гарантируют, что все потоки могут согласовать порядок появления двух хранилищ двумя другими потоками. (В формальной терминологии модели памяти компьютерной архитектуры они называются «атомарными с множеством копий»).
Другое заблуждение состоит в том, что инструкции asm с ограничением памяти необходимы для очистки буфера хранилища, чтобы другие ядра вообще могли видеть наши хранилища . На самом деле буфер хранилища всегда пытается опустошить себя (зафиксировать кеш L1d) как можно быстрее, иначе он заполнится и остановит выполнение. Что делает полный барьер / забор, так это останавливает текущий поток до тех пор, пока буфер хранилища не будет истощен , поэтому наши последующие загрузки появляются в глобальном порядке после наших предыдущих хранилищ.
(Сильно упорядоченная модель памяти asm volatile
для x86 означает, что на x86 может оказаться ближе к вам mo_acq_rel
, за исключением того, что переупорядочение во время компиляции с неатомарными переменными все еще может происходить. Но большинство не-x86 имеют слабоупорядоченные модели памяти, поэтому volatile
и relaxed
примерно такие же слабый насколько mo_relaxed
позволяет.)