Вводит ли ключевое слово volatile в C ++ забор памяти?


86

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

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


Есть интересное обсуждение этого связанного вопроса

Джонатан Уэйкли пишет :

... Доступ к различным изменчивым переменным не может быть переупорядочен компилятором, если они встречаются в отдельных полных выражениях ... верно, что volatile бесполезен для безопасности потоков, но не по причинам, которые он приводит. Это не потому, что компилятор может изменить порядок доступа к изменчивым объектам, а потому, что ЦП может их изменить. Атомарные операции и барьеры памяти не позволяют компилятору и процессору переупорядочивать

На что Дэвид Шварц отвечает в комментариях :

... С точки зрения стандарта C ++, нет никакой разницы между компилятором, который что-то делает, и компилятором, выдающим инструкции, которые заставляют оборудование что-то делать. Если ЦП может изменить порядок доступа к летучим объектам, то стандарт не требует сохранения их порядка. ...

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

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

Возникает два вопроса: «Правильно» ли кто-то из них? Что на самом деле делают реальные реализации?


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


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

Насколько мне известно, volatile используется для переменных, которые могут быть изменены аппаратно (часто используется с микроконтроллерами). Это просто означает, что чтение переменной не может быть выполнено в другом порядке и не может быть оптимизировано. Хотя это C, но должно быть то же самое в ++.
Mast

1
@Mast Я еще не видел компилятора, который предотвращает volatileоптимизацию чтения переменных кешами ЦП. Либо все эти компиляторы не соответствуют требованиям, либо стандарт не означает то, что вы думаете. (Стандарт не делает различий между тем, что делает компилятор, и тем, что компилятор заставляет делать ЦП. Задача компилятора - генерировать код, который при запуске соответствует стандарту.)
Дэвид Шварц,

Ответы:


58

Вместо того, чтобы объяснять, что volatileделает, позвольте мне объяснить, когда вам следует использовать volatile.

  • Когда внутри обработчика сигнала. Поскольку запись в volatileпеременную - это практически единственное, что стандарт позволяет вам делать из обработчика сигналов. Начиная с C ++ 11, вы можете использовать std::atomicдля этой цели, но только если атомар не заблокирован.
  • При работе с setjmp Intel .
  • Когда вы имеете дело непосредственно с оборудованием, и вы хотите убедиться, что компилятор не оптимизирует ваши чтения или записи.

Например:

volatile int *foo = some_memory_mapped_device;
while (*foo)
    ; // wait until *foo turns false

Без volatileспецификатора компилятору разрешено полностью оптимизировать цикл. Спецификатор volatileсообщает компилятору, что он не может предполагать, что 2 последующих чтения вернут одно и то же значение.

Обратите внимание, что volatile это не имеет ничего общего с потоками. Приведенный выше пример не работает, если другой поток записывал в*foo потому что не задействована операция получения.

Во всех других случаях использование volatileдолжно рассматриваться как непереносимое и больше не проходить проверку кода, за исключением случаев, когда речь идет о компиляторах до C ++ 11 и расширениях компилятора (таких как /volatile:msпереключатель msvc , который включен по умолчанию в X86 / I64).


5
Это строже, чем «нельзя предполагать, что 2 последующих чтения вернут одно и то же значение». Даже если вы прочитали только один раз и / или выбросили значение (я), чтение должно быть выполнено.
Филипси

1
Использование в обработчиках сигналов и setjmp- это две гарантии, которые обеспечивает стандарт. С другой стороны, целью , по крайней мере с самого начала, была поддержка ввода-вывода с отображением памяти. Что на некоторых процессорах может потребовать ограждения или мембраны.
Джеймс Канце

@philipxy Только никто не знает, что означает "прочитанное". Например, никто не верит, что фактическое чтение из памяти должно выполняться - ни один из известных мне компиляторов не пытается обходить кеши ЦП при volatileдоступе.
Дэвид Шварц

