Starvation (ภาวะขาดแคลนเธรด) คือสถานการณ์ที่เธรดไม่สามารถเข้าถึงทรัพยากรที่จำเป็นสำหรับการทำงานต่อได้ แม้ว่าจะพร้อมทำงานก็ตาม ตามข้อมูลจาก Baeldung (Java Thread Starvation, 2024) ภาวะขาดแคลนเกิดจากการจัดลำดับที่ไม่เป็นธรรม ซึ่งเธรดที่มีลำดับความสำคัญต่ำถูกเลื่อนออกไปอย่างต่อเนื่องเพื่อให้เธรดที่มีลำดับความสำคัญสูงกว่า ต่างจาก Deadlock ตรงที่ Starvation ไม่ได้บล็อกเธรด — มันยังคงอยู่ในสถานะ RUNNABLE แต่ไม่ได้รับเวลา CPU เลย
ประเด็นสำคัญ
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 ตามสัดส่วนของลำดับความสำคัญ และหากเธรดที่มีลำดับความสำคัญสูงทำงานตลอดเวลา เธรดที่มีลำดับความสำคัญต่ำอาจไม่ได้รับเวลา 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 ล็อคตลอดเวลาและปล่อยเพียง 10 ms เนื่องจากลักษณะที่ไม่เป็นธรรมของ synchronized ตัวจัดลำดับ JVM มีแนวโน้มสูงที่จะให้ล็อคอีกครั้งกับเธรดเดียวกับที่เพิ่งปล่อย — เธรดที่มีลำดับความสำคัญต่ำขาดแคลน วิธีแก้คือใช้ ReentrantLock(true) พร้อมแฟล็ก fair ซึ่งรับประกันลำดับการรอ FIFO
เวอร์ชันที่แก้ไขด้วยล็อคที่เป็นธรรมช่วยให้การเข้าถึงทรัพยากรเป็นธรรม
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 เพราะไม่ถึงตาย — ภายใต้โหลดที่ลดลง เธรดที่ขาดแคลนจะทำงานในที่สุด อย่างไรก็ตาม ในการใช้งาน Android จริง ซึ่งหน่วยความจำและ CPU มีจำกัด Starvation อาจกินเวลานานนาที สร้างประสบการณ์ผู้ใช้ที่ยอมรับไม่ได้
Thread Dump ที่ถ่ายซ้ำๆ ในช่วงเวลาสั้นๆ — วิธีการพื้นฐานในการตรวจจับภาวะขาดแคลน หากเธรดอยู่ในสถานะ RUNNABLE อย่างต่อเนื่องแต่สแต็กการเรียกไม่เปลี่ยนแปลงในหลายๆ ดัมพ์ — นี่คือสัญญาณคลาสสิกของ Starvation ใน Android Studio ใช้ Android Profiler ด้วยการบันทึกสถานะเธรดตามเวลา
การตรวจจับอัตโนมัติเป็นไปได้ผ่าน การตรวจสอบเวลาในการทำงาน ของงาน หากงานที่มีเวลาในการทำงานที่คาดเดาได้ (เช่น 50 ms) ใช้เวลา 5 วินาทีขึ้นไป — มีความเป็นไปได้สูงที่จะเกิด Starvation ในแอปพลิเคชันมือถือ Firebase Performance Monitoring ช่วยให้คุณตั้งค่า trace ที่กำหนดเองสำหรับส่วนวิกฤตและรับการแจ้งเตือนเมื่อเกินเกณฑ์
ในการวินิจฉัย Starvation ที่เกิดจากบล็อก synchronized ให้ใช้ Java Flight Recorder (JFR) (ใช้ได้บน Android ผ่าน OpenJDK API) หรือ Async Profiler เครื่องมือเหล่านี้แสดงว่ามอนิเตอร์ใดมีเวลารอนานที่สุดและเธรดใดแข่งขันกันเพื่อแต่ละมอนิเตอร์ ข้อมูล JFR รวมเข้ากับ IntelliJ IDEA Ultimate ผ่านโปรไฟล์เลอร์ในตัว
ReentrantLock(true) รับประกันว่าเธรดได้รับล็อคตามลำดับ FIFO ต่างจาก synchronized ตรงที่ล็อคที่เป็นธรรมไม่อนุญาตให้เธรดที่เพิ่งปล่อยล็อคล็อคซ้ำทันที สิ่งนี้กำจัด Starvation อย่างสมบูรณ์ แม้ว่าจะลดปริมาณงานโดยรวม 10–20% เนื่องจากค่าใช้จ่ายในการดูแลคิว
โครงสร้างข้อมูลแบบไม่มีล็อค (ConcurrentHashMap, AtomicReference, LongAdder) กำจัด Starvation โดยนิยาม เพราะไม่มีล็อคที่เธรดเดียวสามารถถือครองได้ การดำเนินการทั้งหมดใช้คำสั่ง CAS ของ CPU ซึ่งรับประกันว่าเธรดอย่างน้อยหนึ่งเธรดจะคืบหน้าในจำนวนขั้นตอนที่จำกัด สำหรับการพัฒนาแอปมือถือ ให้เลือกใช้ ConcurrentLinkedQueue สำหรับคิวงาน
การลดเวลาในการถือล็อค เป็นวิธีสากลในการลดความเสี่ยงของ Starvation ย้ายการดำเนินการหนัก (เครือข่าย I/O ดิสก์ การคำนวณซับซ้อน) ออกจากบล็อก synchronized ใช้ ReadWriteLock สำหรับสถานการณ์ที่ผู้อ่านไม่ควรขาดแคลนเนื่องจากผู้เขียนที่หายาก ไลบรารี Kotlin Coroutines มี Mutex พร้อมกลไกการระงับที่ไม่บล็อกเธรด OS
Condition.await() และ signal() ควรใช้ด้วยความระมัดระวัง: เธรดที่รอใน Condition ตื่นขึ้นพร้อมกับเธรดอื่น (spurious wakeup) และทั้งหมดแข่งขันกันเพื่อล็อค หากเธรดกลับไปรอทันทีหลังจาก await ในขณะที่เธรดอื่นได้รับล็อค เธรดที่ขาดแคลนอาจตื่นและหลับไปอย่างไม่สิ้นสุด ตรวจสอบเงื่อนไขในลูป while เสมอ แทนที่จะใช้ if เพื่อให้แน่ใจว่ามีการตรวจสอบซ้ำ
คำถามที่พบบ่อย
Priority Inversion (การกลับลำดับความสำคัญ) คือสถานการณ์ที่เธรดที่มีลำดับความสำคัญต่ำถือล็อคที่เธรดที่มีลำดับความสำคัญสูงต้องการ ผลลัพธ์คือเธรดที่มีลำดับความสำคัญสูงรอเธรดที่มีลำดับความสำคัญต่ำ — ลำดับความสำคัญกลับกัน Starvation เป็นปัญหาที่กว้างกว่า: เธรดไม่สามารถรับทรัพยากรได้โดยไม่ขึ้นกับลำดับความสำคัญ เนื่องจากการจัดลำดับที่ไม่เป็นธรรมหรือส่วนวิกฤตที่ยาวนาน
ไม่ Starvation เป็นปัญหาของหลายเธรด โค้ดแบบเธรดเดียวไม่มีการแข่งขันทรัพยากรหรือการจัดลำดับเธรด อย่างไรก็ตาม Starvation สามารถเกิดขึ้นในโค้ดอะซิงโครนัสแบบเธรดเดียว (เช่น อีเวนต์ลูปของ JavaScript) หากไมโครงานหนึ่งเลื่อนการทำงานของงานอื่นอย่างไม่มีกำหนดผ่าน 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 ในโครูทีน เพราะมันอาจยึดเธรดจากพูลและทำให้โครูทีนอื่นขาดแคลน
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ
อ่านเพิ่มเติม