Взаємне блокування (deadlock) в java і методи боротьби з нею

При розробці багатопоточних додатків часто виникає дилема: що важливіше надійність або працездатність програми. Наприклад, ми використовуємо синхронізацію для потокової безпеки (thread safety), при цьому у випадку, неправильного порядку синхронізації, ми можемо викликати взаємну блокування. Так само, ми використовуємо пули потоків і семафори, для обмеження споживання ресурсів, при цьому помилка в такому дизайні може привести до взаємної блокування, внаслідок нестачі ресурсів. У даній статті ми поговоримо про те, як уникати взаємного блокування, а так само інших проблем в працездатності програми. Так само ми розглянемо, як може додаток бути написано таким чином, щоб мати можливість відновиться в випадки взаємного блокування.






Взаємне блокування - це ситуація в якій, два або більше процесів займаючи деякі ресурси, намагаються роздобути деякі інші ресурси, зайняті іншими процесами і жоден з процесів не може зайняти необхідний їм ресурс, і відповідно звільнити займаний. Дане визначення занадто загальне, тому складно для сприйняття, для кращого його розуміння ми розглянемо типи взаємних блокувань на прикладах.

Взаємне блокування порядку синхронізації


Розглянемо наступну задачу: необхідно написати метод здійснює транзакцію перекладу деякої кількості грошей з одного рахунку на інший. Рішення може мати наступний вигляд:


