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 с повторными снятиями через короткие интервалы — базовый метод обнаружения голодания. Если поток consistently находится в состоянии RUNNABLE, но его стек вызовов не меняется на протяжении нескольких дампов — это классический признак Starvation. В Android Studio для этого используется Android Profiler с записью состояния потоков во времени.
Автоматизированное обнаружение возможно через мониторинг времени выполнения задач. Если задача с predictable execution time (например, 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-инструкции процессора, которые гарантируют прогресс хотя бы одного потока за конечное число шагов. Для мобильной разработки prefer 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 года. Мы проконсультируем вас и предложим наилучшее решение.
Читайте также