Основи Паралелізму: взаимоблокировки і монітори об'єктів (розділи 1, 2) (переклад статті)
Ця статися - частина нашого курсу Основи Паралелізму в Java.
У цьому курсі ви поринете в магію паралелізму. Ви пізнаєте основи паралелізму і паралельного коду, познайомитеся з такими концепціями як атомарность, синхронізація нітебезопасность. Погляньте на нього тут!
При розробці додатків, що використовують паралелізм для досягнення поставлених цілей, ви можете зіткнутися з ситуаціями, в яких різні нитки можуть блокувати один одного. Якщо в цій ситуації додаток працює повільніше, ніж очікувалося, ми б сказали, що воно відпрацьовує за часом не так, як передбачалося. В даному розділі ми ближче познайомимося з проблемами, які можуть загрожувати живучості багатонитковою додатки.
Термін взаімоблокіровка добре відомий розробникам ПО і навіть більшість звичайних користувачів використовують його час від часу, хоча і не завжди в правильному розумінні. Строго кажучи, цей термін означає, що кожна з двох (або більше) ниток чекають від іншої нитки, щоб вона звільнила заблокований нею ресурс, в той час як перша сам заблокувала ресурс, доступу до якого чекає друга:
Для кращого розуміння проблеми поглянемо на наступний код:
Як можна бачити з наведеного коду, дві нитки стартують і намагаються заблокувати два статичних ресурсу. Але для взаимоблокировки нам потрібна різна послідовність для обох ниток, тому ми використовуємо екземпляр об'єкта Random, щоб вибрати який ресурс нитка хоче заблокувати першим. Якщо логічна змінна b має значення істина, то першим блокується resource1, а після нитка намагається отримати блокування для resource2. Якщо b - брехня, тоді нитка блокує resource2, а після намагається захопити resource1. Цією програмою не потрібно довго виконуватися для досягнення першої взаимоблокировки, тобто програма зависне назавжди, якщо ми не перервемо її:
В даному запуску tread-1 заволодів блокуванням resource2 і очікує блокування resource1, в той час як tread-2 заблокував resource1 і очікує resource2.
Якби ми поставили значення логічної змінної b в наведеному вище коді рівним істині, то не змогли б спостерігати ніякої взаимоблокировки, тому що послідовність, в якій tread-1 і thread-2 запитують блокування, завжди була б однією і тією ж. У цій ситуації одна з двох ниток отримала б блокування першої та потім запитувала б другу, яка як і раніше доступна, оскільки інша нитка очікує першої блокування.
Загалом, можна виділити наступні необхідні умови виникнення взаимоблокировки:
- Спільне виконання: Існує ресурс, який може бути доступний тільки однієї нитки в довільний момент часу.
- Утримання ресурсу: Під час захоплення одного ресурсу, нитка намагається роздобути ще одну блокування якогось унікального ресурсу.
- Відсутність пріоритетного переривання обслуговування: Відсутній механізм, який звільняє ресурс, якщо одна нитка утримує блокування певний проміжок часу.
- Круговий очікування: Під час виконання виникає сукупність ниток, в якій дві (або більше) ниток чекають один від одного звільнення ресурсу, який був заблокований.
Хоча список умов і виглядає довгим, нерідко добре налагоджені багатонитковою додатки мають проблеми взаимоблокировки. Але ви можете запобігти їх, якщо зумієте зняти одне з умов, наведених вище:
- Спільне виконання: ця умова часто не може бути знято, коли ресурс повинен використовуватися тільки кимось одним. Але це не обов'язково має стати причиною. При використанні DBMS систем можливим рішенням замість використання песимістичною блокування за деякою рядку таблиці, яка повинна бути оновлена, можна використовувати техніку, звану Оптимістичної Блокуванням.
- Спосіб уникнути утримання ресурсу під час очікування іншого ексклюзивного ресурсу полягає в тому, щоб блокувати всі необхідні ресурси на початку алгоритму і звільняти теж все, якщо неможливо їх заблокувати разом. Звичайно, це не завжди можливо, може бути ресурси, що вимагають блокування заздалегідь невідомі або такий підхід просто призведе до марної трати ресурсів.
- Якщо блокування не може бути отримана негайно, способом обходу можливої взаимоблокировки є введень таймаута. Наприклад, клас ReentrantLock з SDK забезпечує можливість завдання терміну дії для блокування.
- Як ми побачили з наведеного вище прикладу, взаімоблокіровка не виникає, якщо послідовність запитів не відрізняється у різних ниток. Це легко проконтролювати, якщо ви можете помістити весь блокуючий код в один метод, через який повинні пройти всі нитки.
У більш просунутих додатках ви навіть можете задумати реалізацію системи виявлення взаімоблокіровок. Тут вам знадобиться реалізувати деяку подобу моніторингу ниток, в якій кожна нитка повідомляє про успішне отримання права блокування і своїй спробі отримати блокування. Якщо нитки і блокування змодельовані як орієнтований граф, ви можете виявити, коли дві різних нитки утримують ресурси, намагаючись одночасно отримати доступ до інших заблокованим ресурсів. Якщо потім ви зможете змусити блокують нитки звільнити необхідні ресурси, то зможете вирішити ситуацію взаимоблокировки автоматично.
Планувальник вирішує, яку з ниток, які перебувають в стані RUNNABLE. він повинен виконати наступній. Рішення ґрунтується на пріоритеті нитки; тому нитки з меншим пріоритетом отримують менше процесорного часу, в порівнянні з тими, у яких пріоритет вище. Те, що виглядає розумним рішенням, також може стати причиною проблем при зловживанні. Якщо більшу частину часу виконуються нитки з високим пріоритетом, то фонові нитки начебто починають «голодувати», оскільки не отримують достатньо часу для того, щоб виконати свою роботу належним чином. Тому рекомендується ставити пріоритет нитки тільки тоді, коли для цього є вагомі причини.
Неочевидний приклад голодування нитки дає, наприклад, метод finalize (). Він надає в мові Java можливість виконати код перед тим, як об'єкт буде видалений складальником сміття. Але якщо ви поглянете на пріоритет фіналізують нитки, то помітите, що вона запускається ні з найвищим пріоритетом. Отже, виникають передумови для Нитяні голодування, коли методи finalize () вашого об'єкта витрачають занадто багато часу в порівнянні з рештою кодом.
Інша проблема з часом виконання виникає від того, що не визначене, в якому порядку нитки проходять блок synchronized. Коли багато паралельних ниток проходять певний код, який оформлений в блок synchronized, може трапитися так, що одним ниткам доведеться чекати довше, ніж іншим, перш ніж увійти в блок. У теорії вони можуть ніколи туди не потрапити.
Рішення даної проблеми - так звана «справедлива» блокування. Справедливі блокування враховують час очікування ниток, коли визначають, кого пропустити наступним. Приклад реалізації справедливої блокування є в Java SDK: java.util.concurrent.locks.ReentrantLock. Якщо використовується конструктор з логічним прапором, встановленим в значення істина, то ReentrantLock дає доступ нитки, яка чекає довше за інших. Це гарантує відсутність голоду але, в той же час, призводить до проблеми ігнорування пріоритетів. Через це процеси з меншим пріоритетом, які часто очікують на цьому бар'єрі, можуть виконуватися частіше. Нарешті, що важливо, клас ReentrantLock може розглядати тільки нитки, які очікують блокування, тобто нитки, які запускалися досить часто і досягли бар'єру. Якщо пріоритет нитки занадто низький, то для неї це не буде відбуватися часто, і тому високопріоритетні нитки як і раніше будуть проходити блокування частіше.
У багатонитковою обчисленнях звичайною ситуацією є наявність деяких робочих ниток, які чекають, що їх виробник створить для них якусь роботу. Але, як ми дізналися, активне очікування в циклі з перевіркою деякого значення не є хороший варіант з точки зору процесорного часу. Використання в цій ситуації методу Thread.sleep () також не особливо підходить, якщо ми хочемо почати нашу роботу негайно після надходження.
Для цього мову програмування Java володіє іншою структурою, яка може бути використана в даній схемі: wait () і notify (). Метод wait (), успадковані всіма об'єктами від класу java.lang.Object, може бути використаний для припинення виконання поточної нитки і очікування до тих пір, поки інша нитка не розбудить нас, використовуючи метод notify (). Для того, щоб працювати правильно, нитка, що викликає метод wait (), повинна утримувати блокування, яку вона попередньо отримала, використовуючи ключове слово synchronized. При виклику wait () блокування звільняється і нитка очікує, поки інша нитка, яка тепер заволоділа блокуванням, не викличе notify () для того самого екземпляра об'єкта.
У багатонитковою додатку природно може бути більше однієї нитки, яка чекає на повідомлення на якомусь об'єкті. Тому існує два різних методу для побудки ниток: notify () і notifyAll (). У той час як перший метод будить одну з очікують ниток, метод notifyAll () пробуджує їх все. Але знайте, що, як і в разі ключового слова synchronized, відсутня правило, що визначає, яка нитка буде розбуджена наступній при виклику notify (). У простому прикладі з виробником і споживачем це не має значення, оскільки нам не важливо, яка саме нитка прокинулася.
Наступний код показує як механізм wait () і notify () може бути використаний для організації очікування нитками-споживачами нової роботи, яка додається в чергу ниткою-виробником:
Метод main () запускає п'ять ниток-споживачів і одну нитку-виробника, а потім чекає закінчення їх роботи. Після нитка-виробник додає нове значення в чергу і повідомляє всі очікують нитки про те, що щось сталося. Споживачі отримують блокування черги (прим. Один довільний споживач) і потім засипають, щоб бути піднятими пізніше, коли черга знову заповниться. Коли виробник закінчує свою роботу, то він повідомляє всіх споживачів, щоб розбудити. Якби ми не зробили останній крок, то нитки-споживачі вічно б чекали таке зауваження, оскільки ми не задали тайм-аут для очікування. Замість цього ми можемо використовувати метод wait (long timeout), щоб бути розбудженими, по крайней мере, після деякого часу.
Як було сказано в попередньому розділі, виклик wait () для монітора об'єкта лише знімає блокування з цього монітора. Інші блокування, які утримувалися тієї ж ниткою, не звільняються. Як це легко зрозуміти, в повсякденній роботі може трапитися так, що нитка, що викликає wait () утримує блокування далі. Якщо інші нитки також очікують ці блокування, то може виникнути ситуація взаимоблокировки. Давайте подивимося на блокування в наступному прикладі:
Як ми дізналися раніше. додавання synchronized в сигнатуру методу рівносильно створенню блоку synchronized (this)<>. У наведеному вище прикладі ми випадково додали ключове слово synchronized в метод, а після синхронізували чергу по монітору об'єкта queue, щоб відправити дану нитку в сон на час очікування наступного значення з queue. Потім, поточна нитка звільняє блокування по queue, але не блокування по this. Метод putInt () повідомляє сплячу нитка, що нове значення було додано. Але випадково ми додали ключове слово synchronized і в цей метод. Тепер, коли друга нитка заснула, вона як і раніше утримує блокування. Тому перша нитка не може увійти в метод putInt () поки це блокування утримується другий ниткою. В результаті маємо ситуацію взаимоблокировки і зависла програму. Якщо ви виконаєте наведений вище код, це відбудеться відразу після початку роботи програми.
У повсякденному житті така ситуація може не бути настільки очевидною. Блокування, утримувані ниткою, можуть залежати від параметрів і умов, що виникають під час роботи, і блок synchronized, що викликає проблему, може не бути таким близьким в коді до місця, де ми помістили виклик wait (). Це робить проблематичним пошук таких проблем, особливо якщо вони можуть виникати через деякий час або при високому навантаженні.
Часто вам потрібно перевірити виконання деякого умови, перш ніж зробити якусь дію з синхронізованим об'єктом. Коли у вас є, наприклад, чергу, ви хочете дочекатися її заповнення. Отже, ви можете написати метод, який перевіряє заповненість черзі. Якщо вона ще порожня, то ви відправляєте поточну нитку в сон до тих пір, поки вона не буде розбуджена:
Наведений вище код синхронізується по queue перш ніж викликати wait () і, потім, очікує в циклі while, поки в queue чи не з'явиться принаймні один елемент. Другий блок synchronized знову використовує queue як монітор об'єкта. Він викликає метод poll () черги, щоб отримати значення. У демонстраційних цілях викидається виключення IllegalStateException, коли poll повертає null. Це відбувається, коли в queue немає елементів для вилучення.
Коли ви запустите цей приклад, то побачите, що IllegalStateException викидається дуже часто. Хоч ми і коректно синхронізувалися по монітору queue, виняток було викинуто. Причина в тому, що у нас є два різних блоку synchronized. Уявіть, що у нас є дві нитки, які прибули до першого блоку synchronized. Перша нитка увійшла в блок і провалилася в сон, тому що queue порожня. Те ж саме істинно і для другої нитки. Тепер, коли обидві нитки прокинулися (завдяки викликом notifyAll (), викликаного іншою ниткою для монітора), вони обидві побачили значення (елемент) в черзі, доданий виробником. Потім обидві прибутку до другого бар'єра. Тут перша нитка увійшла і витягла значення з черги. Коли друга нитка входить, queue вже порожня. Тому, як значення, що повертається з queue, вона отримує null і викидає виключення.
Для запобігання подібних ситуацій вам необхідно виконувати всі операції, що залежать від стану монітора, в одному і тому ж блоці synchronized:
Тут ми виконуємо метод poll () в тому ж самому блоці synchronized, що і метод isEmpty (). Завдяки блоку synchronized ми впевнені, що тільки одна нитка виконує метод для цього монітора в заданий момент часу. Тому ніяка інша нитка не може видалити елементи з queue між викликами isEmpty () і poll ().