Вводит ли ключевое слово volatile в C++ ограничение памяти?
Я это понимаю volatile
информирует компилятор о том, что значение может быть изменено, но для того, чтобы реализовать эту функцию, нужно ли компилятору ввести ограничение памяти, чтобы оно работало?
Насколько я понимаю, последовательность операций над изменчивыми объектами не может быть переупорядочена и должна быть сохранена. Это, кажется, подразумевает, что некоторые ограждения памяти необходимы, и что на самом деле нет никакого способа обойти это. Правильно ли я сказал это?
Есть интересная дискуссия по этому смежному вопросу
... Доступ к различным переменным volatile не может быть переупорядочен компилятором, если они происходят в отдельных полных выражениях... верно, что volatile бесполезна для безопасности потока, но не по причинам, которые он приводит. Это не потому, что компилятор может изменить порядок доступа к изменчивым объектам, а потому, что процессор может изменить их порядок. Атомарные операции и барьеры памяти препятствуют переупорядочению компилятора и процессора
На что David Schwartz отвечает в комментариях:
... Нет никакой разницы, с точки зрения стандарта C++, между компилятором, который что-то делает, и компилятором, выдающим инструкции, которые заставляют аппаратные средства что-то делать. Если процессор может изменить порядок доступа к летучим, то стандарт не требует сохранения их порядка....
... Стандарт C++ не делает никаких различий относительно того, что делает переупорядочение. И вы не можете утверждать, что процессор может изменить их порядок без видимого эффекта, так что все в порядке - стандарт C++ определяет их порядок как наблюдаемый. Компилятор совместим со стандартом C++ на платформе, если он генерирует код, который заставляет платформу делать то, что требует стандарт. Если стандарт требует, чтобы доступ к летучим веществам не переупорядочивался, то платформа, переупорядочивающая их, не соответствует требованиям....
Я хочу сказать, что если стандарт C++ запрещает компилятору переупорядочивать доступы к разным волатильным версиям, исходя из теории, что порядок таких доступов является частью наблюдаемого поведения программы, то он также требует, чтобы компилятор испускал код, запрещающий процессору делать так. Стандарт не проводит различий между тем, что делает компилятор, и тем, что генерирует код компилятора, который делает процессор.
Что приводит к двум вопросам: является ли один из них "правильным"? Что на самом деле делают реальные реализации?
13 ответов
Вместо того, чтобы объяснять, что 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 и расширениях компилятора (таких как msvc's /volatile:ms
переключатель, который включен по умолчанию в X86/I64).
Вводит ли ключевое слово volatile в C++ ограничение памяти?
Компилятор C++, который соответствует спецификации, не требует введения ограничения памяти. Ваш конкретный компилятор может; направьте ваш вопрос авторам вашего компилятора.
Функция "volatile" в C++ не имеет ничего общего с многопоточностью. Помните, что цель "volatile" состоит в том, чтобы отключить оптимизацию компилятора, чтобы чтение из регистра, который изменяется из-за внешних условий, не оптимизировалось. Является ли адрес памяти, который записывается другим потоком на другом процессоре, регистром, который изменяется из-за внешних условий? Нет. Опять же, если некоторые авторы компилятора решили обрабатывать адреса памяти, записываемые разными потоками на разных ЦП, как если бы они были изменениями регистров из-за внешних условий, это их дело; они не обязаны это делать. Они также не требуются - даже если это вводит ограничение памяти - для того, чтобы, например, гарантировать, что каждый поток видит последовательный порядок изменчивых операций чтения и записи.
Фактически, volatile практически бесполезен для потоков в C/C++. Лучшая практика - избегать этого.
Более того: ограждения памяти являются деталями реализации определенных архитектур процессоров. В C#, где volatile явно предназначено для многопоточности, в спецификации не сказано, что будут введены ползагородки, потому что программа может работать на архитектуре, которая не имеет заборов. Скорее, опять же, спецификация дает определенные (крайне слабые) гарантии того, какие оптимизации будут избегать компилятор, среда выполнения и ЦП, чтобы наложить определенные (крайне слабые) ограничения на порядок упорядочения некоторых побочных эффектов. На практике эти оптимизации устраняются путем использования половинных ограждений, но это деталь реализации, которая может измениться в будущем.
Тот факт, что вы заботитесь о семантике volatile в любом языке, так как он относится к многопоточности, указывает на то, что вы думаете о совместном использовании памяти между потоками. Считайте просто не делать этого. Это делает вашу программу более трудной для понимания и с большей вероятностью содержит скрытые, невозможные для воспроизведения ошибки.
Дэвид упускает из виду тот факт, что стандарт C++ определяет поведение нескольких потоков, взаимодействующих только в определенных ситуациях, а все остальное приводит к неопределенному поведению. Состояние гонки, включающее хотя бы одну запись, не определено, если вы не используете атомарные переменные.
Следовательно, компилятор имеет полное право отказаться от любых инструкций по синхронизации, поскольку ваш процессор заметит только разницу в программе, которая демонстрирует неопределенное поведение из-за отсутствия синхронизации.
Во-первых, стандарты C++ не гарантируют барьеры памяти, необходимые для правильного упорядочения неатомарных операций чтения / записи. переменные volatile рекомендуются для использования с MMIO, обработкой сигналов и т. д. В большинстве реализаций volatile не полезна для многопоточности и обычно не рекомендуется.
Что касается реализации энергозависимого доступа, это выбор компилятора.
В этой статье, описывающей поведение gcc, показано, что нельзя использовать энергозависимый объект в качестве барьера памяти для упорядочения последовательности операций записи в энергозависимую память.
Что касается поведения icc, я обнаружил, что этот источник говорит также, что volatile не гарантирует порядок доступа к памяти.
Компилятор Microsoft VS2013 имеет другое поведение. Эта документация объясняет, как volatile обеспечивает семантику Release / Acquire и позволяет использовать volatile объекты в блокировках / выпусках в многопоточных приложениях.
Другим аспектом, который необходимо учитывать, является то, что один и тот же компилятор может иметь другое поведение по сравнению с ним. к энергозависимости в зависимости от целевой аппаратной архитектуры. В этом посте, касающемся компилятора MSVS 2013, четко изложены особенности компиляции с использованием volatile для платформ ARM.
Итак, мой ответ:
Вводит ли ключевое слово volatile в C++ ограничение памяти?
будет: Не гарантировано, вероятно нет, но некоторые компиляторы могут это сделать. Не стоит полагаться на то, что это так.
Это зависит от того, какой компилятор является "компилятором". Visual C++ делает это с 2005 года. Но стандарт не требует этого, поэтому некоторые другие компиляторы этого не делают.
Насколько я знаю, компилятор только вставляет ограничитель памяти в архитектуру Itanium.
volatile
ключевое слово действительно лучше всего использовать для асинхронных изменений, например, для обработчиков сигналов и отображаемых в память регистров; обычно это неправильный инструмент для многопоточного программирования.
Это не обязательно. Volatile не является примитивом синхронизации. Он просто отключает оптимизацию, то есть вы получаете предсказуемую последовательность операций чтения и записи в потоке в том же порядке, как это предписано абстрактной машиной. Но чтение и запись в разных потоках не имеют порядка, во-первых, нет смысла говорить о сохранении или не сохранении их порядка. Порядок между theads может быть установлен примитивами синхронизации, вы получаете UB без них.
Немного объяснения относительно барьеров памяти. Типичный процессор имеет несколько уровней доступа к памяти. Существует конвейер памяти, несколько уровней кеша, затем оперативная память и т. Д.
M embar инструкции промывают трубопровод. Они не изменяют порядок, в котором выполняются операции чтения и записи, а просто заставляют выполнять выдающиеся в данный момент. Это полезно для многопоточных программ, но не намного иначе.
Кэш (ы) обычно автоматически согласованы между процессорами. Если кто-то хочет убедиться, что кэш синхронизирован с оперативной памятью, очистка кеша необходима. Это очень отличается от мембраны.
Это в основном из памяти и основано на pre-C++11, без потоков. Но, поучаствовав в дискуссиях о потоках в комитете, я могу сказать, что комитет никогда не собирался volatile
может быть использован для синхронизации между потоками. Microsoft предложила это, но предложение не несло.
Основная спецификация volatile
в том, что доступ к volatile представляет "наблюдаемое поведение", как и IO. Точно так же компилятор не может переупорядочивать или удалять определенные операции ввода-вывода, он не может переупорядочивать или удалять обращения к изменчивому объекту (или, более правильно, обращения через выражение lvalue с определенным типом volatile). Первоначально намерение volatile заключалось в поддержке ввода-вывода с отображением памяти. Однако "проблема" в этом заключается в том, что именно реализация определяется как "изменчивый доступ". И многие компиляторы реализуют его так, как будто определение было "инструкция, которая читает или записывает в память, была выполнена". Что является юридическим, хотя и бесполезным определением, если оно указано в реализации. (Я еще не нашел актуальную спецификацию для любого компилятора.)
Возможно (и это аргумент, который я принимаю), это нарушает намерение стандарта, поскольку, если аппаратное обеспечение не распознает адреса как ввод-вывод в память и не запрещает любое переупорядочение и т. Д., Вы даже не можете использовать volatile для ввода-вывода, отображенного в память, по крайней мере, на архитектурах Sparc или Intel. Тем не менее, ни один из компиляторов, на которые я смотрел (Sun CC, g++ и MSC), не выводил каких-либо инструкций. (Примерно в то же время Microsoft предложила расширить правилаvolatile
Я думаю, что некоторые из их компиляторов реализовали свое предложение и выпустили инструкции по забору для изменчивого доступа. Я не проверял, что делают последние компиляторы, но меня не удивит, если это зависит от какой-то опции компилятора. Версия, которую я проверил - я думаю, что это была VS6.0 - не излучала ограждения.)
Компилятор должен ввести ограничение памяти вокруг volatile
доступ, если и только если это необходимо для использования volatile
указано в стандартной работе (setjmp
, обработчики сигналов и т. д.) на этой конкретной платформе.
Обратите внимание, что некоторые компиляторы выходят далеко за рамки требований стандарта C++, чтобы volatile
более мощный или полезный на этих платформах. Переносимый код не должен полагаться на volatile
делать что-то помимо того, что указано в стандарте C++.
Я всегда использую volatile в подпрограммах обслуживания прерываний, например, ISR (часто ассемблерный код) изменяет некоторую область памяти, а код более высокого уровня, который работает вне контекста прерывания, получает доступ к области памяти через указатель на volatile.
Я делаю это для оперативной памяти, а также для ввода-вывода в памяти.
Основываясь на обсуждении здесь, кажется, что это все еще допустимое использование volatile, но не имеет ничего общего с несколькими потоками или процессорами. Если компилятор для микроконтроллера "знает", что не может быть никаких других обращений (например, все на кристалле, нет кеша и есть только одно ядро), я думаю, что ограничение памяти вообще не подразумевается, компилятор просто нужно предотвратить определенные оптимизации.
По мере того, как мы собираем больше материала в "систему", которая выполняет объектный код, почти все ставки отключены, по крайней мере, так я прочитал это обсуждение. Как компилятор может охватить все базы?
Ключевое слово volatile
по сути, означает, что чтение и запись объекта должны выполняться точно так, как написано программой, и никоим образом не оптимизированы. Двоичный код должен следовать за кодом C или C++: загрузка, где это читается, хранилище, где есть запись.
Это также означает, что чтение не должно приводить к предсказуемому значению: компилятор не должен предполагать что-либо о чтении, даже сразу после записи в тот же изменчивый объект:
volatile int i;
i = 1;
int j = i;
if (j == 1) // not assumed to be true
volatile
может быть самым важным инструментом в наборе инструментов "C - это язык высокого уровня".
Является ли объявление объекта volatile достаточным для обеспечения поведения кода, который имеет дело с асинхронными изменениями, зависит от платформы: разные процессоры дают разные уровни гарантированной синхронизации для обычных операций чтения и записи в память. Вы, вероятно, не должны пытаться писать такой многопоточный код низкого уровня, если вы не являетесь экспертом в этой области.
Атомарные примитивы обеспечивают хороший высокоуровневый вид объектов для многопоточности, что упрощает анализ кода. Почти все программисты должны использовать атомарные примитивы или примитивы, которые обеспечивают взаимные исключения, такие как мьютексы, блокировки чтения-записи, семафоры или другие блокирующие примитивы.
Пока я работал над онлайновым загружаемым видеоуроком по разработке 3D-графики и игрового движка, работающим с современным OpenGL. Мы использовали volatile
в одном из наших классов. Веб-сайт учебного руководства можно найти здесь, а также видео о работе с volatile
Ключевое слово найдено в Shader Engine
Сериал 98. Эти работы не мои, но аккредитованы Marek A. Krzeminski, MASc
и это отрывок со страницы загрузки видео.
"Поскольку теперь мы можем запускать наши игры в нескольких потоках, важно правильно синхронизировать данные между потоками. В этом видео я покажу, как создать класс блокировки volitile, чтобы обеспечить правильную синхронизацию переменных volitile..."
И если вы подписаны на его сайт и имеете доступ к его видео в этом видео, он ссылается на эту статью, касающуюся использования Volatile
с multithreading
программирование.
Вот статья по ссылке выше: http://www.drdobbs.com/cpp/volatile-the-multithreaded-programmers-b/184403766
volatile: лучший друг многопоточного программиста
Андрей Александреску, 1 февраля 2001 г.
Ключевое слово volatile было разработано, чтобы предотвратить оптимизацию компилятора, которая может сделать код некорректным при наличии определенных асинхронных событий.
Я не хочу портить вам настроение, но этот столбец посвящен страшной теме многопоточного программирования. Если, как говорилось в предыдущей части Generic, программирование, безопасное для исключений, сложно, это детская игра по сравнению с многопоточным программированием.
Программы, использующие несколько потоков, общеизвестно трудны для написания, проверки правильности, отладки, обслуживания и ручного управления в целом. Неправильные многопоточные программы могут работать годами без сбоев, только для неожиданного запуска amok, потому что было выполнено некоторое критическое условие синхронизации.
Само собой разумеется, программисту, пишущему многопоточный код, нужна вся помощь, которую она может получить. Этот столбец посвящен условиям гонки - типичному источнику проблем в многопоточных программах - и предоставляет вам идеи и инструменты, как их избежать, и, что удивительно, компилятор усердно помогает вам в этом.
Просто маленькое ключевое слово
Хотя стандарты 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_ member каждую секунду и возвращать, когда эта переменная установлена в true другим потоком. По крайней мере, так задумал его программист, но, увы, Wait неверен.
Предположим, компилятор выяснил, что Sleep(1000) является вызовом внешней библиотеки, которая не может изменить переменную-член flag_. Затем компилятор приходит к выводу, что он может кешировать flag_ в регистре и использовать этот регистр вместо доступа к более медленной встроенной памяти. Это отличная оптимизация для однопоточного кода, но в этом случае это наносит вред правильности: после вызова Wait для некоторого объекта Gadget, хотя другой поток вызывает Wakeup, Wait будет зацикливаться вечно. Это потому, что изменение flag_ не будет отражено в регистре, который кэширует flag_. Оптимизация слишком... оптимистична.
Кэширование переменных в регистрах - это очень ценная оптимизация, которая применяется большую часть времени, поэтому было бы жалко тратить ее впустую. C и C++ дают вам возможность явно отключить такое кэширование. Если вы используете модификатор volatile для переменной, компилятор не будет кэшировать эту переменную в регистрах - каждый доступ будет попадать в фактическую ячейку памяти этой переменной. Таким образом, все, что вам нужно сделать, чтобы комбо-функция Gadget's Wait/Wakeup работала, - это квалифицировать flag_ соответствующим образом:
class Gadget { public: ... as above ... private: volatile bool flag_; };
Большинство объяснений обоснования и использования volatile здесь останавливаются и советуют вам определить volatile-типы примитивных типов, которые вы используете в нескольких потоках. Тем не менее, есть гораздо больше, что вы можете сделать с volatile, потому что оно является частью замечательной системы типов C++.
Использование volatile с пользовательскими типами
Вы можете квалифицировать volatile не только примитивные типы, но и определяемые пользователем типы. В этом случае volatile изменяет тип способом, аналогичным const. (Вы также можете применять const и volatile к одному и тому же типу одновременно.)
В отличие от const, volatile различает примитивные типы и определяемые пользователем типы. А именно, в отличие от классов, примитивные типы по-прежнему поддерживают все свои операции (сложение, умножение, присваивание и т. Д.) С квалификацией volatile. Например, вы можете назначить энергонезависимое int для volatile int, но вы не можете назначить энергонезависимый объект для volatile объекта.
Давайте проиллюстрируем, как volatile работает на пользовательских типах на примере.
class Gadget { public: void Foo() volatile; void Bar(); ... private: String name_; int state_; }; ... Gadget regularGadget; volatile Gadget volatileGadget;
Если вы думаете, что изменчивость не очень полезна для объектов, приготовьтесь к удивлению.
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
Класс с изменчивой квалификацией предоставляет доступ только к подмножеству его интерфейса, подмножеству, которое находится под контролем разработчика класса. Пользователи могут получить полный доступ к интерфейсу этого типа только с помощью const_cast. Кроме того, так же, как и constness, переменная распространяется от класса к его членам (например, volatileGadget.name_ и volatileGadget.state_ являются переменными).
изменчивые, критические секции и условия гонки
Самым простым и наиболее часто используемым устройством синхронизации в многопоточных программах является мьютекс. Мьютекс предоставляет доступ к примитивам Acquire и Release. Когда вы вызываете Acquire в каком-либо потоке, любой другой поток, вызывающий Acquire, блокируется. Позже, когда этот поток вызывает Release, будет освобожден ровно один поток, заблокированный в вызове Acquire. Другими словами, для данного мьютекса только один поток может получить процессорное время между вызовом Acquire и вызовом Release. Выполнение кода между вызовом Acquire и вызовом Release называется критическим разделом. (Терминология Windows немного сбивает с толку, потому что она называет мьютекс критическим разделом, тогда как "мьютекс" на самом деле является мьютексом между процессами. Было бы неплохо, если бы их называли мьютексом потока и мьютексом процесса.)
Мьютексы используются для защиты данных от условий гонки. По определению, состояние гонки возникает, когда влияние большего количества потоков на данные зависит от того, как запланированы потоки. Условия гонки появляются, когда два или более потоков конкурируют за использование одних и тех же данных. Поскольку потоки могут прерывать друг друга в произвольные моменты времени, данные могут быть повреждены или неверно истолкованы. Следовательно, изменения и иногда доступ к данным должны быть тщательно защищены критическими разделами. В объектно-ориентированном программировании это обычно означает, что вы сохраняете мьютекс в классе как переменную-член и используете его всякий раз, когда обращаетесь к состоянию этого класса.
Опытные многопоточные программисты, возможно, зевали, читая два абзаца выше, но их цель - обеспечить интеллектуальную тренировку, потому что теперь мы будем связываться с изменчивой связью. Мы делаем это, проводя параллель между миром типов C++ и миром семантики потоков.
- За пределами критической секции любой поток может прервать любой другой в любое время; нет никакого контроля, поэтому переменные, доступные из нескольких потоков, являются изменчивыми. Это соответствует первоначальному замыслу volatile - предотвращению непреднамеренного кэширования компилятором значений, используемых несколькими потоками одновременно.
- Внутри критической секции, определенной мьютексом, только один поток имеет доступ. Следовательно, внутри критической секции исполняемый код имеет однопоточную семантику. Управляемая переменная больше не является изменчивой - вы можете удалить изменяемый квалификатор.
Короче говоря, данные, разделяемые между потоками, концептуально изменчивы вне критической секции и энергонезависимы внутри критической секции.
Вы входите в критическую секцию, блокируя мьютекс. Вы удаляете изменчивый квалификатор из типа, применяя const_cast. Если нам удастся соединить эти две операции вместе, мы создадим связь между системой типов C++ и семантикой потоков приложения. Мы можем заставить компилятор проверить условия гонки для нас.
LockingPtr
Нам нужен инструмент, который собирает мьютекс и const_cast. Давайте разработаем шаблон класса LockingPtr, который вы инициализируете с помощью изменяемого объекта obj и мьютекса mtx. В течение своей жизни LockingPtr сохраняет полученный MTX. Кроме того, LockingPtr предлагает доступ к объекту с изменяемым содержанием. Доступ предоставляется в виде интеллектуального указателя, через operator-> и operator*. Const_cast выполняется внутри LockingPtr. Приведение семантически допустимо, потому что LockingPtr сохраняет мьютекс, полученный в течение его времени жизни.
Сначала давайте определим каркас класса Mutex, с которым будет работать LockingPtr:
class Mutex { public: void Acquire(); void Release(); ... };
Чтобы использовать LockingPtr, вы реализуете Mutex, используя собственные структуры данных и примитивные функции операционной системы.
LockingPtr определяется типом контролируемой переменной. Например, если вы хотите управлять виджетом, вы используете 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 очень полезен для написания правильного многопоточного кода. Вы должны определить объекты, которые совместно используются потоками, как volatile и никогда не использовать 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(); }
Вернуться к примитивным типам
Мы увидели, как хорошо изменчивый защищает объекты от неконтролируемого доступа и как LockingPtr обеспечивает простой и эффективный способ написания поточно-ориентированного кода. Давайте теперь вернемся к примитивным типам, которые по-разному обрабатываются volatile.
Давайте рассмотрим пример, когда несколько потоков совместно используют переменную типа int.
class Counter { public: ... void Increment() { ++ctr_; } void Decrement() { —ctr_; } private: int ctr_; };
Если Increment и Decrement должны вызываться из разных потоков, фрагмент выше глючит. Во-первых, ctr_ должен быть изменчивым. Во-вторых, даже внешне атомарная операция, такая как ++ctr_, на самом деле является трехэтапной операцией. Сама память не имеет арифметических возможностей. При увеличении переменной процессор:
- Читает эту переменную в регистре
- Увеличивает значение в регистре
- Записывает результат обратно в память
Эта трехступенчатая операция называется RMW (Read-Modify-Write). Во время части 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 равняется бесплатному многопоточному коду и без критической секции; энергонезависимый равняется однопоточному сценарию или внутри критического раздела.
Например, вы определяете класс Widget, который реализует операцию в двух вариантах: поточно-ориентированный и быстрый, незащищенный.
class Widget { public: void Operation() volatile; void Operation(); ... private: Mutex mtx_; };
Обратите внимание на использование перегрузки. Теперь пользователь Widget может вызывать Operation с использованием унифицированного синтаксиса либо для изменчивых объектов и получать безопасность потоков, либо для обычных объектов и получать скорость. Пользователь должен быть осторожен при определении общих объектов Widget как энергозависимых.
При реализации функции энергозависимого члена первая операция обычно заключается в том, чтобы заблокировать это с помощью LockingPtr. Затем работа выполняется с использованием энергонезависимого брата:
void Widget::Operation() volatile { LockingPtr<Widget> lpThis(*this, mtx_); lpThis->Operation(); // invokes the non-volatile function }
Резюме
При написании многопоточных программ вы можете использовать volatile в ваших интересах. Вы должны придерживаться следующих правил:
- Определите все общие объекты как изменчивые.
- Не используйте volatile напрямую с примитивными типами.
- При определении разделяемых классов используйте функции-члены volatile для выражения безопасности потока.
Если вы сделаете это, и если вы используете простой универсальный компонент LockingPtr, вы можете написать поточно-ориентированный код и гораздо меньше беспокоиться о состоянии гонки, потому что компилятор будет беспокоиться за вас и будет старательно указывать на те места, где вы ошибаетесь.
Несколько проектов, в которых я принимал участие, используют volatile и LockingPtr для получения отличного эффекта. Код чистый и понятный. Я вспоминаю пару взаимоблокировок, но я предпочитаю взаимоблокировки условиям гонки, потому что их намного легче отлаживать. Практически не было проблем, связанных с условиями гонки. Но тогда ты никогда не узнаешь.
Подтверждения
Большое спасибо Джеймсу Канзе и Сорину Цзяну, которые помогли с проницательными идеями.
Андрей Александреску - менеджер по развитию в RealNetworks Inc. (www.realnetworks.com), базирующейся в Сиэтле, штат Вашингтон, и автор знаменитой книги Modern C++ Design. С ним можно связаться по адресу www.moderncppdesign.com. Андрей также является одним из ведущих инструкторов Семинара C++ (www.gotw.ca/cpp_seminar).
Эта статья может быть немного устаревшей, но она дает хорошее представление о превосходном использовании модификатора volatile с использованием многопоточного программирования, чтобы помочь поддерживать асинхронность событий, в то время как компилятор проверяет условия гонки для нас. Это может не дать прямого ответа на первоначальный вопрос ОП о создании забора памяти, но я предпочитаю опубликовать его как ответ для других, как отличную ссылку на хорошее использование volatile при работе с многопоточными приложениями.
Я думаю, что путаница вокруг изменчивости и переупорядочения команд проистекает из двух понятий переупорядочения процессоров:
- Внеочередное исполнение.
- Последовательность чтения / записи памяти, видимая другими процессорами (переупорядочение в том смысле, что каждый процессор может видеть свою последовательность).
Volatile влияет на то, как компилятор генерирует код, предполагая однопоточное выполнение (включая прерывания). Это не подразумевает каких-либо инструкций о барьере памяти, но скорее мешает компилятору выполнять определенные виды оптимизаций, связанных с доступом к памяти.
Типичным примером является повторное извлечение значения из памяти вместо использования одного кэшированного в регистре.
Внеочередное исполнение
Процессоры могут выполнять инструкции не по порядку / умозрительно при условии, что конечный результат мог произойти в исходном коде. Процессоры могут выполнять преобразования, которые запрещены в компиляторах, поскольку компиляторы могут выполнять преобразования, которые являются правильными при любых обстоятельствах. Напротив, процессоры могут проверить правильность этих оптимизаций и отказаться от них, если они окажутся неверными.
Последовательность чтения / записи памяти, видимая другими процессорами
Конечный результат последовательности инструкций, эффективный порядок, должен соответствовать семантике кода, сгенерированного компилятором. Однако фактический порядок выполнения, выбранный ЦП, может отличаться. Эффективный порядок, который виден в других процессорах (каждый процессор может иметь различное представление), может быть ограничен барьерами памяти.
Я не уверен, насколько эффективный и фактический порядок может отличаться, потому что я не знаю, в какой степени барьеры памяти могут помешать ЦП выполнять выполнение не по порядку.
Источники: