Starvation в мобільних застосунках — суть, причини виникнення та способи запобігання голодуванню потоку

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

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

Головне

  • Starvation — ситуація, при якій потік не отримує доступу до ресурсу, незважаючи на готовність до виконання
  • На відміну від Deadlock, потік при голодуванні залишається в стані RUNNABLE — він не заблокований, але не прогресує
  • Несправедливе планування (наприклад, синхронізація через synchronized) — основна причина Starvation на JVM
  • Fair Lock (ReentrantLock(true)) гарантує справедливий порядок доступу до блокування в порядку черги
  • Thread Priority в мобільній розробці рекомендується не змінювати — Android Runtime сама керує пріоритетами

Що таке Starvation?

Starvation (голодування потоку) — це проблема багатопотокового програмування, при якій потік не може отримати доступ до ресурсу, необхідного для виконання завдання, хоча ресурс не заблокований іншим потоком назавжди. Потік знаходиться в стані RUNNABLE, але планувальник або механізм синхронізації систематично відкладає його виконання на користь інших потоків.

У мобільній розробці Starvation проявляється як нерівномірне виконання завдань: одні операції виконуються миттєво, інші — з катастрофічними затримками. Наприклад, фоновий потік синхронізації даних може ніколи не отримати доступ до БД, якщо UI-потік та обробники анімацій постійно його випереджають. За даними Android Developer Blog (Performance Matters, 2023), близько 12% пропущених кадрів (jank) на Android викликані Starvation фонових завдань, від яких залежить рендеринг.

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

Причини голодування потоку

Несправедливі блокування (Non-Fair Locks)

synchronized у Java та Kotlin — класичний приклад несправедливого механізму. При високій конкуренції JVM може нескінченно віддавати блокування одним і тим же активним потокам, у той час як інші потоки постійно програють у гонці. Це не баг JVM, а особливість реалізації: несправедливі блокування забезпечують вищу пропускну здатність на шкоду рівномірності доступу. Для мобільних застосунків із 4–8 потоками ця проблема особливо актуальна.

Неправильне використання пріоритетів

Встановлення різних пріоритетів потоків може призвести до Starvation низькопріоритетних потоків. У Android Runtime планувальник CFS (Completely Fair Scheduler) Linux розподіляє процесорний час пропорційно пріоритетам, і якщо високопріоритетні потоки постійно активні, низькопріоритетні можуть ніколи не отримати CPU. Google категорично не рекомендує змінювати пріоритети потоків в Android — система сама керує ними.

Довгі критичні секції

Якщо потік утримує блокування занадто довго (виконує важкі обчислення, мережеві запити або файлові операції всередині synchronized-блоку), інші потоки, що очікують на це блокування, голодують. Це особливо небезпечно в Android, де довгі операції в UI-потоці викликають ANR, а перенесення їх у фонові потоки без оптимізації критичних секцій переносить проблему Starvation на worker-потоки.

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

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

kotlin
class SharedResource {
    private val lock = Any()

    fun criticalSection(id: String) {
        synchronized(lock) {
            println("$id отримав доступ")
            Thread.sleep(10)  // симуляція роботи
        }
    }
}

fun main() {
    val resource = SharedResource()

    // Високопріоритетний потік — активний постійно
    val highPriority = Thread {
        while (true) {
            resource.criticalSection("High")
        }
    }

    // Низькопріоритетний потік — може ніколи не отримати доступ
    val lowPriority = Thread {
        while (true) {
            resource.criticalSection("Low")
        }
    }

    highPriority.start()
    lowPriority.start()
    // "Low" може жодного разу не вивести повідомлення — Starvation!
}

У цьому прикладі highPriority потік постійно захоплює блокування і звільняє його лише на 10 мс. Через несправедливий характер synchronized планувальник JVM з високою ймовірністю буде віддавати блокування знову тому ж потоку, який щойно його звільнив, — низькопріоритетний потік голодує. Рішення — використовувати ReentrantLock(true) з прапорцем fair, який гарантує порядок у черзі очікування.

Виправлена версія з fair lock забезпечує справедливий розподіл доступу до ресурсу.

kotlin
class FairSharedResource {
    private val fairLock = ReentrantLock(true)  // fair = true

    fun criticalSection(id: String) {
        fairLock.lock()
        try {
            println("$id отримав доступ (fair)")
            Thread.sleep(10)
        } finally {
            fairLock.unlock()
        }
    }
}

Starvation vs Deadlock vs Livelock

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

ПараметрStarvationDeadlockLivelock
Стан потокуRUNNABLEBLOCKEDRUNNABLE
ПрогресНемаєНемаєНемає (хоча активний)
Споживання CPUНизькеМінімальнеВисоке (до 100%)
ПричинаНесправедливе плануванняЦиклічне очікуванняОднакова реакція на конфлікт
Головне виправленняFair Lock, зменшення критичних секційІєрархія блокуваньRetry limit, exponential backoff

Starvation вважається менш критичним, ніж Deadlock, тому що він не фатальний — при зниженні навантаження голодуючий потік все ж виконається. Однак в умовах реального використання Android-застосунків, де пам'ять і CPU обмежені, Starvation може тривати хвилинами, створюючи неприйнятний UX.

Як виявити Starvation

