Deadlock (взаємне блокування) — це стан, у якому два або більше потоків нескінченно очікують звільнення ресурсів, захоплених іншими учасниками. Згідно з Oracle Java Tutorials (2024), Deadlock виникає при круговому очікуванні, коли кожний потік утримує блокування, необхідне іншому потоку. Без спеціальних засобів виявлення Deadlock повністю зупиняє виконання додатка без видимих помилок.
Головне
Deadlock — це ситуація в багатопотоковому програмуванні, при якій два або більше потоків назавжди блокують один одного. Кожний потік утримує ресурс, необхідний іншому потоку, і не звільняє його, очікуючи захоплення відсутнього ресурсу. У результаті жоден із потоків не може продовжити виконання.
У мобільній розробці Deadlock особливо критичний, тому що він не викликає винятків або крашів. Додаток просто перестає відповідати на дії користувача (ANR — Application Not Responding), і єдиний вихід — примусове завершення процесу. За даними Google (Android Performance Patterns, 2023), близько 15% ANR-звітів у Google Play Console пов'язані з взаємними блокуваннями у фонових потоках.
Ключова відмінність Deadlock від інших проблем конкурентності — його незворотність без зовнішнього втручання. Потоки не звільнять ресурси самостійно, оскільки планувальник операційної системи не може примусово відкликати блокування. Це відрізняє Deadlock від Livelock, де потоки активні, але не виконують корисної роботи.
У 1971 році Edward G. Coffman сформулював чотири обов'язкові умови, необхідні для виникнення Deadlock. Якщо хоча б одна з них відсутня, взаємне блокування неможливе. Ці умови відомі як умови Коффмана і лежать в основі всіх алгоритмів запобігання Deadlock.
Ресурс може бути захоплений тільки одним потоком у кожний момент часу. Якщо ресурс допускає одночасне читання кількома потоками (наприклад, ReadWriteLock у режимі читання), Deadlock не виникає. Ця умова випливає із самої природи Mutex та блокувань.
Потік утримує вже захоплений ресурс і одночасно очікує захоплення іншого ресурсу. Якщо потік може звільнити поточний ресурс перед запитом наступного (через two-phase locking), умова Hold and Wait порушується. В Android це часто проявляється, коли потік утримує блокування БД і намагається захопити блокування SharedPreferences.
Операційна система не може примусово відібрати блокування у потоку. Ресурс звільняється тільки коли потік сам його відпускає. У деяких системах (наприклад, SQLite WAL mode) реалізовано примусове витіснення на рівні окремих операцій, що знижує ризик Deadlock.
Існує замкнута ланцюг потоків, кожний з яких очікує ресурсу, що утримується наступним у ланцюзі. Наприклад, потік A утримує ресурс 1 і чекає ресурс 2, потік B утримує ресурс 2 і чекає ресурс 1. Це єдина умова, яку розробник може усунути архітектурно — через ієрархію блокувань. Якщо всі потоки захоплюють ресурси в строго заданому глобальному порядку, цикл фізично неможливий.
На практиці в Android-додатках Deadlock найчастіше виникає через неявне перетин блокувань різних рівнів: блокування БД (Room), блокування SharedPreferences та блокування колекції в пам'яті. Кожне з цих блокувань керується різними компонентами, і без централізованого протоколу порядку захоплення розробники ненавмисно створюють цикли.
Розглянемо класичний приклад взаємного блокування — два потоки захоплюють блокування в різному порядку. Якщо перший потік блокує ресурс A і намагається захопити B, а другий — блокує B і намагається захопити A, виникає Deadlock.
class DeadlockExample {
private val lockA = Any()
private val lockB = Any()
fun operationA() {
synchronized(lockA) {
Thread.sleep(50) // симуляція роботи
synchronized(lockB) {
println("operationA виконана")
}
}
}
fun operationB() {
synchronized(lockB) { // зворотний порядок
Thread.sleep(50)
synchronized(lockA) {
println("operationB виконана")
}
}
}
}
fun main() {
val ex = DeadlockExample()
Thread { ex.operationA() }.start()
Thread { ex.operationB() }.start()
// Додаток зависне назавжди — Deadlock!
}
У цьому прикладі operationA захоплює lockA, а operationB захоплює lockB. Потім кожний намагається захопити друге блокування — і обидва нескінченно чекають. Програма зависає без повідомлення про помилку. Єдиний спосіб виправлення — гарантувати однаковий порядок захоплення блокувань у всіх методах.
Ці три проблеми конкурентності часто плутають, але їх механізми та наслідки принципово різні. Deadlock — повна зупинка, Starvation — нескінченне очікування ресурсу, Livelock — активна бездіяльність. Розуміння відмінностей критично важливе для вибору правильної стратегії усунення.
| Характеристика | Deadlock | Starvation | Livelock |
|---|---|---|---|
| Стан потоків | Заблоковані (BLOCKED) | Готові (RUNNABLE) | Активні (RUNNABLE) |
| Виконання роботи | Ні | Ні | Так, але марне |
| Причина | Циклічне очікування | Несправедливе планування | Некоректна обробка конфлікту |
| Виявлення | Thread Dump, таймаути | Моніторинг прогресу | Лічильник повторних спроб |
Starvation (голодування) виникає, коли планувальник постійно відкладає виконання низькоприоритетного потоку на користь інших. На відміну від Deadlock, потік не заблокований — він готовий до виконання, але не отримує процесорного часу. В Android типовий сценарій — фоновий потік з низьким пріоритетом ніколи не виконується, якщо UI-потік і Service-потоки постійно активні.
Livelock (активне блокування) — ситуація, при якій потоки не заблоковані, але нескінченно реагують на дії один одного, не виконуючи корисної роботи. Класична аналогія — дві людини зустрічаються в коридорі і обидва намагаються поступитися дорогою, рухаючись в одну й ту ж сторону. На відміну від Deadlock, потоки в Livelock споживають CPU, розряджаючи батарею пристрою.
Thread Dump (дамп потоків) — основний інструмент виявлення взаємних блокувань у JVM та Android Runtime. При дампі JVM автоматично аналізує граф залежностей між моніторами та позначає Deadlock-цикли. В Android Studio дамп потоків можна отримати через Android Profiler або команду kill -3 PID з ADB Shell.
Автоматичне виявлення Deadlock на етапі виконання реалізується через Watchdog-таймери. Якщо потік не завершує операцію за заданий таймаут, watchdog ініціює зняття дампа та відправляє звіт у Crash Reporting-систему (Firebase Crashlytics, Sentry). За даними Sentry (Issue Resolution Report, 2024), налаштування watchdog скорочує час діагностики Deadlock з тижнів до кількох годин.
На етапі розробки ефективні статичний аналізатор ThreadSafe від JetBrains та Checker Framework з модулем Lock Checker. Ці інструменти аналізують порядок захоплення блокувань на рівні вихідного коду та попереджають про потенційні цикли. Додатково рекомендується використовувати Test-Driven Deadlock Detection — стрес-тести, які запускають операції з різними порядками блокувань у сотнях потоків.
Окремої уваги заслуговує Cooperative Deadlock Detection — метод, при якому потоки обмінюються інформацією про захоплені блокування через глобальний registry. Якщо потік виявляє потенційний цикл, він звільняє всі ресурси та повторює операцію. Цей підхід використовується в розподілених системах (Apache ZooKeeper, Google Chubby) і поступово впроваджується в мобільну розробку через бібліотеки типу Jetpack Sync.
Найнадійніший спосіб — встановити глобальний порядок захоплення блокувань у всьому додатку. Якщо всі потоки завжди спочатку захоплюють блокування з меншим номером, а потім з більшим, циклічне очікування (умова Circular Wait) неможливе. У великих проектах порядок фіксується в документації та перевіряється code review.
TryLock — метод блокування, який не блокує потік нескінченно, а повертає false, якщо блокування не отримано за заданий час. В Java це реалізовано через ReentrantLock.tryLock(timeout, TimeUnit), у Kotlin Coroutines — через Mutex.withLock з таймаутом. При невдачі потік звільняє всі захоплені ресурси та повторює спробу пізніше.
Алгоритм банкіра — теоретичний метод запобігання Deadlock, запропонований Edsger Dijkstra. Він моделює розподіл ресурсів як банківські транзакції: система не виділяє ресурс, якщо це може призвести до небезпечного стану (deadlock). На практиці алгоритм рідко застосовується в мобільній розробці через складність попереднього знання максимальних потреб потоків, але його принципи використовуються в БД SQLite та файлових системах.
Часто задавані питання
Ні, для взаємного блокування необхідно мінімум два потоки. В однопотоковому коді всі операції виконуються послідовно, тому циклічне очікування неможливе. Однак Deadlock може виникнути між процесами при використанні файлових блокувань або міжпроцесних семафорів.
У корутинах Deadlock виникає на рівні призупинених функцій (suspend) і не блокує потік ОС, що робить його менш помітним. Mutex з kotlinx.coroutines — призупиняючий (suspending), він не блокує потік, але корутина при цьому не виконується. Для виявлення використовуйте DebugProbes з модуля kotlinx-coroutines-debug.
SQLite Deadlock виникає, коли два з'єднання з БД намагаються виконати транзакції в різних порядках. SQLite виявляє такі ситуації та повертає код помилки SQLITE_BUSY або SQLITE_LOCKED. В Android рекомендується використовувати Room з єдиним екземпляром БД та транзакціями через @Transaction, що усуває міжз'єднальний Deadlock.
Android Runtime має вбудований детектор Deadlock, який запускається при генерації ANR (Application Not Responding). Система аналізує Thread Dump всіх потоків додатка та позначає взаємні блокування. Результат доступний у /data/anr/traces.txt та Google Play Console у розділі ANR Reports.
Першим ділом отримайте Thread Dump всіх потоків додатка. Проаналізуйте, які блокування утримує кожний потік і які намагається захопити. Впровадьте Watchdog-таймер з автоматичним дампом при перевищенні ліміту часу. Після виправлення додайте lint-правило ThreadSafety у CI-пайплайн для запобігання рецидивам.
Підсумки
Ми розробимо мобільний застосунок під ключ
IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також