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

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

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

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

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

Ответ 1

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

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

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

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

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

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

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

Так как С++ 11, атомные переменные (std::atomic<T>) дают нам все соответствующие гарантии.

Ответ 2

Вы можете также рассмотреть это из документации ядра 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, должны сделать шаг назад и подумать о том, чего они действительно пытаются достичь.

Ответ 3

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

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

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

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

Ответ 4

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

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

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-kernel, не знает эквивалент С++ 0x):

atomic_t counter;

...
atomic_inc(&counter);

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

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

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

atomically {
  counter+=2;
}

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

Ответ 5

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 необязательно быть неустойчивым, несмотря на то, что он не доступен, потому что

  • Другой поток имеет к нему доступ.
  • Значение ссылки на нее должно быть принято когда-то (с оператором &).
    • (Или ссылка была сделана на содержащую структуру)
  • pthread_mutex_lock - это библиотечная функция.
  • Значение компилятора не может определить, как pthread_mutex_lock каким-то образом получает эту ссылку.
  • Значение компилятора должно предполагать, что pthread_mutex_lock изменяет общий флаг!
  • Поэтому переменная должна быть перезагружена из памяти. volatile, хотя и имеет смысл в этом контексте, является посторонним.

Ответ 6

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

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

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

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

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

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

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

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

Ответ 7

Часто задаваемые вопросы comp.programming.threads классическое объяснение от Dave Butenhof:

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

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

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

Итак, да, это правда, что компилятор, который строго соответствует (но очень агрессивно), чтобы ANSI C не работал с несколькими потоками без неустойчивый. Но кто-то лучше исправить это. Потому что любая СИСТЕМА (то есть, прагматически, комбинация ядра, библиотек и компилятора 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

----------------- [Улучшение жизни через Concurrency] ----------------/

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

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

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

Все, что одинаково применимо к С++.

Ответ 8

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

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

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

Ответ 9

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

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

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