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