Deadlock (khóa lẫn nhau) là trạng thái trong đó hai hoặc nhiều thread chờ đợi vô thời hạn để giải phóng tài nguyên bị chiếm giữ bởi các thành viên khác. Theo Oracle Java Tutorials (2024), Deadlock xảy ra khi chờ đợi vòng tròn khi mỗi thread giữ một khóa cần thiết cho thread khác. Nếu không có công cụ phát hiện đặc biệt, Deadlock ngừng hoàn toàn việc thực thi ứng dụng mà không có lỗi hiển thị.
Các điểm chính
Deadlock là tình huống trong lập trình đa luồng khi hai hoặc nhiều thread chặn nhau vĩnh viễn. Mỗi thread giữ một tài nguyên cần thiết cho thread khác và không giải phóng nó trong khi chờ đợi để có được tài nguyên còn thiếu. Kết quả là không thread nào có thể tiếp tục thực thi.
Trong phát triển di động, Deadlock đặc biệt nghiêm trọng vì nó không gây ra ngoại lệ hay sự cố. Ứng dụng chỉ đơn giản là ngừng phản hồi hành động của người dùng (ANR — Application Not Responding), và cách duy nhất là buộc kết thúc tiến trình. Theo Google (Android Performance Patterns, 2023), khoảng 15% báo cáo ANR trong Google Play Console liên quan đến khóa lẫn nhau trong các thread nền.
Sự khác biệt chính giữa Deadlock và các vấn đề đồng thời khác là tính không thể đảo ngược nếu không có can thiệp bên ngoài. Các thread sẽ không tự giải phóng tài nguyên vì bộ lập lịch của hệ điều hành không thể thu hồi khóa một cách cưỡng chế. Điều này phân biệt Deadlock với Livelock, nơi các thread hoạt động nhưng không thực hiện công việc hữu ích.
Năm 1971, Edward G. Coffman đã xây dựng bốn điều kiện bắt buộc cần thiết để Deadlock xảy ra. Nếu thiếu ít nhất một trong số chúng, khóa lẫn nhau là không thể. Các điều kiện này được gọi là điều kiện Coffman và là nền tảng của tất cả các thuật toán ngăn chặn Deadlock.
Tài nguyên chỉ có thể được chiếm giữ bởi một thread duy nhất tại mỗi thời điểm. Nếu tài nguyên cho phép đọc đồng thời bởi nhiều thread (ví dụ: ReadWriteLock ở chế độ đọc), Deadlock không xảy ra. Điều kiện này xuất phát từ bản chất của Mutex và khóa.
Một thread giữ tài nguyên đã chiếm được và đồng thời chờ đợi chiếm tài nguyên khác. Nếu một thread có thể giải phóng tài nguyên hiện tại trước khi yêu cầu tài nguyên tiếp theo (thông qua khóa hai pha), điều kiện Hold and Wait bị phá vỡ. Trên Android, điều này thường thể hiện khi một thread giữ khóa cơ sở dữ liệu và cố gắng chiếm khóa SharedPreferences.
Hệ điều hành không thể tước khóa một cách cưỡng chế khỏi thread. Tài nguyên chỉ được giải phóng khi thread tự giải phóng nó. Trong một số hệ thống (ví dụ: SQLite chế độ WAL), việc chiếm quyền cưỡng chế được thực hiện ở mức thao tác riêng lẻ, giúp giảm nguy cơ Deadlock.
Tồn tại một chuỗi khép kín các thread, mỗi thread chờ đợi tài nguyên bị giữ bởi thread tiếp theo trong chuỗi. Ví dụ, thread A giữ tài nguyên 1 và chờ tài nguyên 2, thread B giữ tài nguyên 2 và chờ tài nguyên 1. Đây là điều kiện duy nhất mà nhà phát triển có thể loại bỏ về mặt kiến trúc — thông qua phân cấp khóa. Nếu tất cả các thread chiếm tài nguyên theo một thứ tự toàn cục được xác định chặt chẽ, vòng tròn là không thể về mặt vật lý.
Trong thực tế, trên các ứng dụng Android, Deadlock thường xảy ra nhất do sự giao nhau ngầm của các khóa ở các cấp độ khác nhau: khóa cơ sở dữ liệu (Room), khóa SharedPreferences và khóa collection trong bộ nhớ. Mỗi khóa này được quản lý bởi các thành phần khác nhau, và nếu không có giao thức tập trung về thứ tự chiếm giữ, các nhà phát triển vô tình tạo ra vòng tròn.
Hãy xem xét một ví dụ cổ điển về khóa lẫn nhau — hai thread chiếm khóa theo thứ tự khác nhau. Nếu thread đầu tiên khóa tài nguyên A và cố gắng chiếm B, và thread thứ hai khóa B và cố gắng chiếm A, Deadlock xảy ra.
class DeadlockExample {
private val lockA = Any()
private val lockB = Any()
fun operationA() {
synchronized(lockA) {
Thread.sleep(50) // mô phỏng công việc
synchronized(lockB) {
println("operationA hoàn thành")
}
}
}
fun operationB() {
synchronized(lockB) { // thứ tự đảo ngược
Thread.sleep(50)
synchronized(lockA) {
println("operationB hoàn thành")
}
}
}
}
fun main() {
val ex = DeadlockExample()
Thread { ex.operationA() }.start()
Thread { ex.operationB() }.start()
// Ứng dụng sẽ treo vĩnh viễn — Deadlock!
}
Trong ví dụ này, operationA chiếm lockA, và operationB chiếm lockB. Sau đó mỗi thread cố gắng chiếm khóa thứ hai — và cả hai chờ đợi vô thời hạn. Chương trình treo mà không có thông báo lỗi. Cách duy nhất để sửa là đảm bảo cùng một thứ tự chiếm khóa trong tất cả các phương thức.
Ba vấn đề đồng thời này thường bị nhầm lẫn, nhưng cơ chế và hậu quả của chúng khác nhau về cơ bản. Deadlock — dừng hoàn toàn, Starvation — chờ đợi vô hạn một tài nguyên, Livelock — không hoạt động tích cực. Hiểu được sự khác biệt là rất quan trọng để chọn chiến lược giải quyết đúng đắn.
| Đặc điểm | Deadlock | Starvation | Livelock |
|---|---|---|---|
| Trạng thái thread | Bị khóa (BLOCKED) | Sẵn sàng (RUNNABLE) | Hoạt động (RUNNABLE) |
| Thực hiện công việc | Không | Không | Có, nhưng vô ích |
| Nguyên nhân | Chờ đợi vòng tròn | Lập lịch không công bằng | Xử lý xung đột không đúng |
| Phát hiện | Thread Dump, thời gian chờ | Giám sát tiến độ | Bộ đếm thử lại |
Starvation (đói tài nguyên) xảy ra khi bộ lập lịch liên tục hoãn thực thi của thread ưu tiên thấp để ưu tiên các thread khác. Không giống Deadlock, thread không bị khóa — nó sẵn sàng thực thi nhưng không nhận được thời gian CPU. Trên Android, kịch bản điển hình là thread nền ưu tiên thấp không bao giờ thực thi nếu thread UI và thread Service liên tục hoạt động.
Livelock (khóa chủ động) là tình huống các thread không bị khóa nhưng phản ứng vô tận với hành động của nhau mà không thực hiện công việc hữu ích. Sự tương tự cổ điển — hai người gặp nhau trong hành lang và cả hai đều cố gắng nhường đường, di chuyển cùng một hướng. Không giống Deadlock, các thread trong Livelock tiêu thụ CPU, làm hao pin thiết bị.
Thread Dump là công cụ chính để phát hiện khóa lẫn nhau trong JVM và Android Runtime. Trong quá trình dump, JVM tự động phân tích đồ thị phụ thuộc giữa các monitor và đánh dấu các chu trình Deadlock. Trong Android Studio, thread dump có thể được lấy qua Android Profiler hoặc lệnh kill -3 PID từ ADB Shell.
Phát hiện Deadlock tự động tại thời gian chạy được thực hiện thông qua bộ định thời Watchdog. Nếu một thread không hoàn thành thao tác trong thời gian chờ xác định, watchdog khởi tạo dump và gửi báo cáo đến hệ thống Crash Reporting (Firebase Crashlytics, Sentry). Theo Sentry (Issue Resolution Report, 2024), cấu hình watchdog giảm thời gian chẩn đoán Deadlock từ vài tuần xuống vài giờ.
Trong giai đoạn phát triển, trình phân tích tĩnh ThreadSafe của JetBrains và Checker Framework với mô-đun Lock Checker rất hiệu quả. Các công cụ này phân tích thứ tự chiếm khóa ở cấp mã nguồn và cảnh báo về các chu trình tiềm ẩn. Ngoài ra, Test-Driven Deadlock Detection được khuyến nghị — các kiểm tra stress chạy thao tác với các thứ tự khóa khác nhau trong hàng trăm thread.
Đáng chú ý đặc biệt là Cooperative Deadlock Detection — phương pháp mà các thread trao đổi thông tin về các khóa đã chiếm qua một registry toàn cục. Nếu một thread phát hiện chu trình tiềm ẩn, nó giải phóng tất cả tài nguyên và thử lại thao tác. Cách tiếp cận này được sử dụng trong các hệ thống phân tán (Apache ZooKeeper, Google Chubby) và đang dần được áp dụng trong phát triển di động thông qua các thư viện như Jetpack Sync.
Cách đáng tin cậy nhất là thiết lập thứ tự chiếm khóa toàn cục trong toàn bộ ứng dụng. Nếu tất cả các thread luôn chiếm khóa có số nhỏ hơn trước, sau đó là khóa có số lớn hơn, chờ đợi vòng tròn (điều kiện Circular Wait) là không thể. Trong các dự án lớn, thứ tự được ghi lại và xác minh qua code review.
TryLock là phương pháp khóa không chặn thread vô thời hạn mà trả về false nếu khóa không được chiếm trong thời gian xác định. Trong Java, điều này được thực hiện qua ReentrantLock.tryLock(timeout, TimeUnit), trong Kotlin Coroutines — qua Mutex.withLock với thời gian chờ. Khi thất bại, thread giải phóng tất cả tài nguyên đã chiếm và thử lại sau.
Thuật toán ngân hàng là phương pháp lý thuyết ngăn chặn Deadlock do Edsger Dijkstra đề xuất. Nó mô hình hóa việc phân bổ tài nguyên như các giao dịch ngân hàng: hệ thống không phân bổ tài nguyên nếu điều đó có thể dẫn đến trạng thái không an toàn (deadlock). Trong thực tế, thuật toán hiếm khi được sử dụng trong phát triển di động do khó biết trước nhu cầu tối đa của các thread, nhưng các nguyên tắc của nó được sử dụng trong cơ sở dữ liệu SQLite và hệ thống tệp.
Câu hỏi thường gặp
Không, khóa lẫn nhau cần ít nhất hai thread. Trong mã đơn luồng, tất cả các thao tác được thực thi tuần tự, vì vậy chờ đợi vòng tròn là không thể. Tuy nhiên, Deadlock có thể xảy ra giữa các tiến trình khi sử dụng khóa tệp hoặc semaphore liên tiến trình.
Trong coroutines, Deadlock xảy ra ở mức hàm tạm dừng (suspend) và không chặn thread OS, khiến nó ít bị chú ý hơn. Mutex từ kotlinx.coroutines là khóa tạm dừng (suspending) — nó không chặn thread, nhưng coroutine không thực thi. Để phát hiện, hãy sử dụng DebugProbes từ mô-đun kotlinx-coroutines-debug.
Deadlock trong SQLite xảy ra khi hai kết nối cơ sở dữ liệu cố gắng thực thi giao dịch theo các thứ tự khác nhau. SQLite phát hiện các tình huống như vậy và trả về mã lỗi SQLITE_BUSY hoặc SQLITE_LOCKED. Trên Android, khuyến nghị sử dụng Room với một phiên bản cơ sở dữ liệu duy nhất và giao dịch qua @Transaction, giúp loại bỏ Deadlock giữa các kết nối.
Android Runtime có bộ phát hiện Deadlock tích hợp chạy khi ANR (Application Not Responding) được tạo ra. Hệ thống phân tích Thread Dump của tất cả các thread trong ứng dụng và đánh dấu các khóa lẫn nhau. Kết quả có sẵn tại /data/anr/traces.txt và trong Google Play Console ở mục ANR Reports.
Đầu tiên, lấy Thread Dump của tất cả các thread trong ứng dụng. Phân tích thread nào giữ khóa nào và đang cố chiếm khóa nào. Triển khai bộ định thời Watchdog với dump tự động khi vượt quá giới hạn thời gian. Sau khi sửa, thêm quy tắc lint ThreadSafety vào đường ống CI để ngăn tái diễn.
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