Thread Dump з повторними зняттями через короткі інтервали — базовий метод виявлення голодування. Якщо потік постійно знаходиться в стані RUNNABLE, але його стек викликів не змінюється протягом кількох дампів — це класична ознака Starvation. В Android Studio для цього використовується Android Profiler із записом стану потоків у часі.

Автоматизоване виявлення можливе через моніторинг часу виконання завдань. Якщо завдання з передбачуваним часом виконання (наприклад, 50 мс) виконується 5 секунд і більше — висока ймовірність Starvation. У мобільних застосунках Firebase Performance Monitoring дозволяє налаштувати custom traces для критичних секцій та отримувати сповіщення при перевищенні порогових значень.

Для діагностики Starvation через synchronized-блоки використовуйте Java Flight Recorder (JFR) (доступний на Android через OpenJDK API) або Async Profiler. Ці інструменти показують, які монітори мають найбільший час очікування та які потоки конкурують за кожен монітор. Дані з JFR інтегруються з IntelliJ IDEA Ultimate через вбудований профілювальник.

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

Fair Lock (ReentrantLock з прапорцем true)

ReentrantLock(true) гарантує, що потоки отримують блокування в порядку черги (FIFO). На відміну від synchronized, fair lock не допускає ситуації, коли потік, що щойно звільнив блокування, одразу захоплює його знову. Це повністю усуває Starvation, хоча знижує загальну пропускну здатність на 10–20% через накладні витрати на підтримання черги.

Атомарні структури без блокувань

Lock-free структури даних (ConcurrentHashMap, AtomicReference, LongAdder) виключають Starvation за визначенням, оскільки в них немає блокувань, які можуть утримуватися одним потоком. Всі операції використовують CAS-інструкції процесора, які гарантують прогрес хоча б одного потоку за скінченну кількість кроків. Для мобільної розробки надавайте перевагу ConcurrentLinkedQueue для черг завдань.

Короткі критичні секції

Мінімізація часу утримання блокування — універсальний спосіб знизити ризик Starvation. Виносьте важкі операції (мережа, дисковий ввід-вивід, складні обчислення) за межі synchronized-блоку. Використовуйте ReadWriteLock для сценаріїв, де читачі не повинні голодувати через рідкісних письменників. Бібліотека Kotlin Coroutines надає Mutex із suspending-механізмом, який не блокує потік ОС.

Умовні змінні та сигнали

Condition.await() та signal() слід використовувати з обережністю: потік, що очікує на Condition, пробуджується разом з іншими потоками (spurious wakeup), і всі вони конкурують за блокування. Якщо один потік після await негайно повертається в очікування, а інші встигають захопити блокування — голодуючий потік може пробуджуватися та засинати нескінченно. Завжди перевіряйте умову в циклі while, а не в if, щоб гарантувати повторну перевірку.

Часті запитання

У чому різниця між Starvation та інверсією пріоритету (Priority Inversion)?

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

Чи може Starvation виникнути в однопотоковому застосунку?

Ні, Starvation — проблема багатопотоковості. В однопотоковому коді немає конкуренції за ресурси та планування потоків. Однак Starvation може виникнути в асинхронному однопотоковому коді (наприклад, JavaScript event loop), якщо одна мікрозадача нескінченно відкладає виконання інших через setTimeout з нульовою затримкою.

Як Java Memory Model пов'язана зі Starvation?

JMM (Java Memory Model) визначає правила видимості змін між потоками, але не гарантує справедливого планування. synchronized відповідно до JMM забезпечує sequential consistency — базову коректність — але не запобігає Starvation. Для справедливості потрібні додаткові механізми, що не входять до специфікації JMM.

Що таке Starvation в Android UI-потоці?

UI-потік (Main Thread) не може голодувати в класичному сенсі, оскільки він має найвищий пріоритет. Однак Starvation виникає, коли UI-потік чекає результату від голодуючого фонового потоку. Типовий сценарій: AsyncTask або корутина завантажують дані, але не можуть отримати доступ до БД через конкуренцію з іншими потоками, і UI зависає в очікуванні.

Як запобігти Starvation в Kotlin Coroutines?

У корутинах для запобігання Starvation використовуйте limitedParallelism на Dispatchers.IO, щоб уникнути вичерпання потоків. Для синхронізації застосовуйте Mutex із kotlinx.coroutines.sync — він призупиняє корутину, а не блокує потік, що знижує ризик голодування. Уникайте runBlocking в корутинах, оскільки він може захопити потік пулу та викликати Starvation інших корутин.

Підсумки

  • Starvation — ситуація, при якій потік готовий до виконання, але не отримує ресурс через несправедливе планування
  • На відміну від Deadlock, при голодуванні потік у стані RUNNABLE і може виконатися при зниженні навантаження
  • Несправедливі блокування (synchronized) та неправильне використання пріоритетів — основні причини Starvation
  • Fair Lock (ReentrantLock з прапорцем true) гарантує порядок доступу FIFO та повністю усуває голодування
  • Lock-free структури (ConcurrentHashMap, AtomicReference) виключають Starvation на рівні архітектури
  • Thread Dump з повторними зняттями та Java Flight Recorder — ефективні методи діагностики Starvation
  • Короткі критичні секції та ReadWriteLock знижують ймовірність голодування у високонавантажених системах

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

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

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

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