На перший погляд, цей код синхронізований цілком нормально, ми маємо атомарному операцію перевірки і зміни стану рахунку-джерела і зміна рахунку-одержувача. Але, при даній стратегії синхронізації може виникнути ситуація взаємного блокування. Давайте розглянемо приклад того, як це відбувається. Необхідно провести дві транзакції: з рахунку A на рахунок B перевести x грошей, а з рахунку B на рахунок A - y. Найчастіше ця ситуація не викличе взаємного блокування, однак, при невдалому збігу обставин, транзакція 1 займе монітор рахунки A, транзакція 2 займе монітор рахунку B. Результат - взаємне блокування: транзакція 1 чекає, поки транзакція 2 звільнить монітор рахунку B, але для цього транзакція 2 повинна отримати доступ до монітора A, зайнятому транзакцією 1.
Одна з великих проблем з взаємними блокуваннями - що їх нелегко знайти при тестуванні. Навіть в ситуації, описаної в прикладі, потоки можуть не блокуватися, тобто дана ситуація не буде постійно відтворюється, що значно ускладнює діагностику. В цілому описана проблема недетермінованости є типовою для многопоточности (хоча від цього не легше). Тому, в підвищенні якості багатопоточних додатків важливу роль відіграє code review, оскільки він дозволяє виявити помилки, які проблематично відтворити при тестуванні. Це, звичайно ж, не означає, що програма не треба тестувати, просто про code review теж не треба забувати.
Що потрібно зробити, щоб цей код не приводив до взаємної блокування? Дана блокування викликана тим, що синхронізація рахунків може відбуватися в різному порядку. Відповідно, якщо ввести деякий порядок на рахунках (це деяке правило, що дозволяє сказати, що рахунок A менше ніж рахунок B), то проблема буде усунена. Як це зробити? По-перше, якщо у рахунків є якийсь унікальний ідентифікатор (наприклад, номер рахунку) чисельний, рядковий або ще якийсь з природним поняттям порядку (рядки можна порівнювати в лексикографічному порядку. То можемо вважати, що нам пощастило, і ми завжди можемо спочатку займати монітор меншого рахунку, а потім більшого (або навпаки).

Другий варіант, якщо такого ідентифікатора у нас немає, то доведеться його придумати самим. Ми можемо в першому наближенні порівнювати об'єкти по хеш-коду. Швидше за все, вони будуть відрізнятися. Але що робити, якщо вони все ж виявляться однаковими? Тоді доведеться додати ще один об'єкт для синхронізації. Це може виглядати дещо витонченим, але що поробиш. Та й до того ж, третій об'єкт буде використовуватися досить рідко. Результат буде виглядати наступним чином:







Взаємне блокування між об'єктами


Описані умови блокування представляють найбільш простий по діагностиці випадок взаємного блокування. Найчастіше в багатопотокових застосуваннях різні об'єкти намагаються отримати доступ до одних і тих же синхронізованим блокам. При цьому може виникнути взаємне блокування. Розглянемо наступний приклад: додаток для диспетчера польотів. Літаки повідомляють диспетчеру, коли вони прибули на місце призначення і запитують дозвіл на посадку. Диспетчер зберігає всю інформацію про літаки, що летять в його напрямку, і може будувати їх розташування на карті.

Зрозуміти, що в цьому код є помилка, яка може привести до взаємної блокування складніше, ніж в попередньому. На перший погляд, в ньому немає повторних синхронізацій, проте це не так. Ви, напевно, вже помітили, що методи setLocation класу Plane і getMap класу Dispatcher, є синхронізованими і викликають всередині себе синхронізовані методи інших класів. Це в цілому погана практика. Про те, як це можна виправити, мова піде в наступному розділі. В результаті, якщо літак прибуває на місце, в той же момент, як хтось вирішує отримати карту може виникнути взаємне блокування. Тобто, будуть викликані методи, getMap і setLocation, які займуть монітори примірників Dispatcher і Plane відповідно. Потім метод getMap викличе plane.getLocation (зокрема для примірника Plane, який в даний момент зайнятий), який буде чекати звільнення монітора для кожного з примірників Plane. У той же час в методі setLocation буде викликаний dispatcher.requestLanding, при цьому монітор примірника Dispatcher залишається зайнятий малюванням карти. Результат - взаємне блокування.

відкриті виклики


З метою недопущення ситуацій на зразок описаної в попередньому розділі рекомендується використовувати відкриті виклики до методів інших об'єктів. Тобто, викликати методи інших об'єктів поза синхронізованого блоку. Якщо із застосуванням принципу відкритих викликів переписати методи setLocation і getMap можливість взаємного блокування буде усунена. Виглядати це буде, наприклад, так:

Ресурсна взаємне блокування


Взаємні блокування можуть виникати так само при спробі отримати доступ до деяких ресурсів, які може використовувати одночасно тільки один потік. Прикладом може служити пул з'єднань з базами даних. Якщо деяким потокам необхідний доступ одночасно до двох сполук, і вони отримують цей доступ в різному порядку, це може привести до взаємної блокування. Принципово, такого роду блокування ні чим не відрізняються від блокувань порядку синхронізації, крім того, що виникають не при спробі виконати деякий код, а при спробі отримати доступ до ресурсів.

Як уникати взаємних блокувань?


Безумовно, якщо код написаний без будь-яких помилок (приклади яких ми бачили в попередніх розділах), то взаємних блокувань в ньому не буде. Але хто може поручитися, що його код написаний без помилок? Безумовно, тестування допомагає виявити значну частину помилок, але як ми вже бачили раніше, помилки в багатопотоковому коді нелегко діагностувати і навіть після тестування можна бути впевненим у відсутності ситуацій взаємних блокувань. Чи можемо ми якось перестрахуватися від блокувань? Відповідь - так. Подібні техніки застосовуються в двигунах баз даних, яким нерідко необхідно відновлюватися після взаємних блокувань (пов'язаних з механізмом транзакцій в БД). Інтерфейс Lock і його реалізації доступні в пакеті java.util.concurrent.locks дозволяють спробувати зайняти монітор, пов'язаний з екземпляром даного класу методом tryLock (повертає true, якщо вдалося зайняти монітор). Нехай у нас є пара об'єктів реалізують інтерфейс Lock і нам необхідно зайняти їх монітори так, щоб уникнути взаємного блокування. Реалізувати це можна так:

Як видно в цій програмі ми займаємо два монітори, при цьому, виключаючи можливість взаємного блокування. Зверніть увагу, блок try- finally необхідний, оскільки класи з пакета java.util.concurrent.locks автоматично не звільняють монітор, і якщо в процесі виконання вашого завдання виникло якесь виключення, то монітор зависне в заблокованому стані.

Як діагностувати взаємні блокування?


JVM дозволяє діагностувати взаємні блокування відображаючи їх в дампи потоків. Такі дампи включають інформацію про те, в якому стані знаходиться потік. Якщо він заблокований, то дамп містить інформацію про моніторі, звільнення якого потік очікує. Перш ніж вивести дамп потоків JVM переглядає граф очікуваних (зайнятих) моніторів, і якщо знаходить цикли - додає інформацію про взаємну блокування, вказуючи беруть участь монітори і потоки.
Дамп потоків з взаємним блокуванням виглядає так:

Наведений вище дамп явно показує, що два потоку, що працюють з базою даних заблокували один одного.
Для того щоб діагностувати взаємні блокування за допомогою цієї особливості JVM необхідно розмістити виклики операції дампа потоків в різних місцях програми і провести тестування програми. Далі слід проаналізувати отримані логи. У випадку якщо в них буде вказано, що сталася взаємне блокування, інформація з дампа допоможе виявити умови її виникнення.

В цілому, слід не допускати ситуацій, наведених в прикладах взаємних блокувань. У такому випадки додаток, швидше за все, буде працювати стабільно. Але не забувайте про тестування і код рев'ю. Це допоможе виявити неполадки, якщо вони все ж виникнуть. У випадки, якщо ви розробляєте систему, для якої критично відновлення поле взаємних блокувань, можна використовувати метод, описаний в розділі «Як уникати взаємних блокувань?». У цьому випадки може так само виявитися корисним метод lockInterruptibly інтерфейсу Lock з пакета java.util.concurrent.locks. Він може бути скасована потік зайняв монітор цим методом (і таким чином звільнити монітор).







Схожі статті