Starvation (đói luồng) là tình huống trong đó một luồng không thể truy cập tài nguyên cần thiết để tiếp tục công việc, mặc dù nó đã sẵn sàng thực thi. Theo Baeldung (Java Thread Starvation, 2024), đói xảy ra do lập lịch không công bằng, khi các luồng có mức ưu tiên thấp liên tục bị hoãn lại để nhường cho các luồng có mức ưu tiên cao hơn. Không giống như Deadlock, Starvation không chặn luồng — nó vẫn ở trạng thái RUNNABLE nhưng không bao giờ nhận được thời gian CPU.
Những điểm chính
Starvation (đói luồng) là vấn đề lập trình đa luồng trong đó một luồng không thể truy cập tài nguyên cần thiết để hoàn thành tác vụ, mặc dù tài nguyên không bị khóa vĩnh viễn bởi luồng khác. Luồng ở trạng thái RUNNABLE, nhưng bộ lập lịch hoặc cơ chế đồng bộ hóa trì hoãn một cách có hệ thống việc thực thi của nó để ưu tiên các luồng khác.
Trong phát triển di động, Starvation biểu hiện như thực thi tác vụ không đồng đều: một số thao tác thực thi ngay lập tức, trong khi những thao tác khác chịu độ trễ thảm khốc. Ví dụ, một luồng đồng bộ dữ liệu nền có thể không bao giờ truy cập được cơ sở dữ liệu nếu luồng UI và trình xử lý hoạt ảnh liên tục vượt lên trước. Theo Android Developer Blog (Performance Matters, 2023), khoảng 12% số khung hình bị mất (jank) trên Android là do Starvation của các tác vụ nền mà quá trình kết xuất phụ thuộc vào.
Sự khác biệt chính giữa Starvation và Deadlock là tính khả nghịch. Nếu tải hệ thống giảm hoặc các mức ưu tiên được phân bổ lại, luồng bị đói có thể giành được tài nguyên và hoàn thành công việc. Tuy nhiên, dưới tải cao liên tục, Starvation có thể kéo dài vô thời hạn, tạo ấn tượng về một ứng dụng bị đóng băng.
synchronized trong Java và Kotlin là ví dụ kinh điển về cơ chế không công bằng. Dưới sự tranh chấp cao, JVM có thể liên tục cấp khóa cho cùng các luồng đang hoạt động, trong khi các luồng khác liên tục thua trong cuộc đua. Đây không phải lỗi của JVM mà là sự đánh đổi trong thiết kế: khóa không công bằng cung cấp thông lượng cao hơn nhưng đánh đổi sự công bằng trong truy cập. Đối với ứng dụng di động có 4–8 luồng, vấn đề này đặc biệt phù hợp.
Đặt các mức ưu tiên luồng khác nhau có thể dẫn đến Starvation cho các luồng có mức ưu tiên thấp. Trong Android Runtime, bộ lập lịch CFS (Completely Fair Scheduler) của Linux phân phối thời gian CPU tỷ lệ thuận với mức ưu tiên, và nếu các luồng có mức ưu tiên cao liên tục hoạt động, các luồng có mức ưu tiên thấp có thể không bao giờ nhận được thời gian CPU. Google đặc biệt khuyến cáo không thay đổi mức ưu tiên luồng trong Android — hệ thống tự quản lý chúng.
Nếu một luồng giữ khóa quá lâu (thực hiện các tính toán nặng, yêu cầu mạng hoặc thao tác tệp bên trong khối synchronized), các luồng khác đang chờ khóa đó sẽ bị đói. Điều này đặc biệt nguy hiểm trong Android, nơi các thao tác dài trên luồng UI gây ra ANR, và việc di chuyển chúng sang luồng nền mà không tối ưu hóa các đoạn tới hạn chỉ chuyển vấn đề Starvation sang các luồng công nhân.
Hãy xem xét một ví dụ trong đó một luồng giành khóa quá thường xuyên do lập lịch không công bằng. Starvation được thể hiện qua vòng lặp vô hạn của một luồng có mức ưu tiên cao ngăn luồng có mức ưu tiên thấp truy cập tài nguyên chia sẻ.
class SharedResource {
private val lock = Any()
fun criticalSection(id: String) {
synchronized(lock) {
println("$id đã truy cập được")
Thread.sleep(10) // mô phỏng công việc
}
}
}
fun main() {
val resource = SharedResource()
// Luồng có mức ưu tiên cao — luôn hoạt động
val highPriority = Thread {
while (true) {
resource.criticalSection("High")
}
}
// Luồng có mức ưu tiên thấp — có thể không bao giờ truy cập được
val lowPriority = Thread {
while (true) {
resource.criticalSection("Low")
}
}
highPriority.start()
lowPriority.start()
// "Low" có thể không bao giờ in thông báo — Starvation!
}
Trong ví dụ này, luồng highPriority liên tục giành khóa và chỉ giải phóng nó trong 10 ms. Do bản chất không công bằng của synchronized, bộ lập lịch JVM rất có thể sẽ cấp khóa lại cho cùng luồng vừa giải phóng nó — luồng có mức ưu tiên thấp bị đói. Giải pháp là sử dụng ReentrantLock(true) với cờ fair, đảm bảo thứ tự chờ FIFO.
Phiên bản đã sửa với khóa công bằng đảm bảo phân phối công bằng quyền truy cập tài nguyên.
class FairSharedResource {
private val fairLock = ReentrantLock(true) // fair = true
fun criticalSection(id: String) {
fairLock.lock()
try {
println("$id đã truy cập được (fair)")
Thread.sleep(10)
} finally {
fairLock.unlock()
}
}
}
Ba vấn đề đa luồng kinh điển — Starvation, Deadlock và Livelock — thường được nhóm lại với nhau, nhưng cơ chế và giải pháp của chúng khác nhau. Starvation — luồng đã sẵn sàng nhưng không thể giành được tài nguyên. Deadlock — các luồng bị chặn do chờ đợi vòng tròn. Livelock — các luồng đang hoạt động nhưng không tiến triển.
| Tham số | Starvation | Deadlock | Livelock |
|---|---|---|---|
| Trạng thái luồng | RUNNABLE | BLOCKED | RUNNABLE |
| Tiến triển | Không | Không | Không (dù đang hoạt động) |
| Sử dụng CPU | Thấp | Tối thiểu | Cao (lên đến 100%) |
| Nguyên nhân | Lập lịch không công bằng | Chờ đợi vòng tròn | Phản ứng giống nhau với xung đột |
| Khắc phục chính | Fair Lock, rút ngắn đoạn tới hạn | Phân cấp khóa | Giới hạn thử lại, exponential backoff |
Starvation được coi là ít nghiêm trọng hơn Deadlock vì nó không gây tử vong — dưới tải giảm, luồng bị đói cuối cùng sẽ thực thi. Tuy nhiên, trong sử dụng Android thực tế, nơi bộ nhớ và CPU bị giới hạn, Starvation có thể kéo dài hàng phút, tạo ra trải nghiệm người dùng không thể chấp nhận được.
Thread Dump được chụp nhiều lần trong khoảng thời gian ngắn — phương pháp cơ bản để phát hiện đói. Nếu một luồng luôn ở trạng thái RUNNABLE nhưng ngăn xếp cuộc gọi của nó không thay đổi qua nhiều lần dump — đây là dấu hiệu kinh điển của Starvation. Trong Android Studio, hãy sử dụng Android Profiler với tính năng ghi lại trạng thái luồng theo thời gian.
Phát hiện tự động có thể thực hiện thông qua giám sát thời gian thực thi của các tác vụ. Nếu một tác vụ có thời gian thực thi có thể dự đoán (ví dụ: 50 ms) mất 5 giây hoặc hơn — có khả năng cao là Starvation. Trong ứng dụng di động, Firebase Performance Monitoring cho phép bạn thiết lập các trace tùy chỉnh cho các đoạn tới hạn và nhận thông báo khi vượt quá ngưỡng.
Để chẩn đoán Starvation do các khối synchronized gây ra, hãy sử dụng Java Flight Recorder (JFR) (có sẵn trên Android qua OpenJDK API) hoặc Async Profiler. Các công cụ này hiển thị monitor nào có thời gian chờ cao nhất và luồng nào đang tranh chấp cho từng monitor. Dữ liệu JFR tích hợp với IntelliJ IDEA Ultimate thông qua trình phân tích tích hợp sẵn.
ReentrantLock(true) đảm bảo rằng các luồng giành khóa theo thứ tự FIFO. Không giống như synchronized, khóa công bằng không cho phép luồng vừa giải phóng khóa giành lại nó ngay lập tức. Điều này loại bỏ hoàn toàn Starvation, mặc dù làm giảm thông lượng tổng thể 10–20% do chi phí duy trì hàng đợi.
Cấu trúc dữ liệu không khóa (ConcurrentHashMap, AtomicReference, LongAdder) loại bỏ Starvation theo định nghĩa, vì chúng không có khóa mà một luồng có thể giữ. Tất cả các thao tác sử dụng lệnh CAS của CPU, đảm bảo ít nhất một luồng tiến triển trong một số bước hữu hạn. Đối với phát triển di động, ưu tiên sử dụng ConcurrentLinkedQueue cho hàng đợi tác vụ.
Giảm thiểu thời gian giữ khóa là cách phổ biến để giảm nguy cơ Starvation. Di chuyển các thao tác nặng (mạng, I/O đĩa, tính toán phức tạp) ra ngoài khối synchronized. Sử dụng ReadWriteLock cho các kịch bản mà người đọc không nên bị đói do người ghi hiếm hoi. Thư viện Kotlin Coroutines cung cấp Mutex với cơ chế tạm dừng không chặn luồng HĐH.
Condition.await() và signal() nên được sử dụng thận trọng: một luồng đang chờ trên Condition thức dậy cùng với các luồng khác (spurious wakeup), và tất cả tranh chấp khóa. Nếu một luồng ngay lập tức quay lại chờ sau await trong khi các luồng khác giành được khóa, luồng bị đói có thể thức dậy và ngủ trở lại vô thời hạn. Luôn kiểm tra điều kiện trong vòng lặp while thay vì if để đảm bảo kiểm tra lại.
Các câu hỏi thường gặp
Priority Inversion (đảo ngược ưu tiên) là tình huống trong đó một luồng có mức ưu tiên thấp giữ khóa mà luồng có mức ưu tiên cao cần. Kết quả là luồng có mức ưu tiên cao chờ luồng có mức ưu tiên thấp — các mức ưu tiên bị đảo ngược. Starvation là vấn đề rộng hơn: luồng không thể giành được tài nguyên bất kể mức ưu tiên, do lập lịch không công bằng hoặc các đoạn tới hạn dài.
Không, Starvation là vấn đề đa luồng. Mã đơn luồng không có tranh chấp tài nguyên hoặc lập lịch luồng. Tuy nhiên, Starvation có thể xảy ra trong mã bất đồng bộ đơn luồng (ví dụ: vòng lặp sự kiện JavaScript) nếu một vi tác vụ trì hoãn vô thời hạn việc thực thi các tác vụ khác qua setTimeout với độ trễ bằng không.
JMM (Java Memory Model) định nghĩa các quy tắc về khả năng hiển thị thay đổi giữa các luồng nhưng không đảm bảo lập lịch công bằng. synchronized, theo JMM, đảm bảo tính nhất quán tuần tự — tính đúng đắn cơ bản — nhưng không ngăn chặn Starvation. Sự công bằng đòi hỏi các cơ chế bổ sung không được chỉ định trong JMM.
Luồng UI (Main Thread) không thể bị đói theo nghĩa cổ điển vì nó có mức ưu tiên cao nhất. Tuy nhiên, Starvation xảy ra khi luồng UI chờ kết quả từ một luồng nền bị đói. Một kịch bản điển hình: AsyncTask hoặc coroutine tải dữ liệu nhưng không thể truy cập cơ sở dữ liệu do tranh chấp với các luồng khác, và UI bị đóng băng khi chờ đợi.
Trong coroutines, để ngăn chặn Starvation, hãy sử dụng limitedParallelism trên Dispatchers.IO để tránh cạn kiệt luồng. Để đồng bộ hóa, sử dụng Mutex từ kotlinx.coroutines.sync — nó tạm dừng coroutine thay vì chặn luồng, giảm nguy cơ đói. Tránh runBlocking trong coroutines, vì nó có thể chiếm luồng từ pool và gây Starvation cho các coroutine khác.
Tổng kết
Chúng tôi sẽ phát triển ứng dụng di động chìa khóa trao tay
IT Sectr tạo các ứng dụng iOS và Android cho các công ty khởi nghiệp và doanh nghiệp từ năm 2017. Chúng tôi sẽ tư vấn và đề xuất giải pháp tốt nhất cho bạn.
Đọc thêm