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 στα νήματα εργασίας.
Ας εξετάσουμε ένα παράδειγμα όπου ένα νήμα καταλαμβάνει το κλείδωμα πολύ συχνά λόγω άδικου προγραμματισμού. Το 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, η οποία εγγυάται τη σειρά στην ουρά αναμονής.
Η διορθωμένη έκδοση με 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, μείωση κρίσιμων ενοτήτων | Ιεραρχία κλειδωμάτων | Όριο επαναλήψεων, εκθετική υπαναχώρηση |
Το Starvation θεωρείται λιγότερο κρίσιμο από το Deadlock, επειδή δεν είναι μοιραίο — με τη μείωση του φορτίου, το νήμα που πεινά θα εκτελεστεί τελικά. Ωστόσο, υπό συνθήκες πραγματικής χρήσης εφαρμογών Android, όπου η μνήμη και η CPU είναι περιορισμένες, το Starvation μπορεί να διαρκέσει λεπτά, δημιουργώντας μη αποδεκτή εμπειρία χρήστη.
Thread Dump με επαναλαμβανόμενες λήψεις σε σύντομα διαστήματα — η βασική μέθοδος ανίχνευσης της πείνας. Εάν ένα νήμα βρίσκεται σταθερά σε κατάσταση RUNNABLE, αλλά η στοίβα κλήσεών του δεν αλλάζει σε πολλές λήψεις — αυτό είναι κλασικό σημάδι Starvation. Στο Android Studio, για αυτό χρησιμοποιείται το Android Profiler με καταγραφή της κατάστασης νημάτων στο χρόνο.
Αυτοματοποιημένη ανίχνευση είναι δυνατή μέσω παρακολούθησης του χρόνου εκτέλεσης εργασιών. Εάν μια εργασία με προβλέψιμο χρόνο εκτέλεσης (π.χ. 50 ms) εκτελείται για 5 δευτερόλεπτα ή περισσότερο — υπάρχει μεγάλη πιθανότητα Starvation. Σε εφαρμογές κινητών, το Firebase Performance Monitoring επιτρέπει τη διαμόρφωση προσαρμοσμένων ιχνών (custom traces) για κρίσιμες ενότητες και τη λήψη ειδοποιήσεων όταν ξεπερνιούνται οι τιμές κατωφλίου.
Για τη διάγνωση του Starvation λόγω μπλοκ synchronized, χρησιμοποιήστε το Java Flight Recorder (JFR) (διαθέσιμο στο Android μέσω OpenJDK API) ή το Async Profiler. Αυτά τα εργαλεία δείχνουν ποια monitors έχουν τον μεγαλύτερο χρόνο αναμονής και ποια νήματα ανταγωνίζονται για κάθε monitor. Τα δεδομένα 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 με μηχανισμό αναστολής που δεν μπλοκάρει το νήμα του λειτουργικού συστήματος.
Condition.await() και signal() πρέπει να χρησιμοποιούνται με προσοχή: το νήμα που περιμένει στο Condition ξυπνά μαζί με άλλα νήματα (spurious wakeup) και όλα ανταγωνίζονται για το κλείδωμα. Εάν ένα νήμα μετά το await επιστρέφει αμέσως στην αναμονή, ενώ άλλα καταφέρνουν να καταλάβουν το κλείδωμα — το νήμα που πεινά μπορεί να ξυπνά και να κοιμάται ατέρμονα. Ελέγχετε πάντα τη συνθήκη σε βρόχο while, όχι σε if, για να εγγυηθείτε τον επανέλεγχο.
Συχνές ερωτήσεις
Priority Inversion — είναι η κατάσταση κατά την οποία ένα νήμα χαμηλής προτεραιότητας κρατά ένα κλείδωμα που είναι απαραίτητο για ένα νήμα υψηλής προτεραιότητας. Ως αποτέλεσμα, το νήμα υψηλής προτεραιότητας περιμένει το νήμα χαμηλής προτεραιότητας — οι προτεραιότητες αντιστρέφονται. Το Starvation είναι ένα ευρύτερο πρόβλημα: το νήμα δεν λαμβάνει πόρο ανεξάρτητα από την προτεραιότητα, λόγω άδικου προγραμματισμού ή μεγάλων κρίσιμων ενοτήτων.
Όχι, το Starvation — είναι πρόβλημα πολυνηματικότητας. Σε μονονηματικό κώδικα δεν υπάρχει ανταγωνισμός για πόρους και προγραμματισμός νημάτων. Ωστόσο, το Starvation μπορεί να εμφανιστεί σε ασύγχρονο μονονηματικό κώδικα (π.χ. βρόχος συμβάντων JavaScript), εάν μια μικρο-εργασία αναβάλλει επ' αόριστον την εκτέλεση άλλων μέσω setTimeout με μηδενική καθυστέρηση.
JMM (Java Memory Model) ορίζει τους κανόνες ορατότητας αλλαγών μεταξύ νημάτων, αλλά δεν εγγυάται δίκαιο προγραμματισμό. Το synchronized σύμφωνα με το JMM παρέχει σειριακή συνέπεια — βασική ορθότητα — αλλά δεν αποτρέπει το Starvation. Για δικαιοσύνη απαιτούνται πρόσθετοι μηχανισμοί που δεν αποτελούν μέρος της προδιαγραφής JMM.
Νήμα UI (Main Thread) δεν μπορεί να πεινάσει με την κλασική έννοια, καθώς έχει την υψηλότερη προτεραιότητα. Ωστόσο, το Starvation εμφανίζεται όταν το νήμα UI περιμένει το αποτέλεσμα από ένα νήμα παρασκηνίου που πεινά. Τυπικό σενάριο: ένα AsyncTask ή μια coroutine φορτώνει δεδομένα, αλλά δεν μπορεί να αποκτήσει πρόσβαση στη βάση δεδομένων λόγω ανταγωνισμού με άλλα νήματα, και το UI παγώνει στην αναμονή.
Στις coroutines, για την πρόληψη του Starvation χρησιμοποιήστε limitedParallelism στο Dispatchers.IO για να αποφύγετε την εξάντληση νημάτων. Για συγχρονισμό, εφαρμόστε το Mutex από το kotlinx.coroutines.sync — αναστέλλει την coroutine, δεν μπλοκάρει το νήμα, μειώνοντας τον κίνδυνο πείνας. Αποφύγετε το runBlocking σε coroutines, καθώς μπορεί να καταλάβει ένα νήμα του pool και να προκαλέσει Starvation άλλων coroutines.
Σύνοψη
Θα αναπτύξουμε μια εφαρμογή για κινητά έτοιμη για χρήση
Η IT Sectr δημιουργεί εφαρμογές iOS και Android για νεοφυείς επιχειρήσεις και επιχειρήσεις από το 2017. Θα σας συμβουλεύσουμε και θα προτείνουμε την καλύτερη λύση.
Διαβάστε επίσης