Пока я работал над загружаемым онлайн-видеоуроком по разработке 3D-графики и игрового движка, работая с современным OpenGL. Мы использовали volatile
в одном из наших классов. Веб-сайт с учебным курсом можно найти здесь, а видео о работе с volatile
ключевым словом можно найти в Shader Engine
серии видео 98. Эти работы не являются моими собственными, но аккредитованы, Marek A. Krzeminski, MASc
и это отрывок со страницы загрузки видео.
И если вы подписаны на его веб-сайт и имеете доступ к его видео в этом видео, он ссылается на эту статью, касающуюся использования Volatile
с multithreading
программированием.
volatile: лучший друг многопоточного программиста
Текст: Андрей Александреску, 1 февраля 2001 г.
Ключевое слово volatile было разработано для предотвращения оптимизации компилятора, которая могла бы отображать код некорректно при наличии определенных асинхронных событий.
Не хочу портить вам настроение, но в этой колонке рассматривается ужасная тема многопоточного программирования. Если - как говорилось в предыдущем выпуске Generic - безопасное программирование исключений сложно, это детская игра по сравнению с многопоточным программированием.
Как известно, программы, использующие несколько потоков, сложно писать, проверять их правильность, отлаживать, поддерживать и в целом приручать. Неправильные многопоточные программы могут работать годами без сбоев, только чтобы неожиданно выйти из-под контроля, потому что было выполнено какое-то критическое условие синхронизации.
Излишне говорить, что программисту, пишущему многопоточный код, нужна вся доступная помощь. Эта колонка посвящена условиям гонки - распространенному источнику проблем в многопоточных программах - и предоставляет вам идеи и инструменты, позволяющие их избежать, и, что довольно удивительно, заставить компилятор усердно работать, чтобы помочь вам в этом.
Просто небольшое ключевое слово
Хотя стандарты C и C ++ явно молчат, когда речь идет о потоках, они делают небольшую уступку многопоточности в виде ключевого слова volatile.
Как и его более известный аналог const, volatile является модификатором типа. Он предназначен для использования вместе с переменными, к которым осуществляется доступ и которые изменяются в разных потоках. По сути, без volatile либо написание многопоточных программ становится невозможным, либо компилятор тратит впустую огромные возможности оптимизации. Пояснения по порядку.
Рассмотрим следующий код:
class Gadget {
public:
void Wait() {
while (!flag_) {
Sleep(1000);
}
}
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();
regularGadget.Foo();
volatileGadget.Bar();
Преобразование неквалифицированного типа в его изменчивый аналог тривиально. Однако, как и в случае с const, вы не можете вернуться из изменчивого состояния в неквалифицированное. Вы должны использовать гипс:
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar();
Квалифицированный 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:
LockingPtr(volatile T& obj, Mutex& mtx)
: pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {
mtx.Lock();
}
~LockingPtr() {
pMtx_->Unlock();
}
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_;
};
Внутри функции потока вы просто используете 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() {
BufT::iterator i = buffer_.begin();
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();
}
Резюме
При написании многопоточных программ вы можете использовать volatile в своих интересах. Вы должны придерживаться следующих правил:
- Определите все общие объекты как изменчивые.
- Не используйте volatile напрямую с примитивными типами.
- При определении общих классов используйте изменчивые функции-члены для выражения безопасности потоков.
Если вы сделаете это и используете простой общий компонент LockingPtr, вы сможете написать поточно-ориентированный код и гораздо меньше беспокоиться об условиях гонки, потому что компилятор будет беспокоиться за вас и будет старательно указывать на те места, где вы ошибаетесь.
В паре проектов, в которых я участвовал, очень эффективно используются volatile и LockingPtr. Код чистый и понятный. Я помню пару тупиковых ситуаций, но я предпочитаю тупиковые ситуации условиям гонки, потому что их намного легче отлаживать. Проблем, связанных с условиями гонки, практически не было. Но тогда мало ли.
Благодарности
Большое спасибо Джеймсу Канце и Сорину Джиану, которые помогли с проницательными идеями.
Андрей Александреску - менеджер по развитию компании RealNetworks Inc. (www.realnetworks.com) из Сиэтла, штат Вашингтон, и автор знаменитой книги «Современный дизайн на C ++». С ним можно связаться на сайте www.moderncppdesign.com. Андрей также является одним из ведущих преподавателей семинара по C ++ (www.gotw.ca/cpp_seminar).
Эта статья может быть немного устаревшей, но она дает хорошее представление об отличном использовании модификатора volatile с использованием многопоточного программирования, чтобы помочь сохранить асинхронность событий, в то время как компилятор проверяет условия гонки за нас. Это может не напрямую отвечать на исходный вопрос OP о создании ограждения памяти, но я решил опубликовать это как ответ для других как отличную ссылку на хорошее использование volatile при работе с многопоточными приложениями.