Почему volatile не считается полезным в многопоточном программировании на C или C++?

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

Мое понимание таково: в любое время, когда переменная может быть изменена вне потока контроля фрагмента кода, обращающегося к ней, эта переменная должна быть объявлена ​​как volatile, Обработчики сигналов, регистры ввода-вывода и переменные, измененные другим потоком, представляют собой такие ситуации.

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

Как и где я не прав?

9 ответов

Решение

Проблема с volatile в многопоточном контексте это то, что он не обеспечивает всех необходимых нам гарантий. У него есть несколько необходимых нам свойств, но не все, поэтому мы не можем полагаться на volatile один

Тем не менее, примитивы, которые мы должны использовать для остальных свойств, также предоставляют те, которые volatile делает, так что это фактически не нужно.

Для поточно-ориентированного доступа к общим данным нам нужна гарантия того, что:

  • на самом деле происходит чтение / запись (что компилятор не просто сохранит значение в регистре, а отложит обновление основной памяти намного позже)
  • что никакого переупорядочения не происходит. Предположим, что мы используем volatile переменная как флаг, чтобы указать, готовы ли некоторые данные для чтения. В нашем коде мы просто устанавливаем флаг после подготовки данных, поэтому все выглядит хорошо. Но что, если инструкции переупорядочены так, чтобы флаг был установлен первым?

volatile действительно гарантирует первый пункт. Это также гарантирует, что не происходит переупорядочения между различными изменчивыми операциями чтения / записи. Все volatile доступ к памяти будет происходить в том порядке, в котором они указаны. Это все, что нам нужно для чего volatile предназначен для: манипулирования регистрами ввода-вывода или отображенного в память оборудования, но он не помогает нам в многопоточном коде, где volatile Объект часто используется только для синхронизации доступа к энергонезависимым данным. Эти доступы все еще могут быть переупорядочены относительно volatile из них.

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

Тем не менее, барьеры памяти также гарантируют, что все ожидающие чтения / записи выполняются, когда барьер достигнут, поэтому он эффективно дает нам все, что нам нужно, сам volatile ненужным. Мы можем просто удалить volatile классификатор целиком.

Начиная с C++11, атомарные переменные (std::atomic<T>) дайте нам все соответствующие гарантии.

Вы можете также рассмотреть это из документации ядра Linux.

Программисты на C часто используют volatile для обозначения того, что переменная может быть изменена вне текущего потока выполнения; в результате у них иногда возникает соблазн использовать его в коде ядра, когда используются общие структуры данных. Другими словами, известно, что они рассматривают изменчивые типы как своего рода легкую атомарную переменную, которой они не являются. Использование volatile в коде ядра почти никогда не является правильным; этот документ описывает почему.

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

Как и volatile, примитивы ядра, обеспечивающие безопасный одновременный доступ к данным (спин-блокировки, мьютексы, барьеры памяти и т. Д.), Предназначены для предотвращения нежелательной оптимизации. Если они используются должным образом, не будет необходимости использовать также и volatile. Если volatile все еще необходимо, то в коде почти наверняка есть ошибка. В правильно написанном коде ядра volatile может только замедлять работу.

Рассмотрим типичный блок кода ядра:

spin_lock(&the_lock);
do_something_on(&shared_data);
do_something_else_with(&shared_data);
spin_unlock(&the_lock);

Если весь код следует правилам блокировки, значение shared_data не может неожиданно измениться, пока удерживается the_lock. Любой другой код, который может захотеть поиграть с этими данными, будет ожидать блокировки. Примитивы спин-блокировки действуют как барьеры памяти - они явно написаны для этого - это означает, что доступ к данным не будет оптимизирован для них. Таким образом, компилятор может подумать, что он знает, что будет в shared_data, но вызов spin_lock(), поскольку он действует как барьер памяти, заставит его забыть все, что он знает. Не будет проблем с оптимизацией при доступе к этим данным.

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

Класс энергозависимой памяти изначально предназначался для регистров ввода-вывода с отображением в памяти. Внутри ядра доступ к регистрам также должен быть защищен блокировками, но также не требуется, чтобы компилятор "оптимизировал" доступ к регистрам в критической секции. Но в ядре доступ к памяти ввода / вывода всегда осуществляется через функции доступа; доступ к памяти ввода / вывода напрямую через указатели не одобряется и не работает на всех архитектурах. Эти средства доступа написаны для предотвращения нежелательной оптимизации, поэтому, опять же, volatile не нужна.

Другая ситуация, в которой может возникнуть искушение использовать volatile, - это когда процессор занят ожиданием значения переменной. Правильный способ ожидания ожидания:

while (my_variable != what_i_want)
    cpu_relax();

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

Есть еще несколько редких ситуаций, когда volatile имеет смысл в ядре:

  • Вышеупомянутые функции доступа могут использовать volatile на архитектурах, где прямой доступ к памяти ввода-вывода работает. По сути, каждый вызов метода доступа сам по себе становится небольшим критическим разделом и гарантирует, что доступ происходит в соответствии с ожиданиями программиста.

  • Встроенный код сборки, который изменяет память, но не имеет других видимых побочных эффектов, рискует быть удаленным GCC. Добавление ключевого слова volatile в операторы asm предотвратит это удаление.

  • Переменная jiffies является особенной в том смысле, что она может иметь различное значение при каждой ссылке на нее, но она может быть прочитана без какой-либо специальной блокировки. Таким образом, jiffies может быть изменчивым, но добавление других переменных этого типа решительно осуждается. Jiffies считается проблемой "глупого наследия" (слова Линуса) в этом отношении; исправить это будет больше проблем, чем стоит.

  • Указатели на структуры данных в когерентной памяти, которые могут быть изменены устройствами ввода-вывода, иногда могут быть законно изменчивыми. Кольцевой буфер, используемый сетевым адаптером, где этот адаптер меняет указатели, чтобы указать, какие дескрипторы были обработаны, является примером ситуации такого типа.

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

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

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

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

Лично мое основное (только?) Использование для флага volatile - логическое значение pleaseGoAwayNow. Если у меня есть рабочий поток, который зацикливается непрерывно, я буду проверять логическое значение volatile на каждой итерации цикла и завершать работу, если логическое значение когда-либо истинно. Затем основной поток может безопасно очистить рабочий поток, установив для логического значения значение true, а затем вызвав функцию pthread_join(), чтобы дождаться завершения рабочего потока.

volatile полезен (хотя и недостаточен) для реализации базовой конструкции мьютекса спин-блокировки, но если у вас есть это (или что-то превосходное), вам не нужен другой volatile,

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

pthread_mutex_t flag_guard_mutex; // contains something volatile
bool my_shared_flag;

Это не только инкапсулирует "жесткую часть", но и принципиально необходимо: C не включает атомарные операции, необходимые для реализации мьютекса; это только имеет volatile сделать дополнительные гарантии об обычных операциях.

Теперь у вас есть что-то вроде этого:

pthread_mutex_lock( &flag_guard_mutex );
my_local_state = my_shared_flag; // critical section
pthread_mutex_unlock( &flag_guard_mutex );

pthread_mutex_lock( &flag_guard_mutex ); // may alter my_shared_flag
my_shared_flag = ! my_shared_flag; // critical section
pthread_mutex_unlock( &flag_guard_mutex );

my_shared_flag не должен быть изменчивым, несмотря на то, что не кэшируется, потому что

  1. Другой поток имеет к нему доступ.
  2. Это означает, что ссылка на него когда-то была взята (с & оператор).
    • (Или ссылка была взята на содержащую структуру)
  3. pthread_mutex_lock это библиотечная функция
  4. Это означает, что компилятор не может сказать, если pthread_mutex_lock каким-то образом получает эту ссылку.
  5. Это означает, что компилятор должен предполагать, что pthread_mutex_lockмодифицирует общий флаг!
  6. Таким образом, переменная должна быть перезагружена из памяти. volatileНесмотря на то, что имеет смысл в этом контексте, является посторонним.

Ваше понимание действительно неверно.

Свойство, которое имеют изменчивые переменные, "чтение и запись в эту переменную являются частью воспринимаемого поведения программы". Это означает, что эта программа работает (при наличии соответствующего оборудования):

int volatile* reg=IO_MAPPED_REGISTER_ADDRESS;
*reg=1; // turn the fuel on
*reg=2; // ignition
*reg=3; // release
int x=*reg; // fire missiles

Проблема в том, что это не то свойство, которое мы хотим от поточно-ориентированного ничего.

Например, потокобезопасный счетчик будет просто (код, похожий на ядро ​​Linux, не знаю эквивалента C++0x):

atomic_t counter;

...
atomic_inc(&counter);

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

atomic_inc(&counter);
atomic_inc(&counter);

все еще можно оптимизировать для

atomically {
  counter+=2;
}

если оптимизатор достаточно умен (он не меняет семантику кода).

В FAQ по comp.programming.threads есть классическое объяснение Дэйва Бутенхофа:

В56: Почему мне не нужно объявлять общие переменные VOLATILE?

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

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

