JavaRush /Java блог /Random UA /Управління непостійністю (volatility)
lexmirnov
29 рівень
Москва

Управління непостійністю (volatility)

Стаття з групи Random UA

Вказівки щодо використання volatile-змінних

Автор Браян Гьотц, 19 червня 2007 року. Оригінал: Managing Volatility Volatile-змінні Java можна назвати "synchronized-лайт"; для їх використання потрібно менше коду, ніж для synchronized-блоків, часто вони виконуються швидше, але при цьому можуть робити лише частину того, що роблять synchronized. У цій статті представлено кілька паттернів ефективного використання volatile - і кілька попереджень про те, де їх використовувати не треба. Локи (locks) мають дві основні риси: взаємний виняток (mutual exclusion, mutex) і видимість. Взаємний виняток означає, що лок може бути захоплений лише однією ниткою в окремий момент часу, і цю властивість можна використовувати для реалізації протоколів управління доступом до загальнодоступних ресурсів, так що тільки одна нитка їх використовуватиме в окремий момент часу. Видимість - питання тонше, її завдання забезпечити, що зміни, зроблені в загальнодоступних ресурсах до звільнення замку, будуть видно наступну нитку, яка захопила цей замок. Якби синхронізація не гарантувала видимість, нитки могли б набувати застарілих або неправильних значень загальнодоступних змінних, що призвело б до цілої низки серйозних проблем.
Volatile-змінні
Volatile-змінні мають властивості видимості, властиві synchronized, але позбавлені їх атомарності. Це означає, що нитки автоматично використовуватимуть найактуальніші значення volatile-змінних. Їх можна використовувати для ниткобезпеки (thread safety, частіше перекладається як потокобезпеки ), але в дуже обмеженому наборі випадків: тих, що не вводять зв'язки між декількома змінними або між поточними та майбутніми змінними значеннями. Таким чином, однієї volatile недостатньо для реалізації лічильника, м'ютексу або будь-якого класу, незмінні частини яких пов'язані з декількома змінними (наприклад, "start <=end"). Віддати перевагу volatile локам можна по одній з двох основних причин: простоті або масштабованості. Деякі мовні конструкції легше записати у вигляді програмного коду, а надалі - прочитати та розібратися, коли вони використовують volatile-змінні замість локів. Крім того, на відміну від локів, вони не можуть заблокувати нитку, і тому менш загрожують проблемами масштабованості. У ситуаціях, коли операцій читання набагато більше, ніж записи, volatile-змінні можуть дати виграш у продуктивності порівняно з локами.
Умови правильного використання
Замінити локи на volatile можна в обмеженій кількості обставин. Для ниткобезпеки необхідно, щоб виконувались обидва критерії:
  1. Те, що записується в змінну, залежить від її поточного значення.
  2. Змінна не бере участі в інваріантах з іншими змінними.
