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

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

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

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

Как и где я ошибаюсь?

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

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

Для streamобезопасного доступа к общим данным нам нужна гарантия:

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

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

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

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

Поскольку C ++ 11, атомные переменные ( std::atomic ) дают нам все соответствующие гарантии.

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

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

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

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

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

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

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

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

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

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

 while (my_variable != what_i_want) cpu_relax(); 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Например, поточно-безопасный счетчик будет просто (код, похожий на linux-kernel, не знает эквивалент c ++ 0x):

 atomic_t counter; ... atomic_inc(&counter); 

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

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

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

 atomically { counter+=2; } 

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

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

Типичный способ многопоточного программирования – не защищать каждую общую переменную на уровне машины, а скорее вводить защитные переменные, которые направляют stream программы. Вместо 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. Другой stream имеет к нему доступ.
  2. Значение ссылки на нее должно было быть принято когда-то (с оператором & ).
    • (Или была сделана ссылка на содержащую структуру)
  3. pthread_mutex_lock – это библиотечная функция.
  4. Это означает, что компилятор не может определить, как pthread_mutex_lock каким-то образом получает эту ссылку.
  5. Значение компилятора должно предполагать, что pthread_mutex_lock изменяет общий флаг !
  6. Поэтому переменная должна быть перезагружена из памяти. volatile , хотя и значимая в этом контексте, является посторонней.

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

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

2) Консистенция, то есть порядок операций чтения / записи должен рассматриваться одинаково между несколькими параллельными средами – будь то streamи, машины и т. Д.

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

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

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

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

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

Часто задаваемые вопросы comp.programming.threads имеют classическое объяснение Дэйва Бутенхофа:

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

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

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

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

Поэтому, если ваша программа ломается, потому что вы не использовали volatile, это BUG. Это может быть не ошибка в C, или ошибка в библиотеке нитей, или ошибка в ядре. Но это ошибка SYSTEM, и один или несколько из этих компонентов должны будут работать, чтобы исправить это.

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

/ — [Dave Butenhof] ———————– [[email protected]] — \
| Digital Equipment Corporation 110 Spit Brook Rd ZKO2-3 / Q18 |
| 603.881.2218, FAX 603.881.0120 Nashua NH 03062-2698 |
—————– [Лучше жить через параллелизм] —————- /

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

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

Поэтому, как объяснил Брайан, использование volatile ничего не дает, кроме как предотвратить компилятор от полезной и желательной оптимизации, не оказывая никакой помощи при создании кода «streamобезопасным». Разумеется, вы можете объявить все, что хотите, как «изменчивое» – это законный атрибут хранения ANSI C. Просто не ожидайте, что он решит любые проблемы синхронизации streamов для вас.

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

Согласно моему старому стандарту C, «то, что представляет собой доступ к объекту, который имеет изменчивый тип, определяется реализацией» . Таким образом, авторы компилятора C могли выбрать «волатильный» средний «поточно-безопасный доступ в многопроцессорной среде» . Но они этого не сделали.

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

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

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

Это ИТ. Он сообщает компилятору, что ваше значение, ну, volatile – это значение может быть изменено в любой момент внешней логикой (другой stream, другой процесс, kernel ​​и т. Д.). Он существует более или менее исключительно для подавления оптимизаций компилятора, которые будут тайно кэшировать значение в регистре, которое по своей сути небезопасно для EVER cache.

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

  • Для чего используется ключевое слово «volatile»?
  • Volatile boolean vs AtomicBoolean
  • Interesting Posts

    Когда использовать значения без знака над подписанными?

    Закругленные края в кнопке C # (WinForms)

    Android getText из поля EditText

    В C #, почему объект List не может быть сохранен в переменной List

    Какова максимальная оперативная память, которую может использовать один слот в моем Sony Vaio?

    Перейти к определению функции в vim

    Что говорит о стандарте C о битрейтах больше бит, чем ширина типа?

    Внедрение N технологического барьера с использованием семафоров

    Почему C ++ не использует std :: nested_exception, чтобы позволить метать из деструктора?

    Как обеспечить, чтобы прокси создавались при использовании шаблона репозитория с каркасом сущности?

    Удалите повторяющиеся строки в excel, если не все столбцы одинаковы

    Как правильно установить ant 1.8 на Ubuntu 11.04

    CoffeeScript, Когда использовать стрелку жира (=>) над стрелкой (->) и наоборот

    Ошибка Java. Фактические и формальные списки аргументов различаются по длине

    Что такое «Обычный тип» в контексте семантики перемещения?

    Давайте будем гением компьютера.