@JamesKanze: Это не так. В отношении обработчиков сигналов стандарт говорит, что во время обработки сигналов только volatile std :: sig_atomic_t & lock-free атомарные объекты имеют определенные значения. Но в нем также говорится, что доступ к изменчивым объектам является наблюдаемым побочным эффектом.
Филипси

1
@DavidSchwartz Некоторые пары «компилятор-архитектура» отображают стандартную последовательность доступа к реальным эффектам, а рабочие программы обращаются к изменчивым, чтобы получить эти эффекты. Тот факт, что некоторые такие пары не имеют сопоставления или тривиального бесполезного сопоставления, имеет отношение к качеству реализаций, но не к сути.
Филипси

25

Вводит ли ключевое слово volatile в C ++ забор памяти?

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

Функция "volatile" в C ++ не имеет ничего общего с потоками. Помните, что цель «volatile» - отключить оптимизацию компилятора, чтобы чтение из регистра, которое изменяется из-за внешних условий, не оптимизировалось. Является ли адрес памяти, в который записывается другой поток на другом процессоре, регистром, который изменяется из-за внешних условий? Нет. Опять же, если некоторые авторы компиляторов решили рассматривать адреса памяти, в которые записываются разные потоки на разных процессорах, как если бы регистры менялись из-за внешних условий, это их дело; они не обязаны этого делать. Они также не требуются - даже если это вводит ограждение памяти - например, для обеспечения каждого поток видит согласованный упорядочивания энергозависимых операций чтения и записи.

Фактически, volatile практически бесполезен для многопоточности в C / C ++. Лучше всего избегать этого.

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

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


19
«volatile практически бесполезен в C / C ++». Не за что! У вас есть взгляд на мир, ориентированный на пользовательский режим, но большая часть кода C и C ++ выполняется во встроенных системах, где volatile очень необходимы для ввода-вывода с отображением в память.
Ben Voigt

12
И причина того, что энергозависимый доступ сохраняется, не просто в том, что внешние условия могут изменять ячейки памяти. Сам доступ может вызвать дальнейшие действия. Например, очень часто чтение продвигает FIFO или очищает флаг прерывания.
Бен Фойгт

3
@BenVoigt: Я имел в виду бесполезность для эффективного решения проблем с потоками.
Эрик Липперт,

4
@DavidSchwartz Стандарт, очевидно, не может гарантировать, как работает ввод-вывод с отображением памяти. Но ввод-вывод с отображением памяти - вот почему volatileбыл введен в стандарт C. Тем не менее, поскольку стандарт не может указывать такие вещи, как то, что на самом деле происходит при «доступе», он говорит, что «то, что составляет доступ к объекту, который имеет тип с изменяемым типом, определяется реализацией». Слишком много реализаций сегодня не предоставляют полезного определения доступа, что ИМХО нарушает дух стандарта, даже если он соответствует букве.
Джеймс Канце

8
Это изменение является определенным улучшением, но ваше объяснение все еще слишком сосредоточено на том, что «память может быть изменена экзогенно». volatileсемантика более сильная, компилятор должен генерировать каждый запрошенный доступ (1.9 / 8, 1.9 / 12), а не просто гарантировать, что в конечном итоге будут обнаружены экзогенные изменения (1.10 / 27). В мире ввода-вывода с отображением в память чтение из памяти может иметь произвольную связанную логику, например средство получения свойства. Вы не будете оптимизировать вызовы методов получения свойств в соответствии с указанными вами правилами volatile, и Стандарт не позволяет этого.
Ben Voigt

13

Дэвид упускает из виду тот факт, что стандарт C ++ определяет поведение нескольких потоков, взаимодействующих только в определенных ситуациях, а все остальное приводит к неопределенному поведению. Состояние гонки, включающее хотя бы одну запись, не определено, если вы не используете атомарные переменные.

Следовательно, компилятор имеет полное право отказаться от любых инструкций по синхронизации, поскольку ваш ЦП заметит разницу только в программе, которая демонстрирует неопределенное поведение из-за отсутствия синхронизации.


