Когда использовать volatile с многопоточностью?
Если есть два потока, обращающихся к глобальной переменной, то во многих руководствах говорится, что переменная становится изменчивой, чтобы компилятор не кэшировал переменную в регистре и, следовательно, не обновлялся правильно. Однако два потока, обращающихся к общей переменной, - это то, что требует защиты через мьютекс, не так ли? Но в этом случае, между блокировкой потока и освобождением мьютекса, код находится в критической секции, где только один поток может получить доступ к переменной, и в этом случае переменная не должна быть изменчивой?
Итак, каково использование / назначение volatile в многопоточной программе?
4 ответа
Краткий и быстрый ответ: volatile
(почти) бесполезен для независимого от платформы многопоточного программирования приложений. Он не обеспечивает никакой синхронизации, не создает заборов памяти и не обеспечивает порядок выполнения операций. Это не делает операции атомарными. Это не делает ваш код волшебным потокобезопасным. volatile
может быть самым непонятным средством во всем C++. Смотрите это, это и это для получения дополнительной информации о volatile
С другой стороны, volatile
действительно есть какое-то использование, которое может быть не столь очевидным. Он может быть использован так же, как можно было бы const
чтобы помочь компилятору показать вам, где вы могли ошибиться при доступе к некоторому общему ресурсу незащищенным способом. Это использование обсуждается Александреску в этой статье. Тем не менее, это в основном использует систему типов C++ таким образом, что часто рассматривается как ухищрение и может вызывать неопределенное поведение.
volatile
было специально предназначено для использования при взаимодействии с отображенным в память оборудованием, обработчиками сигналов и инструкцией машинного кода setjmp. Это делает volatile
непосредственно применимо к программированию системного уровня, а не к нормальному программированию прикладного уровня.
Стандарт C++ 2003 года не говорит, что volatile
применяет любой тип семантики Acquire или Release к переменным. На самом деле, стандарт полностью ничего не говорит о многопоточности. Однако определенные платформы применяют семантику Acquire и Release к volatile
переменные.
[Обновление для C++11]
Стандарт C++ 11 теперь признает многопоточность непосредственно в модели памяти и языке и предоставляет библиотечные средства для работы с ней независимо от платформы. Однако семантика volatile
до сих пор не изменились. volatile
все еще не механизм синхронизации. Бьярн Страуструп говорит так много в TCPPPL4E:
Не использовать
volatile
за исключением низкоуровневого кода, который непосредственно связан с оборудованием.Не думайте
volatile
имеет особое значение в модели памяти. Это не. Это не - как в некоторых более поздних языках - механизм синхронизации. Чтобы получить синхронизацию, используйтеatomic
,mutex
илиcondition_variable
,
[/ Конец обновления]
Все вышеизложенное относится к самому языку C++, как определено стандартом 2003 года (а теперь и стандартом 2011 года). Однако некоторые конкретные платформы добавляют дополнительную функциональность или ограничения к тому, что volatile
делает. Например, в MSVC 2010 (по крайней мере) семантика Acquire и Release действительно применяется к определенным операциям на volatile
переменные. Из MSDN:
При оптимизации компилятор должен поддерживать порядок среди ссылок на изменчивые объекты, а также ссылок на другие глобальные объекты. Особенно,
Запись в энергозависимый объект (volatile write) имеет семантику Release; ссылка на глобальный или статический объект, которая происходит перед записью в энергозависимый объект в последовательности команд, будет происходить до этой энергозависимой записи в скомпилированном двоичном файле.
Чтение летучего объекта (volatile read) имеет семантику Acquire; ссылка на глобальный или статический объект, который происходит после чтения энергозависимой памяти в последовательности команд, будет происходить после этого энергозависимого чтения в скомпилированном двоичном файле.
Тем не менее, вы можете принять к сведению тот факт, что если вы перейдете по вышеуказанной ссылке, в комментариях возникнут споры относительно того, действительно ли семантика приобретения / выпуска действительно применима в этом случае.
В C++11 обычно никогда не используйте volatile
для нарезки, только для MMIO
Но TL:DR, он "работает" как атомарный с mo_relaxed
на оборудовании с когерентными кешами (т.е. все); достаточно, чтобы компиляторы не сохраняли вары в регистрах. atomic
не нужны барьеры памяти для создания атомарности или видимости между потоками, только для того, чтобы текущий поток ждал до / после операции, чтобы создать порядок между доступами этого потока к различным переменным. mo_relaxed
никогда не нуждается в каких-либо барьерах, просто загружайте, храните или RMW.
Для атомарной электроники с volatile
(и inline-asm для барьеров) в старые добрые времена до C++11std::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. (например, GNU C__atomic
встроенные команды, которые работают с "простыми" объектами.)
Volatile можно использовать на практике (но не делайте этого)
Тем не менее, volatile
может использоваться на практике для таких вещей, как exit_now
флаг на всех (?) существующих реализациях C++ на реальных процессорах из-за того, как работают процессоры (согласованные кеши) и общих предположений о том, как volatile
должно сработать. Но больше нечего и не рекомендуется. Цель этого ответа - объяснить, как на самом деле работают существующие процессоры и реализации C++. Если вас это не волнует, все, что вам нужно знать, это то, чтоstd::atomic
с устаревшими mo_relaxed volatile
для заправки.
(Стандарт ISO C++ об этом довольно расплывчат, просто говорит, что volatile
доступы должны оцениваться строго в соответствии с правилами абстрактной машины C++, а не оптимизироваться. Учитывая, что реальные реализации используют адресное пространство памяти машины для моделирования адресного пространства C++, это означаетvolatile
операции чтения и присвоения должны компилироваться для загрузки / сохранения инструкций для доступа к объектному представлению в памяти.)
Как указывает другой ответ, exit_now
flag - это простой случай межпоточного взаимодействия, который не требует никакой синхронизации: он не публикует, что содержимое массива готово или что-то в этом роде. Просто магазин, который сразу замечается неоптимизированной загрузкой в другом потоке.
// 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
store, чтобы он был виден глобально, прежде чем продолжить дальнейшие операции в этом потоке. (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, эквивалентных 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
[Примечание: четыре предшествующих требования согласованности эффективно запрещают компилятор переупорядочивать атомарные операции для одного объекта, даже если обе операции являются ослабленными нагрузками. Это фактически обеспечивает гарантию согласованности кэша, предоставляемую большинством оборудования, доступного для атомарных операций C++.- конец примечания]
Нет механизма для release
store, чтобы только очистить себя и несколько выбранных диапазонов адресов: ему пришлось бы синхронизировать все, потому что он не знал бы, что другие потоки могли бы захотеть прочитать, если бы их загрузка-загрузка увидела это хранилище выпуска (формируя последовательность выпуска, которая устанавливает связь между потоками происходит раньше, гарантируя, что более ранние неатомарные операции, выполняемые потоком записи, теперь безопасны для чтения. Если только он не выполняет дальнейшую запись в них после хранилища релизов...) Или компиляторы должны быть действительно умными, чтобы Докажите, что нужно очистить всего несколько строк кэша.
Связанный: мой ответ на вопрос "Безопасно ли 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
(Внутренний разделяемый барьер) vs. 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) как можно быстрее, иначе он заполнится и остановит выполнение. Что делает полный барьер / забор, так это останавливает текущий поток до тех пор, пока буфер хранилища не будет истощен, поэтому наши последующие загрузки появляются в глобальном порядке после наших предыдущих хранилищ.
- Является ли загрузка и сохранение единственными переупорядоченными инструкциями?
- x86 mfence и барьер памяти C++
- Глобально невидимые инструкции по загрузке
(строго упорядоченная модель памяти asm x86 означает, что volatile
на x86 может приблизить вас к mo_acq_rel
, за исключением того, что переупорядочение во время компиляции с неатомарными переменными все еще может происходить. Но большинство моделей памяти, отличных от x86, имеют слабоупорядоченные модели памяти, поэтомуvolatile
а также relaxed
примерно так же слабы, как mo_relaxed
позволяет.)
Volatile иногда полезен по следующей причине: этот код:
/* global */ bool flag = false;
while (!flag) {}
оптимизируется gcc для:
if (!flag) { while (true) {} }
Что, очевидно, неверно, если флаг записан другим потоком. Обратите внимание, что без этой оптимизации механизм синхронизации, вероятно, работает (в зависимости от другого кода могут потребоваться некоторые барьеры памяти) - нет необходимости в мьютексе в сценарии 1 производитель - 1 потребитель.
В противном случае ключевое слово volatile слишком странно, чтобы его можно было использовать - оно не обеспечивает какого-либо упорядочения памяти, гарантирующего как энергозависимые, так и энергонезависимые доступы, и не обеспечивает никаких атомарных операций - т.е. компилятор с ключевым словом volatile не получает никакой помощи, кроме отключенного кэширования регистра.,
#include <iostream>
#include <thread>
#include <unistd.h>
using namespace std;
bool checkValue = false;
int main()
{
std::thread writer([&](){
sleep(2);
checkValue = true;
std::cout << "Value of checkValue set to " << checkValue << std::endl;
});
std::thread reader([&](){
while(!checkValue);
});
writer.join();
reader.join();
}
Однажды интервьюер, который также считал, что volatile бесполезен, поспорил со мной, что оптимизация не вызовет каких-либо проблем, и имел в виду разные ядра, имеющие отдельные строки кэша и все такое (на самом деле не понимал, о чем он конкретно говорил). Но этот фрагмент кода при компиляции с -O3 на g++ (g++ -O3 thread.cpp -lpthread) показывает неопределенное поведение. В основном, если значение установлено перед проверкой while, оно работает нормально, а если нет, оно входит в цикл, не удосужившись извлечь значение (которое фактически было изменено другим потоком). По сути, я считаю, что значение checkValue выбирается только один раз в регистр и никогда не проверяется снова при самом высоком уровне оптимизации. Если перед извлечением установлено значение true, он работает нормально, а если нет, то зацикливается. Пожалуйста, поправьте меня, если я не прав.
Вам нужен изменчивый и, возможно, блокировка.
volatile сообщает оптимизатору, что значение может изменяться асинхронно, таким образом
volatile bool flag = false;
while (!flag) {
/*do something*/
}
будет читать флаг каждый раз вокруг цикла.
Если вы выключите оптимизацию или сделаете каждую переменную изменчивой, программа будет вести себя так же, но медленнее. volatile просто означает "Я знаю, что вы, возможно, только что прочитали это и знаете, что он говорит, но если я скажу, прочитайте это, то прочитайте это.
Блокировка является частью программы. Так, кстати, если вы реализуете семафоры, то среди прочего они должны быть нестабильными. (Не пытайтесь, это трудно, возможно, понадобится немного ассемблера или новый атомарный материал, и это уже сделано.)