Starvation (голодування потоку) — це ситуація, в якій потік не отримує доступу до ресурсу, необхідного для продовження роботи, хоча сам готовий до виконання. Згідно з Baeldung (Java Thread Starvation, 2024), голодування виникає через несправедливе планування, коли низькопріоритетні потоки постійно відкладаються на користь більш пріоритетних. На відміну від Deadlock, Starvation не блокує потік — він залишається в стані RUNNABLE, але ніколи не отримує процесорного часу.
Головне
Starvation (голодування потоку) — це проблема багатопотокового програмування, при якій потік не може отримати доступ до ресурсу, необхідного для виконання завдання, хоча ресурс не заблокований іншим потоком назавжди. Потік знаходиться в стані RUNNABLE, але планувальник або механізм синхронізації систематично відкладає його виконання на користь інших потоків.
У мобільній розробці Starvation проявляється як нерівномірне виконання завдань: одні операції виконуються миттєво, інші — з катастрофічними затримками. Наприклад, фоновий потік синхронізації даних може ніколи не отримати доступ до БД, якщо UI-потік та обробники анімацій постійно його випереджають. За даними Android Developer Blog (Performance Matters, 2023), близько 12% пропущених кадрів (jank) на Android викликані Starvation фонових завдань, від яких залежить рендеринг.
Ключова відмінність Starvation від Deadlock — оборотність. Якщо навантаження на систему знижується або пріоритети перерозподіляються, голодуючий потік може отримати ресурс і завершити роботу. Однак в умовах постійного високого навантаження Starvation може тривати невизначено довго, створюючи враження застосунку, що завис.
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 демонструється через нескінченний цикл високопріоритетного потоку, який не дає низькопріоритетному отримати доступ до спільного ресурсу.
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 забезпечує справедливий розподіл доступу до ресурсу.
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, Deadlock і Livelock — часто об'єднують, але їхні механізми та способи усунення різні. Starvation — потік готовий, але не отримує ресурс. Deadlock — потоки заблоковані циклічним очікуванням. Livelock — потоки активні, але не прогресують.
| Параметр | Starvation | Deadlock | Livelock |
|---|---|---|---|
| Стан потоку | RUNNABLE | BLOCKED | RUNNABLE |
| Прогрес | Немає | Немає | Немає (хоча активний) |
| Споживання CPU | Низьке | Мінімальне | Високе (до 100%) |
| Причина | Несправедливе планування | Циклічне очікування | Однакова реакція на конфлікт |
| Головне виправлення | Fair Lock, зменшення критичних секцій | Ієрархія блокувань | Retry limit, exponential backoff |
Starvation вважається менш критичним, ніж Deadlock, тому що він не фатальний — при зниженні навантаження голодуючий потік все ж виконається. Однак в умовах реального використання Android-застосунків, де пам'ять і CPU обмежені, Starvation може тривати хвилинами, створюючи неприйнятний UX.
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 через вбудований профілювальник.
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, щоб гарантувати повторну перевірку.
Часті запитання
Priority Inversion — це ситуація, коли низькопріоритетний потік утримує блокування, необхідне високопріоритетному. В результаті високопріоритетний потік чекає на низькопріоритетний — пріоритети обертаються. Starvation — ширша проблема: потік не отримує ресурс незалежно від пріоритету, через несправедливе планування або довгі критичні секції.
Ні, Starvation — проблема багатопотоковості. В однопотоковому коді немає конкуренції за ресурси та планування потоків. Однак Starvation може виникнути в асинхронному однопотоковому коді (наприклад, JavaScript event loop), якщо одна мікрозадача нескінченно відкладає виконання інших через setTimeout з нульовою затримкою.
JMM (Java Memory Model) визначає правила видимості змін між потоками, але не гарантує справедливого планування. synchronized відповідно до JMM забезпечує sequential consistency — базову коректність — але не запобігає Starvation. Для справедливості потрібні додаткові механізми, що не входять до специфікації JMM.
UI-потік (Main Thread) не може голодувати в класичному сенсі, оскільки він має найвищий пріоритет. Однак Starvation виникає, коли UI-потік чекає результату від голодуючого фонового потоку. Типовий сценарій: AsyncTask або корутина завантажують дані, але не можуть отримати доступ до БД через конкуренцію з іншими потоками, і UI зависає в очікуванні.
У корутинах для запобігання Starvation використовуйте limitedParallelism на Dispatchers.IO, щоб уникнути вичерпання потоків. Для синхронізації застосовуйте Mutex із kotlinx.coroutines.sync — він призупиняє корутину, а не блокує потік, що знижує ризик голодування. Уникайте runBlocking в корутинах, оскільки він може захопити потік пулу та викликати Starvation інших корутин.
Підсумки
Ми розробимо мобільний застосунок під ключ
IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також