5
Красиво объяснено, спасибо. Стандарт определяет только последовательность обращений к летучим объектам как наблюдаемую, пока программа не имеет неопределенного поведения .
Джонатан Уэйкли

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

Как вы думаете, почему я не замечаю этого? Как вы думаете, какая часть моего аргумента не имеет силы? Я на 100% согласен с тем, что компилятор вправе отказаться от любой синхронизации.
Дэвид Шварц

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

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

12

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

Что касается реализации изменчивого доступа, это выбор компилятора.

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

Что касается поведения icc, я обнаружил, что этот источник также сообщает, что volatile не гарантирует упорядоченного доступа к памяти.

У компилятора Microsoft VS2013 другое поведение. В этой документации объясняется, как volatile обеспечивает семантику Release / Acquire и позволяет использовать изменчивые объекты в блокировках / выпусках в многопоточных приложениях.

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

Итак, мой ответ:

Вводит ли ключевое слово volatile в C ++ забор памяти?

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


2
Это не препятствует оптимизации, а просто не позволяет компилятору изменять загрузки и сохранения за пределами определенных ограничений.
Дитрих Эпп,

Непонятно о чем ты говоришь. Вы говорите, что это случается с некоторыми неопределенными компиляторами, что не volatileпозволяет компилятору переупорядочивать загрузки / сохранения? Или вы говорите, что этого требует стандарт C ++? А если последнее, можете ли вы ответить на мой аргумент об обратном, приведенный в исходном вопросе?
Дэвид Шварц

@DavidSchwartz Стандарт предотвращает переупорядочение (из любого источника) доступа через volatilelvalue. Однако, поскольку определение «доступа» остается на усмотрение реализации, это не дает нам многого, если реализация не заботится.
Джеймс Канце

Я думаю, что в некоторых версиях компиляторов MSC реализована семантика ограничения volatile, но в сгенерированном коде компилятора Visual Studios 2012 нет
ограничения.

@JamesKanze Что в основном означает, что единственное переносимое поведение volatile- это то, что специально перечислено стандартом. ( setjmp, сигналы и т. д.)
Дэвид Шварц

7

Насколько мне известно, компилятор только вставляет ограждение памяти в архитектуру Itanium.

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


1
Вроде, как бы, что-то вроде. 'компилятор' (msvc) вставляет ограждение памяти, когда нацелена архитектура, отличная от ARM, и используется переключатель / volatile: ms (по умолчанию). См. Msdn.microsoft.com/en-us/library/12a04hfd.aspx . Насколько мне известно, другие компиляторы не ограничивают изменчивые переменные. Следует избегать использования volatile, кроме случаев, когда речь идет непосредственно об оборудовании, обработчиках сигналов или компиляторах, не соответствующих C ++ 11.
Стефан

@Stefan No. volatileчрезвычайно полезен для многих целей, никогда не связанных с оборудованием. Если вы хотите, чтобы реализация генерировала код ЦП, который точно следует коду C / C ++, используйте volatile.
curiousguy

7

Это зависит от того, какой компилятор «компилятор». Visual C ++ делает это с 2005 года. Но Стандарт не требует этого, поэтому некоторые другие компиляторы этого не требуют.


VC ++ 2012 не кажется , чтобы вставить забор: int volatile i; int main() { return i; }генерирует главный ровно две инструкции: mov eax, i; ret 0;.
Джеймс Канце

@JamesKanze: Какая именно версия? И используете ли вы какие-либо параметры компиляции не по умолчанию? Я полагаюсь на документацию (первая затронутая версия) и (последняя версия) , в которых определенно упоминается семантика получения и выпуска.
Бен Фойгт,

cl /helpговорит версия 18.00.21005.1. Каталог, в котором он находится C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC. В заголовке командного окна написано VS 2013. Итак, что касается версии ... Я использовал только следующие параметры /c /O2 /Fa. (Без /O2него также устанавливается локальный фрейм стека. Но инструкции по ограждению все еще нет.)
Джеймс Канце

