Неправильное ключевое слово С++ вовлекает забор памяти?

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

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


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

Джонатан Вакели пишет:

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

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

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

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

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

Что дает два вопроса: является ли одно из них "правильным"? Что действительно делают реальные реализации?

Ответ 1

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

  • Внутри обработчика сигнала. Поскольку запись в переменную volatile в значительной степени является единственной вещью, которую стандарт позволяет вам делать из обработчика сигнала. Начиная с С++ 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 должно рассматриваться как не переносимое, а не обзор кода прохода, кроме случаев, когда речь идет о компиляторах pre-С++ 11 и расширениях компилятора (таких как msvc /volatile:ms switch, что включен по умолчанию в X86/I64).

Ответ 2

Неправильное ключевое слово С++ вовлекает забор памяти?

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

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

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

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

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

Ответ 3

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

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

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

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

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

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

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

Неправильное ключевое слово С++ вовлекает забор памяти?

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

Ответ 4

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

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

Ответ 5

Компилятор только вставляет забор памяти в архитектуре Itanium, насколько мне известно.

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

Ответ 6

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

Ответ 7

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

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

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

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

Ответ 8

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

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

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

Ответ 9

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

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

Ответ 10

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

Я делаю это для ОЗУ, а также для ввода IO с памятью.

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

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

Ответ 11

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

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

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

Выполнение вне очереди

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

Последовательность чтения/записи в памяти, наблюдаемая другими CPU

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

Источники:

Ответ 12

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

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

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

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

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

Андрей Александреску, 01 февраля 2001 г.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Вы можете volatile-qualify не только примитивные типы, но и пользовательские типы. В этом случае volatile изменяет тип способом, аналогичным 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!

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

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

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

летучие, критические разделы и условия гонки

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

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

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

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

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

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

LockingPtr

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

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

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

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

LockingPtr templated с типом управляемой переменной. Например, если вы хотите управлять виджетами, вы используете 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 обеспечивает простой и эффективный способ написания потокобезопасного кода. Теперь вернемся к примитивным типам, которые по-разному относятся к летучим.

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

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

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

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

Эта трехступенчатая операция называется RMW (Read-Modify-Write). Во время изменения части операции 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 member могут оказать большую помощь.

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

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

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

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

При реализации функции volatile member первая операция, как правило, блокирует это с помощью 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), основанный в Сиэтле, штат Вашингтон, и автор знаменитой книги Modern С++ Design. С ним можно связаться по адресу: www.moderncppdesign.com. Андрей также является одним из признанных инструкторов Семинара С++ (www.gotw.ca/cpp_seminar).

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