Deadlock у мобільній розробці: що це, причини виникнення та способи уникнути взаємного блокування

Автор: IT Sectr Опубліковано: 2026-03-18 Час читання: 10 хв

Deadlock (взаємне блокування) — це стан, у якому два або більше потоків нескінченно очікують звільнення ресурсів, захоплених іншими учасниками. Згідно з Oracle Java Tutorials (2024), Deadlock виникає при круговому очікуванні, коли кожний потік утримує блокування, необхідне іншому потоку. Без спеціальних засобів виявлення Deadlock повністю зупиняє виконання додатка без видимих помилок.

Головне

  • Deadlock — взаємне блокування потоків, при якому кожний очікує ресурс, зайнятий іншим потоком
  • Чотири умови Коффмана (Mutual Exclusion, Hold and Wait, No Preemption, Circular Wait) необхідні для виникнення Deadlock
  • Deadlock відрізняється від Starvation тим, що потоки не заблоковані, а активно очікують у циклічній залежності
  • Thread Dump — основний інструмент виявлення Deadlock у JVM та Android Runtime
  • Ієрархія блокувань та єдиний порядок захоплення ресурсів — головний спосіб запобігання взаємним блокуванням

Що таке Deadlock?

Deadlock — це ситуація в багатопотоковому програмуванні, при якій два або більше потоків назавжди блокують один одного. Кожний потік утримує ресурс, необхідний іншому потоку, і не звільняє його, очікуючи захоплення відсутнього ресурсу. У результаті жоден із потоків не може продовжити виконання.

У мобільній розробці Deadlock особливо критичний, тому що він не викликає винятків або крашів. Додаток просто перестає відповідати на дії користувача (ANR — Application Not Responding), і єдиний вихід — примусове завершення процесу. За даними Google (Android Performance Patterns, 2023), близько 15% ANR-звітів у Google Play Console пов'язані з взаємними блокуваннями у фонових потоках.

Ключова відмінність Deadlock від інших проблем конкурентності — його незворотність без зовнішнього втручання. Потоки не звільнять ресурси самостійно, оскільки планувальник операційної системи не може примусово відкликати блокування. Це відрізняє Deadlock від Livelock, де потоки активні, але не виконують корисної роботи.

Умови виникнення Deadlock

У 1971 році Edward G. Coffman сформулював чотири обов'язкові умови, необхідні для виникнення Deadlock. Якщо хоча б одна з них відсутня, взаємне блокування неможливе. Ці умови відомі як умови Коффмана і лежать в основі всіх алгоритмів запобігання Deadlock.

Взаємне виключення (Mutual Exclusion)

Ресурс може бути захоплений тільки одним потоком у кожний момент часу. Якщо ресурс допускає одночасне читання кількома потоками (наприклад, ReadWriteLock у режимі читання), Deadlock не виникає. Ця умова випливає із самої природи Mutex та блокувань.

Утримання та очікування (Hold and Wait)

Потік утримує вже захоплений ресурс і одночасно очікує захоплення іншого ресурсу. Якщо потік може звільнити поточний ресурс перед запитом наступного (через two-phase locking), умова Hold and Wait порушується. В Android це часто проявляється, коли потік утримує блокування БД і намагається захопити блокування SharedPreferences.

Відсутність примусового витіснення (No Preemption)

Операційна система не може примусово відібрати блокування у потоку. Ресурс звільняється тільки коли потік сам його відпускає. У деяких системах (наприклад, SQLite WAL mode) реалізовано примусове витіснення на рівні окремих операцій, що знижує ризик Deadlock.

Циклічне очікування (Circular Wait)

Існує замкнута ланцюг потоків, кожний з яких очікує ресурсу, що утримується наступним у ланцюзі. Наприклад, потік A утримує ресурс 1 і чекає ресурс 2, потік B утримує ресурс 2 і чекає ресурс 1. Це єдина умова, яку розробник може усунути архітектурно — через ієрархію блокувань. Якщо всі потоки захоплюють ресурси в строго заданому глобальному порядку, цикл фізично неможливий.

На практиці в Android-додатках Deadlock найчастіше виникає через неявне перетин блокувань різних рівнів: блокування БД (Room), блокування SharedPreferences та блокування колекції в пам'яті. Кожне з цих блокувань керується різними компонентами, і без централізованого протоколу порядку захоплення розробники ненавмисно створюють цикли.

Приклад Deadlock у коді на Kotlin

Розглянемо класичний приклад взаємного блокування — два потоки захоплюють блокування в різному порядку. Якщо перший потік блокує ресурс A і намагається захопити B, а другий — блокує B і намагається захопити A, виникає Deadlock.

kotlin
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 vs Starvation vs Livelock

Ці три проблеми конкурентності часто плутають, але їх механізми та наслідки принципово різні. Deadlock — повна зупинка, Starvation — нескінченне очікування ресурсу, Livelock — активна бездіяльність. Розуміння відмінностей критично важливе для вибору правильної стратегії усунення.