@JamesKanze: Меня больше интересовала архитектура, например, "Оптимизирующий компилятор Microsoft (R) C / C ++ версии 18.00.30723 для x64" Возможно, здесь нет препятствий, потому что x86 и x64 изначально имеют довольно сильные гарантии согласованности кеша в их модели памяти. ?
Бен Фойгт,

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

5

Это в основном из памяти и основано на pre-C ++ 11, без потоков. Но, участвуя в обсуждениях в комитете, я могу сказать, что у комитета никогда не было намерения, чтобыvolatile можно было бы использовать для синхронизации между потоками. Microsoft предложила это, но предложение не получилось.

Ключевая спецификация volatileзаключается в том, что доступ к изменчивому объекту представляет собой «наблюдаемое поведение», как и ввод-вывод. Точно так же компилятор не может изменить порядок или удалить определенный ввод-вывод, он не может изменить порядок или удалить доступ к изменчивому объекту (или, точнее, доступ через выражение lvalue с изменчивым квалифицированным типом). Первоначальная цель volatile заключалась в том, чтобы поддерживать ввод-вывод с отображением памяти. Однако «проблема» в том, что это определяется реализацией, что составляет «изменчивый доступ». И многие компиляторы реализуют это так, как если бы определение было «выполнена инструкция, которая читает или записывает в память». Это законное, хотя и бесполезное определение, если это указано в реализации. (Мне еще не удалось найти фактическую спецификацию для любого компилятора.

Возможно (и это аргумент, который я принимаю), это нарушает намерение стандарта, поскольку, если оборудование не распознает адреса как ввод-вывод с отображением памяти и не запрещает любое переупорядочение и т. Д., Вы даже не можете использовать volatile для ввода-вывода с отображением памяти, по крайней мере, на архитектурах Sparc или Intel. Тем не менее, ни один из компиляторов, на которые я смотрел (Sun CC, g ++ и MSC), не выводит никаких инструкций ограждения или мембраны. (Примерно в то время, когда Microsoft предлагала расширить правила volatile, я думаю, что некоторые из их компиляторов реализовали свое предложение и выдавали инструкции по ограничению для изменчивого доступа. Я не проверял, что делают последние компиляторы, но меня не удивит, если это зависит от на каком-то параметре компилятора. Версия, которую я проверял - я думаю, это была VS6.0 - однако, не создавала препятствий.)


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

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

1
@DavidSchwartz: для монопольного (или мьютексного) ввода-вывода с отображением памяти на периферийные устройства volatileсемантика вполне адекватна. Обычно такие периферийные устройства сообщают, что их области памяти не кэшируются, что помогает при переупорядочении на аппаратном уровне.
Бен Фойгт,

@BenVoigt Я как-то задумался об этом: идея, что процессор каким-то образом «знает», что адрес, с которым он имеет дело, - это ввод-вывод с отображением в память. Насколько мне известно, Sparcs не поддерживают это, поэтому Sun CC и g ++ на Sparc по-прежнему будут непригодны для ввода-вывода с отображением памяти. (Когда я изучал это, меня в основном интересовал Sparc.)
Джеймс Канце

@JamesKanze: Судя по тому небольшому поиску, который я провел, похоже, что у Sparc есть выделенные диапазоны адресов для «альтернативных представлений» памяти, которые не кэшируются. Пока ваши изменчивые точки доступа в ASI_REAL_IOчасть адресного пространства, я думаю, с вами все будет в порядке. (Altera NIOS использует похожую технику с старшими битами адреса, управляющими обходом MMU; я уверен, что есть и другие)
Бен Фойгт,

5

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

Небольшое объяснение относительно барьеров памяти. Типичный ЦП имеет несколько уровней доступа к памяти. Есть конвейер памяти, несколько уровней кеша, затем ОЗУ и т. Д.

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

Кэш (ы) обычно автоматически согласовываются между процессорами. Если кто-то хочет убедиться, что кеш синхронизирован с ОЗУ, необходима очистка кеша. Это очень отличается от мембраны.


1
Значит, вы говорите, что стандарт C ++ volatileпросто отключает оптимизацию компилятора? В этом нет никакого смысла. Любая оптимизация, которую может сделать компилятор, может, по крайней мере, в принципе, в равной степени выполняться процессором. Поэтому, если в стандарте говорится, что он просто отключил оптимизацию компилятора, это означало бы, что он не обеспечит поведения, на которое можно было бы положиться в переносимом коде. Но это, очевидно, не так, потому что переносимый код может полагаться на свое поведение в отношении setjmpсигналов и.
Дэвид Шварц

1
@DavidSchwartz Нет, в стандарте такого не сказано. Отключение оптимизации - это то, что обычно делается для реализации стандарта. Стандарт требует, чтобы наблюдаемое поведение происходило в том же порядке, что и абстрактная машина. Когда абстрактная машина не требует какого-либо порядка, реализация может использовать любой порядок или вообще не использовать его. Доступ к изменчивым переменным в разных потоках не упорядочивается, если не применяется дополнительная синхронизация.
п. 'местоимения' м.

1
@DavidSchwartz Прошу прощения за неточность формулировок. Стандарт не требует отключения оптимизаций. Он вообще не имеет понятия об оптимизации. Скорее, он определяет поведение, которое на практике требует от компиляторов отключения определенных оптимизаций таким образом, чтобы наблюдаемая последовательность чтения и записи соответствовала стандарту.
п. 'местоимения' м.

1
За исключением того, что этого не требуется, потому что стандарт разрешает реализациям определять «наблюдаемую последовательность чтения и записи», как они хотят. Если реализации предпочитают определять наблюдаемые последовательности, так что оптимизации должны быть отключены, то они это делают. Если нет, то нет. Вы получаете предсказуемую последовательность чтения и записи тогда и только тогда, когда реализация решила предоставить ее вам.
Дэвид Шварц

1
Нет, реализация должна определить, что представляет собой единый доступ. Последовательность таких обращений задается абстрактной машиной. Реализация должна сохранять порядок. В стандарте прямо говорится, что «volatile - это подсказка реализации, позволяющая избежать агрессивной оптимизации, связанной с объектом», хотя и в ненормативной части, но цель ясна.
п. 'местоимения' м.

4

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

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


2

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

Я делаю это для ОЗУ, а также для ввода-вывода с отображением памяти.

Основываясь на обсуждении здесь, кажется, что это все еще допустимое использование volatile, но не имеет ничего общего с несколькими потоками или процессорами. Если компилятор для микроконтроллера «знает», что других доступов быть не может (например, все на кристалле, нет кеша и есть только одно ядро), я бы подумал, что ограничение памяти вообще не подразумевается, компилятор просто нужно предотвратить определенные оптимизации.

По мере того, как мы накапливаем все больше вещей в «систему», которая выполняет объектный код, почти все ставки отключены, по крайней мере, так я прочитал это обсуждение. Как компилятор мог покрыть все основы?


0

Я думаю, что путаница с изменяемым порядком и переупорядочением инструкций проистекает из двух понятий переупорядочивания, которые делают процессоры:

  1. Внеочередное исполнение.
  2. Последовательность чтения / записи в память с точки зрения других процессоров (переупорядочение в том смысле, что каждый процессор может видеть другую последовательность).

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

Внеочередное исполнение

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

Последовательность чтения / записи памяти с точки зрения других процессоров

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

Источники:


0

Пока я работал над загружаемым онлайн-видеоуроком по разработке 3D-графики и игрового движка, работая с современным OpenGL. Мы использовали volatileв одном из наших классов. Веб-сайт с учебным курсом можно найти здесь, а видео о работе с volatileключевым словом можно найти в Shader Engineсерии видео 98. Эти работы не являются моими собственными, но аккредитованы, Marek A. Krzeminski, MAScи это отрывок со страницы загрузки видео.

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

И если вы подписаны на его веб-сайт и имеете доступ к его видео в этом видео, он ссылается на эту статью, касающуюся использования Volatileс multithreadingпрограммированием.

Вот статья по ссылке выше: http://www.drdobbs.com/cpp/volatile-the-multithreaded-programmers-b/184403766

volatile: лучший друг многопоточного программиста

Текст: Андрей Александреску, 1 февраля 2001 г.

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

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

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

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

Просто небольшое ключевое слово

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

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

Рассмотрим следующий код:

class Gadget {
public:
    void Wait() {
        while (!flag_) {
            Sleep(1000); // sleeps for 1000 milliseconds
        }
    }
    void Wakeup() {
        flag_ = true;
    }
    ...
private:
    bool flag_;
};

Целью Gadget :: Wait, описанного выше, является проверка переменной-члена flag_ каждую секунду и возврат, когда эта переменная была установлена ​​в значение true другим потоком. По крайней мере, так задумал его программист, но, увы, Wait неверен.

Предположим, компилятор выясняет, что Sleep (1000) - это вызов внешней библиотеки, которая не может изменить переменную-член flag_. Затем компилятор заключает, что он может кэшировать flag_ в регистре и использовать этот регистр вместо доступа к более медленной встроенной памяти. Это отличная оптимизация для однопоточного кода, но в данном случае она вредит правильности: после вызова Wait для некоторого объекта Gadget, хотя другой поток вызывает Wakeup, Wait будет зацикливаться навсегда. Это связано с тем, что изменение flag_ не будет отражено в регистре, который кэширует flag_. Оптимизация слишком ... оптимистична.

Кэширование переменных в регистрах - очень ценная оптимизация, которая применяется большую часть времени, поэтому было бы жаль тратить ее зря. C и C ++ дают вам возможность явно отключить такое кеширование. Если вы используете модификатор volatile для переменной, компилятор не будет кэшировать эту переменную в регистрах - каждый доступ будет касаться фактического места в памяти этой переменной. Итак, все, что вам нужно сделать, чтобы комбинация Ожидание / Пробуждение Гаджета заработала, - это соответствующим образом квалифицировать flag_:

class Gadget {
public:
    ... as above ...
private:
    volatile bool flag_;
};

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

Использование volatile с пользовательскими типами

Вы можете квалифицировать изменчивые не только примитивные типы, но и типы, определяемые пользователем. В этом случае volatile изменяет тип аналогично const. (Вы также можете одновременно применять const и volatile к одному и тому же типу.)

В отличие от const, volatile различает примитивные типы и типы, определяемые пользователем. А именно, в отличие от классов, примитивные типы по-прежнему поддерживают все свои операции (сложение, умножение, присваивание и т. Д.), Если они определены как изменчивые. Например, вы можете назначить энергонезависимое int для volatile int, но вы не можете назначить энергонезависимый объект изменчивому объекту.

Проиллюстрируем, как volatile работает с пользовательскими типами на примере.

class Gadget {
public:
    void Foo() volatile;
    void Bar();
    ...
private:
    String name_;
    int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;

Если вы думаете, что volatile не так полезен с объектами, приготовьтесь к сюрпризу.

volatileGadget.Foo(); // ok, volatile fun called for
                  // volatile object
regularGadget.Foo();  // ok, volatile fun called for
                  // non-volatile object
volatileGadget.Bar(); // error! Non-volatile function called for
                  // volatile object!

Преобразование неквалифицированного типа в его изменчивый аналог тривиально. Однако, как и в случае с const, вы не можете вернуться из изменчивого состояния в неквалифицированное. Вы должны использовать гипс:

Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar(); // ok

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

изменчивые, критические секции и состояния гонки

Самым простым и наиболее часто используемым устройством синхронизации в многопоточных программах является мьютекс. Мьютекс предоставляет примитивы Acquire и Release. Как только вы вызываете Acquire в каком-либо потоке, любой другой поток, вызывающий Acquire, будет заблокирован. Позже, когда этот поток вызывает Release, будет освобожден ровно один поток, заблокированный при вызове Acquire. Другими словами, для данного мьютекса только один поток может получить процессорное время между вызовом Acquire и вызовом Release. Код, выполняемый между вызовом Acquire и вызовом Release, называется критическим разделом. (Терминология Windows немного сбивает с толку, потому что она называет сам мьютекс критическим разделом, тогда как «мьютекс» на самом деле является мьютексом между процессами. Было бы хорошо, если бы они назывались мьютексом потока и мьютексом процесса.)

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

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

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

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

Вы входите в критическую секцию, блокируя мьютекс. Вы удаляете квалификатор volatile из типа, применяя const_cast. Если нам удастся объединить эти две операции, мы создадим связь между системой типов C ++ и семантикой потоковой передачи приложения. Мы можем заставить компилятор проверять условия гонки за нас.

LockingPtr

Нам нужен инструмент, который собирает захват мьютекса и const_cast. Давайте разработаем шаблон класса LockingPtr, который вы инициализируете с помощью изменчивого объекта obj и мьютекса mtx. В течение своего времени существования LockingPtr сохраняет полученные mtx. Кроме того, LockingPtr предлагает доступ к объекту obj. Доступ предоставляется в виде интеллектуального указателя через operator-> и operator *. Const_cast выполняется внутри LockingPtr. Приведение является семантически допустимым, поскольку LockingPtr сохраняет полученный мьютекс на протяжении всего его срока службы.

Сначала давайте определим скелет класса Mutex, с которым будет работать LockingPtr:

class Mutex {
public:
    void Acquire();
    void Release();
    ...    
};

Чтобы использовать LockingPtr, вы реализуете Mutex, используя собственные структуры данных и примитивные функции вашей операционной системы.

В шаблоне LockingPtr указан тип контролируемой переменной. Например, если вы хотите управлять Widget, вы используете LockingPtr, который вы инициализируете переменной типа volatile Widget.

Определение LockingPtr очень простое. LockingPtr реализует простой интеллектуальный указатель. Он ориентирован исключительно на сбор const_cast и критического раздела.

template <typename T>
class LockingPtr {
public:
    // Constructors/destructors
    LockingPtr(volatile T& obj, Mutex& mtx)
      : pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {    
        mtx.Lock();    
    }
    ~LockingPtr() {    
        pMtx_->Unlock();    
    }
    // Pointer behavior
    T& operator*() {    
        return *pObj_;    
    }
    T* operator->() {   
        return pObj_;   
    }
private:
    T* pObj_;
    Mutex* pMtx_;
    LockingPtr(const LockingPtr&);
    LockingPtr& operator=(const LockingPtr&);
};

Несмотря на свою простоту, LockingPtr является очень полезным помощником в написании правильного многопоточного кода. Вы должны определить объекты, которые совместно используются потоками как изменчивые, и никогда не использовать с ними const_cast - всегда используйте автоматические объекты LockingPtr. Проиллюстрируем это на примере.

Скажем, у вас есть два потока, которые совместно используют векторный объект:

class SyncBuf {
public:
    void Thread1();
    void Thread2();
private:
    typedef vector<char> BufT;
    volatile BufT buffer_;
    Mutex mtx_; // controls access to buffer_
};

Внутри функции потока вы просто используете LockingPtr для получения контролируемого доступа к переменной-члену buffer_:

void SyncBuf::Thread1() {
    LockingPtr<BufT> lpBuf(buffer_, mtx_);
    BufT::iterator i = lpBuf->begin();
    for (; i != lpBuf->end(); ++i) {
        ... use *i ...
    }
}

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

Приятно то, что если вы допустите ошибку, компилятор укажет на нее:

void SyncBuf::Thread2() {
    // Error! Cannot access 'begin' for a volatile object
    BufT::iterator i = buffer_.begin();
    // Error! Cannot access 'end' for a volatile object
    for ( ; i != lpBuf->end(); ++i ) {
        ... use *i ...
    }
}

Вы не можете получить доступ к какой-либо функции buffer_, пока не примените const_cast или не используете LockingPtr. Разница в том, что LockingPtr предлагает упорядоченный способ применения const_cast к изменчивым переменным.

LockingPtr замечательно выразителен. Если вам нужно вызвать только одну функцию, вы можете создать безымянный временный объект LockingPtr и использовать его напрямую:

unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}

Вернуться к примитивным типам

Мы увидели, как хорошо volatile защищает объекты от неконтролируемого доступа и как LockingPtr обеспечивает простой и эффективный способ написания поточно-ориентированного кода. Теперь вернемся к примитивным типам, которые по-разному обрабатываются volatile.

Рассмотрим пример, в котором несколько потоков совместно используют переменную типа int.

class Counter {
public:
    ...
    void Increment() { ++ctr_; }
    void Decrement() { —ctr_; }
private:
    int ctr_;
};

Если Increment и Decrement должны вызываться из разных потоков, то в приведенном выше фрагменте есть ошибки. Во-первых, ctr_ должен быть изменчивым. Во-вторых, даже кажущаяся атомарной операция, такая как ++ ctr_, на самом деле является трехэтапной. Сама память не имеет арифметических возможностей. При увеличении переменной процессор:

  • Читает эту переменную в регистре
  • Увеличивает значение в регистре
  • Записывает результат обратно в память

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

Если в это время другой процессор выполняет операцию RMW с той же переменной, мы имеем состояние гонки: вторая запись перезаписывает эффект первой.

Чтобы избежать этого, вы можете снова положиться на LockingPtr:

class Counter {
public:
    ...
    void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
    void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
    volatile int ctr_;
    Mutex mtx_;
};

Теперь код правильный, но его качество хуже, чем у кода SyncBuf. Почему? Потому что с Counter компилятор не предупредит вас, если вы по ошибке получите прямой доступ к ctr_ (без его блокировки). Компилятор компилирует ++ ctr_, если ctr_ является изменчивым, хотя сгенерированный код просто неверен. Компилятор больше не ваш союзник, и только ваше внимание может помочь вам избежать состояния гонки.

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

volatile функции-члены

До сих пор у нас были классы, которые собирают изменчивые элементы данных; Теперь давайте подумаем о разработке классов, которые, в свою очередь, станут частью более крупных объектов и будут совместно использоваться потоками. Здесь могут быть очень полезны изменчивые функции-члены.

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

Например, вы определяете класс Widget, который реализует операцию в двух вариантах - поточно-ориентированном и быстром, незащищенном.

class Widget {
public:
    void Operation() volatile;
    void Operation();
    ...
private:
    Mutex mtx_;
};

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

При реализации изменчивой функции-члена первой операцией обычно является блокировка ее с помощью LockingPtr. Затем работа выполняется с использованием энергонезависимого родственника:

void Widget::Operation() volatile {
    LockingPtr<Widget> lpThis(*this, mtx_);
    lpThis->Operation(); // invokes the non-volatile function
}

Резюме

При написании многопоточных программ вы можете использовать volatile в своих интересах. Вы должны придерживаться следующих правил:

  • Определите все общие объекты как изменчивые.
  • Не используйте volatile напрямую с примитивными типами.
  • При определении общих классов используйте изменчивые функции-члены для выражения безопасности потоков.

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

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

Благодарности

Большое спасибо Джеймсу Канце и Сорину Джиану, которые помогли с проницательными идеями.


Андрей Александреску - менеджер по развитию компании RealNetworks Inc. (www.realnetworks.com) из Сиэтла, штат Вашингтон, и автор знаменитой книги «Современный дизайн на C ++». С ним можно связаться на сайте www.moderncppdesign.com. Андрей также является одним из ведущих преподавателей семинара по C ++ (www.gotw.ca/cpp_seminar).

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


0

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

Это также означает, что не следует ожидать, что чтение приведет к предсказуемому значению: компилятор не должен ничего предполагать о чтении даже сразу после записи в тот же изменчивый объект:

volatile int i;
i = 1;
int j = i; 
if (j == 1) // not assumed to be true

volatileможет быть самым важным инструментом в наборе инструментов «C - язык ассемблера высокого уровня» .

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

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

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.