Starvation (گرسنگی نخ) — وضعیتی است که در آن یک نخ به منبع لازم برای ادامه کار دسترسی پیدا نمیکند، اگرچه خود آماده اجراست. به گفته Baeldung (Java Thread Starvation, 2024)، گرسنگی به دلیل برنامهریزی ناعادلانه رخ میدهد، زمانی که نخهای کماولویت دائماً به نفع نخهای با اولویت بالاتر به تعویق میافتند. بر خلاف Deadlock، Starvation نخ را مسدود نمیکند — در حالت RUNNABLE باقی میماند، اما هرگز زمان پردازنده دریافت نمیکند.
نکات اصلی
Starvation (گرسنگی نخ) — مشکلی در برنامهنویسی چندنخی است که در آن یک نخ نمیتواند به منبع لازم برای انجام وظیفه دسترسی پیدا کند، اگرچه منبع برای همیشه توسط نخ دیگری مسدود نشده است. نخ در حالت RUNNABLE قرار دارد، اما برنامهریز یا مکانیزم همگامسازی به طور سیستماتیک اجرای آن را به نفع نخهای دیگر به تعویق میاندازد.
در توسعه موبایل، Starvation به صورت اجرای نابرابر وظایف ظاهر میشود: برخی عملیاتها فوراً اجرا میشوند، برخی دیگر — با تأخیرهای فاجعهبار. به عنوان مثال، نخ پسزمینه همگامسازی داده ممکن است هرگز به پایگاه داده دسترسی پیدا نکند، اگر نخ UI و پردازشگرهای انیمیشن دائماً از آن جلو بزنند. طبق Android Developer Blog (Performance Matters, 2023)، حدود 12٪ موارد فریمهای از دست رفته (jank) در اندروید ناشی از Starvation وظایف پسزمینهای است که رندرینگ به آنها وابسته است.
تفاوت کلیدی Starvation با Deadlock — قابلیت بازگشت. اگر بار سیستم کاهش یابد یا اولویتها دوباره توزیع شوند، نخ گرسنه ممکن است منبع را دریافت کرده و کار را تکمیل کند. اما در شرایط بار بالا و مداوم، Starvation میتواند به طور نامحدود ادامه یابد و احساس برنامه قفل شده را ایجاد کند.
synchronized در جاوا و کاتلین — مثال کلاسیک یک مکانیزم ناعادلانه. در رقابت بالا، JVM میتواند به طور نامحدود قفل را به همان نخهای فعال بدهد، در حالی که نخهای دیگر دائماً در مسابقه میبازند. این اشکال JVM نیست، بلکه ویژگی پیادهسازی است: قفلهای ناعادلانه توان عملیاتی بالاتری را به قیمت یکنواختی دسترسی فراهم میکنند. برای برنامههای موبایل با ۴-۸ نخ، این مشکل به ویژه مهم است.
تنظیم اولویتهای مختلف نخها میتواند منجر به Starvation نخهای کماولویت شود. در Android Runtime، برنامهریز CFS (Completely Fair Scheduler) لینوکس زمان پردازنده را متناسب با اولویتها توزیع میکند و اگر نخهای با اولویت بالا دائماً فعال باشند، نخهای کماولویت ممکن است هرگز CPU دریافت نکنند. گوگل قاطعانه تغییر اولویتهای نخ را در اندروید توصیه نمیکند — سیستم خود آنها را مدیریت میکند.
اگر یک نخ قفل را خیلی طولانی نگه دارد (محاسبات سنگین، درخواستهای شبکه یا عملیات فایل در داخل بلوک synchronized انجام دهد)، نخهای دیگر که منتظر این قفل هستند، گرسنه میمانند. این به ویژه در اندروید خطرناک است، جایی که عملیات طولانی در نخ 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 دائماً قفل را تصاحب میکند و آن را فقط برای ۱۰ میلیثانیه آزاد میکند. به دلیل ماهیت ناعادلانه 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 | کم | حداقل | زیاد (تا ۱۰۰٪) |
| دلیل | برنامهریزی ناعادلانه | انتظار چرخهای | واکنش یکسان به تعارض |
| راه حل اصلی | Fair Lock، کاهش بخشهای بحرانی | سلسلهمراتب قفلها | محدودیت تلاش مجدد، backoff نمایی |
Starvation کمتر بحرانی از Deadlock در نظر گرفته میشود، زیرا کشنده نیست — با کاهش بار، نخ گرسنه در نهایت اجرا خواهد شد. اما در شرایط استفاده واقعی از برنامههای اندروید، جایی که حافظه و CPU محدود هستند، Starvation میتواند دقایقی طول بکشد و UX غیرقابل قبولی ایجاد کند.
Thread Dump با برداشتهای مکرر در فواصل کوتاه — روش پایه تشخیص گرسنگی. اگر یک نخ به طور مداوم در حالت RUNNABLE باشد، اما پشته فراخوانی آن در طول چندین dump تغییر نکند — این نشانه کلاسیک Starvation است. در Android Studio از Android Profiler با ضبط وضعیت نخها در طول زمان برای این منظور استفاده میشود.
تشخیص خودکار از طریق نظارت بر زمان اجرای وظایف امکانپذیر است. اگر یک وظیفه با زمان اجرای قابل پیشبینی (مثلاً ۵۰ میلیثانیه) ۵ ثانیه یا بیشتر اجرا شود — احتمال Starvation بالا است. در برنامههای موبایل، Firebase Performance Monitoring امکان پیکربندی ردیابیهای سفارشی (custom traces) برای بخشهای بحرانی و دریافت اعلانها در هنگام فراتر رفتن از مقادیر آستانه را فراهم میکند.
برای تشخیص Starvation ناشی از بلوکهای synchronized از Java Flight Recorder (JFR) (موجود در اندروید از طریق OpenJDK API) یا Async Profiler استفاده کنید. این ابزارها نشان میدهند کدام مانیتورها بیشترین زمان انتظار را دارند و کدام نخها برای هر مانیتور رقابت میکنند. دادههای JFR از طریق پروفایلر داخلی با IntelliJ IDEA Ultimate یکپارچه میشوند.
ReentrantLock(true) تضمین میکند که نخها قفل را به ترتیب صف (FIFO) دریافت میکنند. بر خلاف synchronized، fair lock اجازه نمیدهد نخی که به تازگی قفل را آزاد کرده بلافاصله دوباره آن را تصاحب کند. این کاملاً Starvation را از بین میبرد، اگرچه توان عملیاتی کلی را به دلیل سربار نگهداری صف ۱۰-۲۰٪ کاهش میدهد.
ساختارهای داده 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) رخ دهد، اگر یک میکرووظیفه به طور نامحدود اجرای دیگران را از طریق 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 در کوروتینها خودداری کنید، زیرا میتواند نخ pool را تصاحب کرده و باعث Starvation سایر کوروتینها شود.
خلاصه
ما یک اپلیکیشن موبایل به صورت کلید در دست توسعه خواهیم داد
IT Sectr از سال 2017 برنامههای iOS و Android را برای استارتاپها و کسبوکارها ایجاد میکند. ما به شما مشاوره میدهیم و بهترین راهحل را پیشنهاد خواهیم کرد.
همچنین بخوانید