ХарактеристикаDeadlockStarvationLivelock
Стан потоківЗаблоковані (BLOCKED)Готові (RUNNABLE)Активні (RUNNABLE)
Виконання роботиНіНіТак, але марне
ПричинаЦиклічне очікуванняНесправедливе плануванняНекоректна обробка конфлікту
ВиявленняThread Dump, таймаутиМоніторинг прогресуЛічильник повторних спроб

Starvation (голодування) виникає, коли планувальник постійно відкладає виконання низькоприоритетного потоку на користь інших. На відміну від Deadlock, потік не заблокований — він готовий до виконання, але не отримує процесорного часу. В Android типовий сценарій — фоновий потік з низьким пріоритетом ніколи не виконується, якщо UI-потік і Service-потоки постійно активні.

Livelock (активне блокування) — ситуація, при якій потоки не заблоковані, але нескінченно реагують на дії один одного, не виконуючи корисної роботи. Класична аналогія — дві людини зустрічаються в коридорі і обидва намагаються поступитися дорогою, рухаючись в одну й ту ж сторону. На відміну від Deadlock, потоки в Livelock споживають CPU, розряджаючи батарею пристрою.

Як виявити Deadlock

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.

Методи запобігання взаємному блокуванню

Ієрархія блокувань (Lock Ordering)

Найнадійніший спосіб — встановити глобальний порядок захоплення блокувань у всьому додатку. Якщо всі потоки завжди спочатку захоплюють блокування з меншим номером, а потім з більшим, циклічне очікування (умова Circular Wait) неможливе. У великих проектах порядок фіксується в документації та перевіряється code review.

TryLock з таймаутом

TryLock — метод блокування, який не блокує потік нескінченно, а повертає false, якщо блокування не отримано за заданий час. В Java це реалізовано через ReentrantLock.tryLock(timeout, TimeUnit), у Kotlin Coroutines — через Mutex.withLock з таймаутом. При невдачі потік звільняє всі захоплені ресурси та повторює спробу пізніше.

Алгоритм банкіра (Banker's Algorithm)

Алгоритм банкіра — теоретичний метод запобігання Deadlock, запропонований Edsger Dijkstra. Він моделює розподіл ресурсів як банківські транзакції: система не виділяє ресурс, якщо це може призвести до небезпечного стану (deadlock). На практиці алгоритм рідко застосовується в мобільній розробці через складність попереднього знання максимальних потреб потоків, але його принципи використовуються в БД SQLite та файлових системах.

Часто задавані питання

Чи може Deadlock виникнути в однопотоковому додатку?

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

Чим Deadlock у Kotlin Coroutines відрізняється від Deadlock у потоках?

У корутинах Deadlock виникає на рівні призупинених функцій (suspend) і не блокує потік ОС, що робить його менш помітним. Mutex з kotlinx.coroutines — призупиняючий (suspending), він не блокує потік, але корутина при цьому не виконується. Для виявлення використовуйте DebugProbes з модуля kotlinx-coroutines-debug.

Що таке Deadlock у SQLite на Android?

SQLite Deadlock виникає, коли два з'єднання з БД намагаються виконати транзакції в різних порядках. SQLite виявляє такі ситуації та повертає код помилки SQLITE_BUSY або SQLITE_LOCKED. В Android рекомендується використовувати Room з єдиним екземпляром БД та транзакціями через @Transaction, що усуває міжз'єднальний Deadlock.

Як Android виявляє Deadlock?

Android Runtime має вбудований детектор Deadlock, який запускається при генерації ANR (Application Not Responding). Система аналізує Thread Dump всіх потоків додатка та позначає взаємні блокування. Результат доступний у /data/anr/traces.txt та Google Play Console у розділі ANR Reports.

Що робити, якщо Deadlock знайдено на продакшені?

Першим ділом отримайте Thread Dump всіх потоків додатка. Проаналізуйте, які блокування утримує кожний потік і які намагається захопити. Впровадьте Watchdog-таймер з автоматичним дампом при перевищенні ліміту часу. Після виправлення додайте lint-правило ThreadSafety у CI-пайплайн для запобігання рецидивам.

Підсумки

  • Deadlock — взаємне блокування, при якому потоки нескінченно очікують ресурси, захоплені один одним
  • Чотири умови Коффмана (Mutual Exclusion, Hold and Wait, No Preemption, Circular Wait) необхідні для виникнення Deadlock
  • Thread Dump — стандартний метод виявлення взаємних блокувань у JVM та Android Runtime
  • Ієрархія блокувань з єдиним глобальним порядком повністю усуває умову циклічного очікування
  • TryLock з таймаутом запобігає нескінченному очікуванню та дозволяє потоку коректно обробити недоступність ресурсу
  • Deadlock vs Starvation — при Deadlock потоки заблоковані, при Starvation готові до виконання, але не отримують CPU
  • Watchdog-таймери та статичні аналізатори (ThreadSafe, Checker Framework) — базовий захист від Deadlock у CI/CD

Ми розробимо мобільний застосунок під ключ

IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.

Обговорити проект

Читайте також