Starvation(스레드 기아)은 스레드가 실행 준비가 되었음에도 불구하고 작업을 계속하는 데 필요한 리소스에 액세스할 수 없는 상황입니다. Baeldung(Java Thread Starvation, 2024)에 따르면, 기아는 불공정한 스케줄링으로 인해 발생하며, 낮은 우선순위의 스레드가 높은 우선순위의 스레드에 밀려 지속적으로 연기됩니다. Deadlock과 달리 Starvation은 스레드를 차단하지 않습니다 — RUNNABLE 상태로 유지되지만 CPU 시간을 얻지 못합니다.
핵심 요점
Starvation(스레드 기아)은 멀티스레딩 문제로, 스레드가 작업을 완료하는 데 필요한 리소스에 액세스할 수 없지만 리소스가 다른 스레드에 의해 영구적으로 잠겨 있지는 않은 상태입니다. 스레드는 RUNNABLE 상태에 있지만, 스케줄러 또는 동기화 메커니즘이 다른 스레드를 우선하여 체계적으로 실행을 연기합니다.
모바일 개발에서 Starvation은 불균등한 작업 실행으로 나타납니다. 일부 작업은 즉시 실행되지만 다른 작업은 치명적인 지연을 겪습니다. 예를 들어, 백그라운드 데이터 동기화 스레드는 UI 스레드와 애니메이션 핸들러가 지속적으로 앞서는 경우 데이터베이스에 액세스하지 못할 수 있습니다. Android Developer Blog(Performance Matters, 2023)에 따르면, Android에서 놓친 프레임(jank)의 약 12%는 렌더링이 의존하는 백그라운드 작업의 Starvation으로 인해 발생합니다.
Starvation과 Deadlock의 주요 차이점은 가역성입니다. 시스템 부하가 감소하거나 우선순위가 재분배되면 기아 상태의 스레드가 리소스를 획득하고 작업을 완료할 수 있습니다. 그러나 지속적인 높은 부하에서는 Starvation이 무기한 지속되어 애플리케이션이 정지된 듯한 인상을 줄 수 있습니다.
Java 및 Kotlin의 synchronized는 불공정 메커니즘의 전형적인 예입니다. 높은 경합 상태에서 JVM은 동일한 활성 스레드에 지속적으로 잠금을 부여하고 다른 스레드는 계속해서 경쟁에서 패배합니다. 이는 JVM의 버그가 아니라 설계 트레이드오프입니다. 불공정한 잠금은 액세스 공정성을 희생하여 더 높은 처리량을 제공합니다. 4~8개의 스레드를 가진 모바일 애플리케이션의 경우 이 문제가 특히 중요합니다.
다른 스레드 우선순위를 설정하면 낮은 우선순위 스레드의 Starvation이 발생할 수 있습니다. Android Runtime에서 Linux CFS(Completely Fair Scheduler) 스케줄러는 우선순위에 비례하여 CPU 시간을 분배하며, 높은 우선순위 스레드가 지속적으로 활성화되면 낮은 우선순위 스레드는 CPU 시간을 얻지 못할 수 있습니다. Google은 Android에서 스레드 우선순위를 변경하는 것을 강력히 권장하지 않습니다 — 시스템이 자체적으로 관리합니다.
스레드가 너무 오랫동안 잠금을 유지하는 경우(synchronized 블록 내에서 무거운 계산, 네트워크 요청 또는 파일 작업 수행), 해당 잠금을 기다리는 다른 스레드가 기아 상태에 빠집니다. 이는 Android에서 특히 위험합니다. UI 스레드에서 긴 작업은 ANR을 유발하고, 임계 영역을 최적화하지 않고 백그라운드 스레드로 이동하면 Starvation 문제가 작업자 스레드로 옮겨갈 뿐입니다.
불공정한 스케줄링으로 인해 하나의 스레드가 너무 자주 잠금을 획득하는 예를 생각해 보겠습니다. 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 스레드는 지속적으로 잠금을 획득하고 10ms 동안만 해제합니다. synchronized의 불공정한 특성으로 인해 JVM 스케줄러는 방금 해제한 동일한 스레드에 다시 잠금을 부여할 가능성이 높으며, 낮은 우선순위 스레드는 기아 상태가 됩니다. 해결책은 FIFO 대기 순서를 보장하는 fair 플래그가 있는 ReentrantLock(true)을 사용하는 것입니다.
공정한 잠금으로 수정된 버전은 리소스 액세스의 공평한 분배를 보장합니다.
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, 임계 영역 단축 | 잠금 계층 구조 | 재시도 제한, exponential backoff |
Starvation은 Deadlock보다 덜 중요하다고 간주됩니다. 치명적이지 않기 때문입니다 — 부하가 감소하면 기아 상태의 스레드가 결국 실행됩니다. 그러나 메모리와 CPU가 제한된 실제 Android 사용에서는 Starvation이 몇 분간 지속되어 허용할 수 없는 UX를 만들 수 있습니다.
Thread Dump를 짧은 간격으로 반복적으로 수집하는 것이 기아를 감지하는 기본 방법입니다. 스레드가 지속적으로 RUNNABLE 상태에 있지만 여러 덤프에 걸쳐 호출 스택이 변경되지 않는 경우 — 이는 Starvation의 전형적인 징후입니다. Android Studio에서는 시간 경과에 따른 스레드 상태 기록을 위해 Android Profiler를 사용합니다.
작업의 실행 시간 모니터링을 통한 자동 감지가 가능합니다. 예측 가능한 실행 시간(예: 50ms)의 작업이 5초 이상 걸리는 경우 Starvation의 높은 가능성이 있습니다. 모바일 애플리케이션에서 Firebase Performance Monitoring을 사용하면 임계 영역에 대한 사용자 지정 추적을 설정하고 임계값 초과 시 알림을 받을 수 있습니다.
synchronized 블록으로 인한 Starvation을 진단하려면 Java Flight Recorder(JFR)(OpenJDK API를 통해 Android에서 사용 가능) 또는 Async Profiler를 사용합니다. 이러한 도구는 어떤 모니터의 대기 시간이 가장 긴지, 어떤 스레드가 각 모니터를 경합하는지 보여줍니다. JFR 데이터는 내장 프로파일러를 통해 IntelliJ IDEA Ultimate와 통합됩니다.
ReentrantLock(true)은 스레드가 FIFO 순서로 잠금을 획득하도록 보장합니다. synchronized와 달리 공정한 잠금은 방금 잠금을 해제한 스레드가 즉시 다시 획득하는 것을 허용하지 않습니다. 이는 Starvation을 완전히 제거하지만 큐 유지 오버헤드로 인해 전체 처리량이 10~20% 감소합니다.
잠금 없는 데이터 구조(ConcurrentHashMap, AtomicReference, LongAdder)는 정의상 Starvation을 제거합니다. 하나의 스레드가 보유할 수 있는 잠금이 없기 때문입니다. 모든 작업은 CPU CAS 명령어를 사용하여 유한한 단계 내에서 적어도 하나의 스레드 진행을 보장합니다. 모바일 개발에서는 작업 큐에 ConcurrentLinkedQueue를 선호합니다.
잠금 유지 시간 최소화는 Starvation 위험을 줄이는 보편적인 방법입니다. 무거운 작업(네트워크, 디스크 I/O, 복잡한 계산)을 synchronized 블록 밖으로 이동합니다. 읽기 작업이 드문 쓰기 작업으로 인해 기아 상태가 되지 않아야 하는 시나리오에서는 ReadWriteLock을 사용합니다. Kotlin Coroutines 라이브러리는 OS 스레드를 차단하지 않는 일시 중단 메커니즘을 가진 Mutex를 제공합니다.
Condition.await() 및 signal()은 주의해서 사용해야 합니다. Condition에서 대기 중인 스레드는 다른 스레드와 함께 깨어나고(spurious wakeup), 모두 잠금을 경쟁합니다. await 후 즉시 대기로 돌아가는 스레드가 있는 반면 다른 스레드가 잠금을 획득하는 경우, 기아 상태의 스레드가 무기한 깨어났다 잠들 수 있습니다. 재확인을 보장하기 위해 항상 if 대신 while 루프에서 조건을 확인하세요.
자주 묻는 질문
Priority Inversion(우선순위 역전)은 낮은 우선순위의 스레드가 높은 우선순위의 스레드에 필요한 잠금을 보유하고 있는 상황입니다. 결과적으로 높은 우선순위의 스레드가 낮은 우선순위의 스레드를 기다리며 우선순위가 역전됩니다. Starvation은 더 광범위한 문제로, 스레드가 우선순위와 관계없이 불공정한 스케줄링이나 긴 임계 영역으로 인해 리소스를 획득할 수 없습니다.
아니요, Starvation은 멀티스레딩 문제입니다. 단일 스레드 코드에는 리소스 경합이나 스레드 스케줄링이 없습니다. 그러나 비동기 단일 스레드 코드(예: JavaScript 이벤트 루프)에서는 하나의 마이크로태스크가 setTimeout(지연 0)을 통해 다른 작업의 실행을 무기한 연기하는 경우 Starvation이 발생할 수 있습니다.
JMM(Java Memory Model)은 스레드 간 변경 사항의 가시성 규칙을 정의하지만 공정한 스케줄링을 보장하지 않습니다. synchronized는 JMM에 따라 순차적 일관성(기본적 정확성)을 보장하지만 Starvation을 방지하지는 않습니다. 공정성을 위해서는 JMM에 명시되지 않은 추가 메커니즘이 필요합니다.
UI 스레드(Main Thread)는 가장 높은 우선순위를 가지므로 고전적인 의미에서 기아 상태가 될 수 없습니다. 그러나 UI 스레드가 기아 상태의 백그라운드 스레드의 결과를 기다리는 경우 Starvation이 발생합니다. 일반적인 시나리오: AsyncTask 또는 코루틴이 데이터를 로드하지만 다른 스레드와의 경합으로 인해 데이터베이스에 액세스할 수 없고 UI가 대기 중에 정지됩니다.
코루틴에서 Starvation을 방지하려면 Dispatchers.IO에서 limitedParallelism을 사용하여 스레드 고갈을 방지합니다. 동기화를 위해 kotlinx.coroutines.sync의 Mutex를 사용합니다 — 스레드를 차단하는 대신 코루틴을 일시 중단하여 기아 위험을 줄입니다. 코루틴에서 runBlocking을 피하세요. 풀 스레드를 점유하여 다른 코루틴의 Starvation을 유발할 수 있습니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.