В C ++ 11 обычно никогда не используйте volatile
для потоковой передачи, только для MMIO
Но TL: DR, он работает как атомарный с mo_relaxed
на оборудовании с согласованными кешами (т.е. все); достаточно, чтобы компиляторы не сохраняли вары в регистрах. atomic
не нужны барьеры памяти для создания атомарности или видимости между потоками, только для того, чтобы текущий поток ждал до / после операции, чтобы создать порядок между доступами этого потока к различным переменным. mo_relaxed
никогда не нуждаются в каких-либо барьерах, просто загружайте, храните или RMW.
Для самостоятельной атомики с volatile
(и inline-asm для барьеров) в старые добрые времена до C ++ 11 std::atomic
volatile
был единственным хорошим способом заставить некоторые вещи работать. Но это зависело от множества предположений о том, как работают реализации, и никогда не гарантировалось никакими стандартами.
Например, ядро Linux по-прежнему использует собственные атомики с volatile
, но поддерживает только несколько конкретных реализаций C (GNU C, clang и, возможно, ICC). Частично это связано с расширениями GNU C и встроенным синтаксисом и семантикой asm, но также потому, что это зависит от некоторых предположений о том, как работают компиляторы.
Для новых проектов это почти всегда неправильный выбор; вы можете использовать std::atomic
(с std::memory_order_relaxed
), чтобы компилятор генерировал такой же эффективный машинный код, как и volatile
. std::atomic
с mo_relaxed
устаревшим volatile
для потоковой передачи. (кроме, возможно, исправлять ошибки пропущенной оптимизации с atomic<double>
в некоторых компиляторах.)
Внутренняя реализация std::atomic
в основных компиляторах (таких как gcc и clang) не просто использует volatile
внутри; компиляторы напрямую предоставляют встроенные функции атомарной загрузки, хранения и RMW. (например, встроенные __atomic
GNU C, которые работают с простыми объектами.)
Volatile можно использовать на практике (но не делайте этого)
Тем не менее, volatile
можно использовать на практике для таких вещей, как флаг exit_now
на всех (?) Существующих реализациях C ++ на реальных процессорах, из-за того, как работают процессоры (согласованные кеши) и общих предположений о том, как volatile
должен работать. Но не более того, и это не рекомендуется. Цель этого ответа - объяснить, как на самом деле работают существующие процессоры и реализации C ++. Если вас это не волнует, все, что вам нужно знать, это то, что std::atomic
volatile
устаревшие 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;
Без изменчивости или атомарности, правило «как если бы» и предположение об отсутствии гонки данных 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 вы просто получите полный барьер после сохранения).
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>
также превращают +=
и так далее в атомарные операции 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 volatiles, эквивалент C ++ atomic<T>
с порядком памяти seq_cst.)
Когда я говорю load, я имею в виду инструкцию 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
хранилища, которое бы очищало только себя и несколько выбранных диапазонов адресов: ему пришлось бы синхронизировать все, потому что он не знал бы, что другие потоки могли бы захотеть прочитать, если бы их загрузка-загрузка увидела это хранилище релизов (формирование последовательность выпуска, которая устанавливает связь между потоками "происходит до", гарантируя, что ранее неатомарные операции, выполняемые потоком записи, теперь безопасны для чтения. Если только он не выполняет дальнейшую запись в них после хранилища выпуска ...) должны быть действительно сообразительными, чтобы доказать, что очистка требует лишь нескольких строк кэша.
По теме: мой ответ на Безопасен ли 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/). (А загрузка-загрузка означает упорядочение доступа к кеш-памяти в другом ядре).
Команда барьера памяти просто блокирует загрузку и / или сохранение текущего потока до тех пор, пока буфер хранения не иссякнет; это всегда происходит как можно быстрее само по себе. (Или для барьеры LoadLoad / LoadStore, блокировка до завершения предыдущих загрузок.) (Гарантирует ли барьер памяти, что согласованность кеша завершена? устраняет это заблуждение). Так что, если вам не нужен заказ, просто укажите видимость в других потоках, mo_relaxed
в порядке. (И volatile
, но не делайте этого.)
См. Также сопоставления C / C ++ 11 с процессорами
Интересный факт: на x86 каждое хранилище asm является хранилищем выпуска, потому что модель памяти x86 в основном представляет собой seq-cst плюс буфер хранилища (с пересылкой хранилища).
Наполовину связанный буфер re: store, глобальная видимость и согласованность: C ++ 11 гарантирует очень мало. Большинство реальных ISA (кроме PowerPC) действительно гарантируют, что все потоки могут согласовать порядок появления двух хранилищ двумя другими потоками. (В формальной терминологии модели памяти компьютерной архитектуры они являются атомарными с множеством копий).
Другое заблуждение состоит в том, что инструкции asm с ограничением памяти необходимы для очистки буфера хранилища, чтобы другие ядра могли видеть наши хранилища вообще. На самом деле буфер хранилища всегда пытается опустошить себя (зафиксировать кеш L1d) как можно быстрее, иначе он заполнится и остановит выполнение. Что делает полный барьер / забор, так это останавливает текущий поток до тех пор, пока буфер хранилища не будет истощен, поэтому наши последующие загрузки появляются в глобальном порядке после наших предыдущих хранилищ.
(Сильно упорядоченная модель памяти asm x86 означает, что volatile
на x86 может в конечном итоге дать вам значение, близкое к mo_acq_rel
, за исключением того, что переупорядочение во время компиляции с неатомарными переменными все еще может происходить. Но большинство не-x86 имеют слабоупорядоченные модели памяти, поэтому volatile
и relaxed
настолько слабы, насколько позволяет mo_relaxed
.)
person
Peter Cordes
schedule
24.10.2019