Простіше кажучи, ці умови означають, що коректні значення, які можуть бути записані в volatile-змінну, не залежать від іншого стану програми, включаючи поточний стан змінної. Перша умова виключає використання volatile-змінних як ниткобезпечних лічильників. Хоча інкремент (x++) виглядає як одна операція, насправді це ціла послідовність операцій читання-зміни-запису, яка має виконуватися атомарно, чого volatile не забезпечує. Коректна операція вимагала б, щоб значення x залишалося незмінним протягом усієї операції, чого не можна досягти за допомогою volatile. (Але якщо вам вдасться забезпечити, що значення записуватиметься лише з однієї нитки, першу умову можна опустити). У більшості ситуацій буде порушено або перше, або друге умови, що робить volatile-змінні менш використовуваним підходом до досягнення нітебезпеки, ніж synchronized. У лістингу 1 показаний не-нитебезопасный клас з діапазоном чисел. Він містить інваріант - нижня межа завжди менша або дорівнює верхній. @NotThreadSafe public class NumberRange { private int lower, upper; public int getLower() { return lower; } public int getUpper() { return upper; } public void setLower(int value) { if (value > upper) throw new IllegalArgumentException(...); lower = value; } public void setUpper(int value) { if (value < lower) throw new IllegalArgumentException(...); upper = value; } } Оскільки змінні стани діапазону обмежені таким чином, буде недостатнім зробити поля lower і upper volatile, щоб забезпечити клас класу; як і раніше, буде потрібна синхронізація. Інакше рано чи пізно не пощастить і дві нитки, що виконали setLower() і setUpper() з невідповідними значеннями, можуть привести діапазон у суперечливий стан. Наприклад, якщо початкове значення (0, 5), нитка A викликає setLower(4), і в той же час нитка B викликає setUpper(3), ці операції, що перемежуються, призведуть до помилки, хоча обидві пройдуть перевірку, яка повинна захищати інваріант. У результаті діапазон буде (4, 3) – невірні значення. Нам потрібно зробити setLower() і setUpper() атомарними по відношенню до інших операцій над діапазоном - і присвоєння полям volatile цього не зробить.
Міркування продуктивності
Перша причина використання volatile – простота. У деяких ситуаціях, використовувати таку змінну просто простіше, ніж лок, що відноситься до неї. Друга причина - продуктивність, іноді volatile працюватимуть швидше, ніж локи. Надзвичайно важко зробити точні всеосяжні заяви виду "X завжди швидше, ніж Y," особливо коли йдеться про внутрішні операції віртуальної машини Java. (Наприклад, JVM може повністю зняти блокування в деяких ситуаціях, що ускладнює абстрактне обговорення витрат на volatile по відношенню до синхронізації). Тим не менш, на більшості сучасних процесорних архітектур витрати на читання volatile мало відрізняються від витрат на читання звичайних змінних. Витрати на запис volatile значно більші, ніж на запис звичайних змінних, через огородження пам'яті, необхідного для забезпечення видимості, але в цілому дешевше, ніж установка локів.
Паттерни для правильного використання
Багато експертів з паралелізму схильні уникати використання volatile-змінних взагалі, тому що їх важче використовувати правильно, ніж локи. Однак існують деякі чітко визначені патерни, які, якщо слідувати їм уважно, можуть безпечно використовуватися в різних ситуаціях. Завжди враховуйте обмеження volatile - використовуйте тільки volatile, які ніяк не залежать від решти в програмі, і це повинно не дозволити вам залізти з цими патернами на небезпечну територію.
Паттерн №1: прапори стану
Можливо, канонічне використання мінливих змінних - це прості бульові прапори стану, що вказують на те, що відбулася важлива одноразова подія життєвого циклу, така як завершення ініціалізації або запит на завершення роботи. Багато програм включають конструкцію управління форми: «Поки ми не готові вимкнутись, продовжуємо працювати», як показано в лістингу 2: volatile boolean shutdownRequested; ... public void shutdown() { shutdownRequested = true; } public void doWork() { while (!shutdownRequested) { // do stuff } } Ймовірно, метод shutdown () буде викликатися звідкись ззовні циклу - в іншій нитці - тому потрібна синхронізація для забезпечення правильної видимості змінної shutdownRequested. (Він може бути викликаний зі слухача JMX, слухача дій у нитки подій GUI, через RMI, через веб-службу тощо). Однак цикл із синхронізованими блоками буде набагато громіздкішим, ніж цикл із volatile-прапором стану як у лістингу 2. Оскільки volatile спрощує написання коду, а прапор стану не залежить від будь-якого іншого стану програми, це приклад хорошого використання volatile. Для таких прапорів статусу характерним є те, що зазвичай існує тільки один перехід стану; прапор shutdownRequested переходить із false в true, а потім програма вимикається. Цей патерн можна розширити до прапорів стану, які можуть змінюватися туди й назад, але якщо цикл переходу (від false до true to false) відбуватиметься без зовнішніх втручань. В іншому випадку необхідний якийсь атомарний механізм переходу, такий як змінні атомарні.
Паттерн №2: одноразова безпечна публікація
Помилки видимості, які можливі за відсутності синхронізації, можуть стати ще складнішим питанням, коли відбувається запис посилань на об'єкти замість примітивних значень. Без синхронізації можна побачити актуальне значення для посилання на об'єкт, записаний іншою ниткою, і як і раніше бачити застарілі значення стану цього об'єкта. (Ця загроза лежить в корені проблеми з сумнозвісним блокуванням з подвійною перевіркою, де посилання на об'єкт зчитується без синхронізації, і ви ризикуєте побачити актуальне посилання, але отримати через нього частково сконструйований об'єкт.) Один із способів безпечної публікації об'єкта полягає в тому, щоб зробити посилання на об'єкт volatile. У лістингу 3 показаний приклад, де під час запуску фоновий потік завантажує деякі з бази даних. Інший код, коли може спробувати використовувати ці дані, перевіряє, чи він був опублікований, перш ніж намагатися його використовувати. public class BackgroundFloobleLoader { public volatile Flooble theFlooble; public void initInBackground() { // делаем много всякого theFlooble = new Flooble(); // единственная запись в theFlooble } } public class SomeOtherClass { public void doWork() { while (true) { // чё-то там делаем... // используем theFolooble, но только если она готова if (floobleLoader.theFlooble != null) doSomething(floobleLoader.theFlooble); } } } Якби посилання на theFlooble не було volatile, код doWork() ризикував би побачити частково сконструйований Flooble при спробі звернутися по theFlooble. Ключовою вимогою для цього патерну є те, що об'єкт, що публікується, повинен бути або ниткобезпечним, або за фактом незмінним (фактично незмінний означає, що його стан ніколи не змінюється після його публікації). Volatile-посилання може гарантувати видимість об'єкта в його опублікованій формі, але якщо стан об'єкта буде змінюватися після публікації, то потрібна додаткова синхронізація.
Паттерн №3: незалежні спостереження
Інший простий приклад безпечного застосування volatile - ситуація, коли спостереження періодично публікуються для використання в рамках програми. Наприклад, є датчик довкілля, який визначає поточну температуру. Фонова нитка може зчитувати показання цього датчика з періодом кілька секунд і оновлювати volatile-змінну, що містить поточну температуру. Потім інші нитки можуть зчитувати цю змінну, знаючи, що значення в ній завжди найактуальніше. Ще одне використання цього патерну – збір статистики про програму. У лістингу 4 показано, як механізм автентифікації може запам'ятовувати ім'я останнього користувача, що залогінівся. Посилання lastUser буде повторно використовуватися для публікації значення для використання іншою частиною програми. public class UserManager { public volatile String lastUser; public boolean authenticate(String user, String password) { boolean valid = passwordIsValid(user, password); if (valid) { User u = new User(); activeUsers.add(u); lastUser = user; } return valid; } } Цей патерн розширює попередній; Значення публікується для використання десь у програмі, але публікація не одноразова подія, а серія незалежних. Цей патерн вимагає, щоб опубліковане значення було фактично незмінним – що його стан після публікації не змінювався. Код, який використовує значення, повинен знати, що воно може будь-якої миті змінитися.
Патерн № 4: патерн "volatile bean"
Паттерн “volatile bean” застосуємо у фреймворках, що використовують JavaBeans як “glorified structs”. У патерні “volatile bean” JavaBean використовується як контейнер для групи незалежних властивостей з гетерами та/або сеттерами. Обгрунтуванням необхідності патерну “volatile bean” є те, що багато фреймворків надають контейнери для змінних власників даних (наприклад, HttpSession), але об'єкти, поміщені в ці контейнери, повинні бути ниткобезпечними. У патттерні volatile bean всі елементи даних JavaBean є volatile, а гетери та сеттери повинні бути тривіальними - вони не повинні містити ніякої логіки, крім отримання або встановлення відповідної властивості. Крім того, для членів даних, які є об'єктними посиланнями, ці об'єкти повинні бути ефективно незмінними. (Це забороняє наявність полів-посилань на масиви, тому що коли посилання масиву оголошено volatile, тільки це посилання, а не самі елементи, має властивість volatile.) Як і в будь-якій volatile-змінній, не може бути ніяких інваріантів або обмежень, пов'язаних з властивостями JavaBeans Приклад JavaBean, написаного за патерном "volatile bean", показаний у лістингу 5: @ThreadSafe public class Person { private volatile String firstName; private volatile String lastName; private volatile int age; public String getFirstName() { return firstName; } public String getLastName() { return lastName; } public int getAge() { return age; } public void setFirstName(String firstName) { this.firstName = firstName; } public void setLastName(String lastName) { this.lastName = lastName; } public void setAge(int age) { this.age = age; } }
Більш складні volatile-патерни
Паттерни в попередньому розділі охоплюють більшість типових випадків, коли використання volatile є виправданим і очевидним. У цьому розділі розглядається більш складний патерн, в якому volatile може забезпечити перевагу продуктивності або масштабованості. Більше просунуті патерни використання volatile можуть бути надзвичайно крихкими. Вкрай важливо, щоб ваші припущення були ретельно задокументовані, а ці патерни сильно інкапсульовані, тому що навіть дрібні зміни можуть зламати ваш код! Крім того, враховуючи, що основною причиною більш складних варіантів використання volatile є продуктивність, переконайтеся, що у вас дійсно є виражена потреба в передбачуваному посиленні продуктивності, перш ніж застосовувати їх. Ці патерни є компромісами, які жертвують читабельністю або легкістю підтримки заради можливого підвищення продуктивності - якщо вам не потрібно підвищення продуктивності (або ви не можете довести, що вам це потрібно, за допомогою суворої програми вимірювання), то це, ймовірно, погана угода, тому що ви відмовляєтеся від чогось цінного та отримуєте щось менше натомість.
Паттерн №5: дешевий лок читання-запису
Зараз ви повинні добре розуміти, що volatile занадто слабка для реалізації лічильника. Оскільки ++ x за фактом скорочення трьох операцій (читання, додавання, зберігання), при невдалому збігу обставин ви втратите оновлене значення, якщо кілька потоків спробують одночасно збільшити volatile-лічильник. Однак, якщо операцій читання значно більше, ніж зміни, ви можете об'єднати вбудоване блокування та volatile-змінні, щоб знизити витрати на загальний шлях коду. У лістингу 6 показаний ниткобезпечний лічильник, який використовує synchronized, щоб гарантувати, що операція збільшення є атомарною, і використовує volatile, щоб гарантувати видимість поточного результату. Якщо оновлення нечасті, цей підхід може поліпшити продуктивність, оскільки витрати на читання обмежені читанням volatile, яке зазвичай дешевше, ніж отримання неконфліктуючого локу. @ThreadSafe public class CheesyCounter { // Employs the cheap read-write lock trick // All mutative operations MUST be done with the 'this' lock held @GuardedBy("this") private volatile int value; public int getValue() { return value; } public synchronized int increment() { return value++; } } Причина, через яку цей метод називається «дешевим локом читання-запису», полягає в тому, що ви використовуєте різні механізми синхронізації для читання та запису. Оскільки операції запису в цьому випадку порушують першу умову використання volatile, ви не можете використовувати volatile для безпечної реалізації лічильника – ви повинні використовувати блокування. Однак ви можете використовувати volatile для забезпечення видимості поточного значення під час читання, тому ви використовуєте блокування для всіх операцій зміни та volatile для операцій read-only. Якщо лок дозволяє тільки одну нитку за раз отримувати доступ до значення, volatile-читання допускають більше одного, тому, коли ви використовуєте volatile для захисту читання, ви отримуєте більш високий рівень обміну, ніж якщо б ви використовували блокування для всього коду: і читання, та записи. Однак майте на увазі крихкість цього патерну: з двома конкуруючими механізмами синхронізації він може стати дуже складним, якщо ви вийдете за межі самого базового застосування цього патерну.
Резюме
Volatile-змінні - це простіша, але слабша форма синхронізації, ніж блокування, яка в деяких випадках забезпечує кращу продуктивність або масштабованість, ніж вбудоване блокування. Якщо ви дотримуєтеся умов безпечного використання volatile - змінна дійсно незалежна і від інших змінних, і від своїх власних попередніх значень - іноді ви можете спростити код, замінивши synchronized на volatile. Однак код з використанням volatile часто буває більш крихким, ніж код із блокуванням. Пропоновані тут патерни охоплюють найпоширеніші випадки, коли волатильність – розумна альтернатива синхронізації. Дотримуючись цих патернів - і дбаючи про те, щоб не витісняти їх за їхні власні межі - ви зможете безпечно використовувати volatile у тих випадках, коли вони дають виграш.
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