Поки я працював над завантажуваним в Інтернеті відео-посібником з розробки 3D-графіки та ігрового движка, працюючи з сучасним OpenGL. Ми використовували volatile
в одному з наших класів. Веб-сайт підручника можна знайти тут, а відео, що працює з volatile
ключовим словом, - у Shader Engine
серії 98. Ці роботи не є моїми власними, а акредитовані, Marek A. Krzeminski, MASc
і це уривок зі сторінки завантаження відео.
І якщо ви підписані на його веб - сайт і мати доступ до його відео в межах цього відео він посилається на цю статтю щодо використання Volatile
з multithreading
програмуванням.
мінливий: найкращий друг багатопоточного програміста
Андрій Александреску, 01 лютого 2001 р
Ключове слово volatile було розроблено, щоб запобігти оптимізації компілятора, яка може зробити код неправильним за наявності певних асинхронних подій.
Я не хочу псувати вам настрій, але в цій колонці йдеться про страшну тему багатопоточного програмування. Якщо - як сказано в попередньому розділі Generic - безпечне виняток програмування важко, це дитяча гра в порівнянні з багатопоточним програмуванням.
Програми, що використовують декілька потоків, як відомо, важко писати, доводити правильність, налагоджувати, підтримувати та приручати загалом. Неправильні багатопотокові програми можуть працювати роками без збоїв, лише щоб несподівано запустити роботу, оскільки виконано деякі критичні умови синхронізації.
Що й казати, програмісту, який пише багатопоточний код, потрібна вся допомога, яку вона може отримати. Ця колонка присвячена умовам змагань - типовим джерелом проблем у багатопотокових програмах - і надає вам уявлення та інструменти щодо того, як їх уникнути, і, як не дивно, змусити компілятор наполегливо допомогти вам у цьому.
Просто маленьке ключове слово
Хоча як стандарти C, так і C ++ помітно мовчать, коли справа стосується потоків, вони роблять невелику поступку багатопоточності у формі ключового слова volatile.
Як і його більш відомий аналог const, volatile є модифікатором типу. Він призначений для використання разом зі змінними, до яких здійснюється доступ та модифікація в різних потоках. В основному без мінливості написання багатопоточних програм стає неможливим, або компілятор витрачає величезні можливості оптимізації. Пояснення в порядку.
Розглянемо такий код:
class Gadget {
public:
void Wait() {
while (!flag_) {
Sleep(1000);
}
}
void Wakeup() {
flag_ = true;
}
...
private:
bool flag_;
};
Призначення Gadget :: Wait вище - перевіряти змінну-член flag_ щосекунди і повертати, коли для цієї змінної інший потік встановив значення true. Принаймні так задумав його програміст, але, на жаль, Чекати неправильно.
Припустимо, компілятор вияснив, що Sleep (1000) - це виклик зовнішньої бібліотеки, який не може змінити змінну-член flag_. Тоді компілятор приходить до висновку, що він може кешувати flag_ у регістрі та використовувати цей регістр замість доступу до повільнішої вбудованої пам'яті. Це відмінна оптимізація для однопотокового коду, але в цьому випадку це шкодить правильності: після того, як ви викликаєте Wait для якогось об'єкта Gadget, хоча інший потік викликає Wakeup, Wait буде циклічно назавжди. Це пояснюється тим, що зміна flag_ не відображатиметься в реєстрі, який кешує flag_. Оптимізація занадто ... оптимістична.
Кешування змінних у регістрах є дуже цінною оптимізацією, яка застосовується більшу частину часу, тому було б шкода її витратити. C та C ++ дають вам можливість явно вимкнути таке кешування. Якщо ви використовуєте мінливий модифікатор для змінної, компілятор не буде кешувати цю змінну в регістрах - кожен доступ буде вражати фактичне розташування цієї змінної в пам'яті. Отже, все, що вам потрібно зробити, щоб комбінований пристрій Wait / Wakeup працював, - це належним чином кваліфікувати flag_:
class Gadget {
public:
... as above ...
private:
volatile bool flag_;
};
Більшість пояснень обґрунтування та використання енергозалежних зупиняються на цьому і радять вам визначити летючі класифікації примітивних типів, які ви використовуєте в декількох потоках. Однак можна зробити набагато більше з мінливими, оскільки це частина чудової системи типу C ++.
Використання мінливих з визначеними користувачем типами
Ви можете мінливо визначити не тільки примітивні типи, але й визначені користувачем типи. У цьому випадку volatile модифікує тип способом, подібним до const. (Ви також можете одночасно застосовувати const та volatile до одного типу.)
На відміну від const, volatile розрізняє примітивні типи та визначені користувачем типи. А саме, на відміну від класів, примітивні типи все ще підтримують всі свої операції (додавання, множення, присвоєння тощо), коли вони нестабільні. Наприклад, ви можете призначити енергонезалежний int для енергозалежного int, але ви не можете призначити енергонезалежний об’єкт для леткого об’єкта.
Давайте на прикладі проілюструємо, як мінлива робота працює на визначених користувачем типах.
class Gadget {
public:
void Foo() volatile;
void Bar();
...
private:
String name_;
int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;
Якщо ви вважаєте, що летючі речовини не так корисні для предметів, підготуйтеся до якогось сюрпризу.
volatileGadget.Foo();
regularGadget.Foo();
volatileGadget.Bar();
Перехід з некваліфікованого типу на його летючий аналог є тривіальним. Однак, як і у випадку з const, ви не можете повернути поїздку з мінливої в некваліфіковану. Ви повинні використовувати гіпс:
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar();
Клас, що відповідає витримці, надає доступ лише до підмножини свого інтерфейсу, підмножини, яка знаходиться під контролем реалізатора класу. Користувачі можуть отримати повний доступ до інтерфейсу цього типу лише за допомогою const_cast. Крім того, як і constness, мінливість поширюється від класу до його членів (наприклад, volatileGadget.name_ та volatileGadget.state_ є мінливими змінними).
мінливі, критичні розділи та умови перегонів
Найпростішим і найбільш часто використовуваним пристроєм синхронізації в багатопотокових програмах є мутекс. Мьютекс виставляє примітиви Acquire and Release. Як тільки ви викликаєте Acquire у якомусь потоці, будь-який інший потік, який викликає Acquire, заблокує. Пізніше, коли цей потік викликає Release, буде звільнений саме один потік, заблокований у виклику Acquire. Іншими словами, для даного мьютексу лише один потік може отримати час процесора між викликом Acquire та викликом Release. Код, що виконується між викликом Acquire та викликом Release, називається критичним розділом. (Термінологія Windows дещо заплутана, оскільки сама називає мьютекс критичним розділом, тоді як "мьютекс" насправді є міжпроцесним мьютексом. Було б непогано, якби їх назвали потоковим мьютексом і обробляють мутекс.)
Мьютекси використовуються для захисту даних від расових умов. За визначенням, стан перегонів виникає, коли вплив більшої кількості потоків на дані залежить від того, як заплановано потоки. Умови перегонів з’являються, коли два або більше потоків змагаються за використання однакових даних. Оскільки потоки можуть переривати один одного в довільні моменти часу, дані можуть бути пошкоджені або неправильно інтерпретовані. Отже, зміни та інколи доступ до даних повинні бути ретельно захищені критичними розділами. В об'єктно-орієнтованому програмуванні це зазвичай означає, що ви зберігаєте мьютекс у класі як змінну-член і використовуєте його кожного разу, коли отримуєте доступ до стану цього класу.
Досвідчені багатопотокові програмісти, можливо, позіхнули, читаючи два абзаци вище, але їх метою є забезпечення інтелектуальних тренувань, тому що зараз ми будемо зв’язуватися з нестабільним зв’язком. Ми робимо це, проводячи паралель між світом типів C ++ та семантичним потоком потоків.
- Поза критичним розділом будь-який потік може перервати будь-який інший у будь-який час; відсутній контроль, тому змінні, доступні з декількох потоків, є мінливими. Це відповідає початковому наміру volatile - запобігання компіляторові мимоволі кешування значень, що використовуються декількома потоками одночасно.
- Усередині критичного розділу, визначеного мьютексом, доступ має лише один потік. Отже, всередині критичного розділу виконуваний код має однопоточну семантику. Контрольована змінна вже не мінлива - ви можете видалити мінливий кваліфікатор.
Коротше кажучи, дані, якими обмінюються потоки, концептуально мінливі поза критичним розділом, а енергонезалежні всередині критичного розділу.
Ви потрапляєте в критичний розділ, заблокувавши мьютекс. Ви видаляєте мінливий кваліфікатор із типу, застосовуючи const_cast. Якщо нам вдається поєднати ці дві операції, ми створюємо зв'язок між системою типів C ++ та семантикою потоків додатків. Ми можемо змусити компілятора перевірити для нас умови змагань.
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 шаблонується з типом керованої змінної. Наприклад, якщо ви хочете керувати віджетом, ви використовуєте 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();
}
Повернутися до примітивних типів
Ми побачили, наскільки мінлива мінливість захищає об’єкти від неконтрольованого доступу та як 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!
мінливі функції члена
Дотепер у нас були класи, які об’єднують мінливі члени даних; Тепер давайте подумаємо про розробку класів, які, в свою чергу, будуть частиною більших об'єктів і будуть спільними між потоками. Ось де мінливі функції членів можуть бути корисними.
Розробляючи свій клас, ви змінюєте кваліфікацію лише тих функцій-членів, які є безпечними для потоків. Ви повинні припустити, що зовнішній код у будь-який час викликатиме летючі функції з будь-якого коду. Не забувайте: volatile дорівнює вільному багатопотоковому коду і відсутність критичного розділу; енергонезалежний дорівнює однопоточному сценарію або всередині критичного перерізу.
Наприклад, ви визначаєте віджет класу, який реалізує операцію у двох варіантах - потокобезпечному та швидкому, незахищеному.
class Widget {
public:
void Operation() volatile;
void Operation();
...
private:
Mutex mtx_;
};
Зверніть увагу на використання перевантаження. Тепер користувач віджета може викликати операцію, використовуючи єдиний синтаксис або для нестабільних об'єктів, і отримати безпеку потоків, або для звичайних об'єктів і отримати швидкість. Користувач повинен бути обережним щодо визначення спільних об’єктів віджетів як нестійких.
При реалізації летючої функції члена, перша операція, як правило, полягає у блокуванні цього за допомогою LockingPtr. Потім робота виконується за допомогою енергонезалежного брата або сестри:
void Widget::Operation() volatile {
LockingPtr<Widget> lpThis(*this, mtx_);
lpThis->Operation();
}
Резюме
Створюючи багатопотокові програми, ви можете використовувати енергонезалежні для своєї переваги. Ви повинні дотримуватися наступних правил:
- Визначте всі спільні об’єкти як нестабільні.
- Не використовуйте летючі речовини безпосередньо з примітивними типами.
- Визначаючи спільні класи, використовуйте летючі функції-члени, щоб виразити безпеку потоків.
Якщо ви зробите це, і якщо ви використовуєте простий загальний компонент LockingPtr, ви можете написати безпечний для потоків код і набагато менше турбуватися про умови перегонів, оскільки компілятор буде турбуватися за вас і старанно вказувати на місця, де ви помиляєтесь.
Кілька проектів, в яких я брав участь, з великим ефектом використовують volatile та LockingPtr. Код чистий і зрозумілий. Я пам’ятаю пару тупикових ситуацій, але я віддаю перевагу тупиковим ситуаціям, аніж гоночним умовам, оскільки їх набагато легше налагодити. Проблем, пов'язаних з умовами перегонів, практично не було. Але тоді ніколи не знаєш.
Подяки
Велике спасибі Джеймсу Канзе та Соріну Цзяну, які допомогли з проникливими ідеями.
Андрій Александреску - менеджер з розвитку компанії RealNetworks Inc. (www.realnetworks.com), що базується в Сіетлі, штат Вашингтон, та автор відомої книги "Сучасний дизайн C ++". З ним можна зв’язатися за адресою www.moderncppdesign.com. Андрій також є одним із найкращих викладачів семінару C ++ (www.gotw.ca/cpp_seminar).
Ця стаття може бути трохи застарілою, але вона дає хороший уявлення про чудове використання використання енергозалежного модифікатора при використанні багатопотокового програмування, щоб допомогти зберегти події асинхронними, а компілятор перевіряє умови гонки для нас. Це може безпосередньо не відповісти на оригінальне запитання щодо створення паркану пам'яті, але я вирішив опублікувати це як відповідь для інших як чудове посилання на хороше використання енергонезалежних при роботі з багатопоточними програмами.