Так что да, это правда, что компилятор, который строго (но очень агрессивно) соответствует ANSI C, может не работать с несколькими потоками без volatile. Но кому-то лучше это исправить. Потому что любая СИСТЕМА (то есть, прагматически, комбинация ядра, библиотек и компилятора C), которая не обеспечивает гарантий согласованности памяти POSIX, не СООТВЕТСТВУЕТ стандарту POSIX. Период. Система НЕ МОЖЕТ требовать, чтобы вы использовали volatile с общими переменными для корректного поведения, потому что POSIX требует только того, чтобы были необходимы функции синхронизации POSIX.

Так что, если ваша программа не работает из-за того, что вы не использовали volatile, это БАГ. Это может быть не ошибка в C, или ошибка в библиотеке потоков, или ошибка в ядре. Но это системная ошибка, и один или несколько из этих компонентов должны будут исправиться.

Вы не хотите использовать volatile, потому что в любой системе, где это имеет значение, это будет намного дороже, чем правильная энергонезависимая переменная. (ANSI C требует "точек последовательности" для изменчивых переменных в каждом выражении, в то время как POSIX требует их только при операциях синхронизации - многопоточное приложение с интенсивными вычислениями будет видеть значительно большую активность памяти, используя volatile, и, в конце концов, именно активность памяти действительно тормозит тебя.)

/ --- [Дейв Бутенхоф] ----------------------- [butenhof@zko.dec.com] --- \
| Корпорация цифрового оборудования 110 Spit Brook Rd ZKO2-3 / Q18 |
| 603.881.2218, ФАКС 603.881.0120 Nashua NH 03062-2698 |
----------------- [Лучше жить через параллелизм]----------------/

Г-н Бутенхоф охватывает большую часть того же вопроса в этом посте usenet:

Использование "volatile" недостаточно для обеспечения надлежащей видимости памяти или синхронизации между потоками. Использование мьютекса является достаточным, и, за исключением использования различных альтернатив непереносимого машинного кода (или более тонких последствий правил памяти POSIX, которые в целом гораздо сложнее применять, как объяснялось в моем предыдущем посте), НЕОБХОДИМО мьютекс.

Поэтому, как объяснил Брайан, использование volatile не дает ничего, кроме того, что мешает компилятору делать полезные и желательные оптимизации, не оказывая никакой помощи в обеспечении безопасности кода. Конечно, вы можете объявить все, что вы хотите, "изменчивыми" - это, в конце концов, законный атрибут хранения ANSI C. Только не ожидайте, что это решит любые проблемы синхронизации потока для Вас.

Все это в равной степени применимо к C++.

Чтобы ваши данные были согласованными в параллельной среде, вам необходимо выполнить два условия:

1) атомарность, т.е. если я читаю или записываю некоторые данные в память, то эти данные считываются / записываются за один проход и не могут быть прерваны или оспорены, например, из-за переключения контекста

2) Согласованность, т. Е. Порядок операций чтения / записи должен быть одинаковым в нескольких параллельных средах - будь то потоки, машины и т. Д.

volatile не соответствует ни одному из вышеперечисленных - или, более конкретно, стандарт c или C++ относительно того, как volatile должен вести себя, не включает ни одного из вышеперечисленных.

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

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

C# и java AFAIK исправляют это, заставляя volatile придерживаться 1) и 2), однако этого нельзя сказать о компиляторах c/ C++, поэтому в основном поступайте так, как считаете нужным.

Для более глубокого (хотя и не беспристрастного) обсуждения этой темы прочитайте это

Это все, что делает "volatile": "Привет, компилятор, эта переменная может измениться в ЛЮБОЙ МОМЕНТ (при любом такте), даже если НЕТ ЛОКАЛЬНЫХ ИНСТРУКЦИЙ, действующих на него. НЕ кэшируйте это значение в регистре".

Это ЭТО. Он сообщает компилятору, что ваше значение является изменчивым - это значение может быть изменено в любой момент внешней логикой (другой поток, другой процесс, ядро ​​и т. Д.). Он существует более или менее исключительно для подавления оптимизаций компилятора, которые будут молча кэшировать значение в регистре, которое по своей природе небезопасно для КЕШЕГО кэша.

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

Согласно моему старому стандарту C, "то, что представляет собой доступ к объекту, имеющему тип, определяемый volatile, определяется реализацией". Поэтому создатели компилятора C могли выбрать "изменчивый", означающий "потокобезопасный доступ в многопроцессорной среде". Но они этого не сделали.

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

Это означает, что такие вещи, как "изменчивые" семафоры вокруг критических разделов кода, которые не работают на новом оборудовании с новыми компиляторами, могли когда-то работать со старыми компиляторами на старом оборудовании, и старые примеры иногда не ошибаются, а просто старые.

Другие вопросы по тегам