Tưởng tượng bốn chiếc xe vào một ngã tư cùng lúc, mỗi xe chờ chiếc bên phải nhích lên trước. Không ai sai luật, không ai liều lĩnh, ai cũng lịch sự nhường — và đúng vì thế mà cả bốn đứng im mãi mãi. Không có va chạm để báo động, không có tiếng còi, chỉ là một vòng tròn nhường nhau khép kín. Deadlock trong phần mềm là ngã tư đó, và cái làm nó nguy hiểm chính là chỗ mọi thứ trông rất đúng đắn.
Bài 68 đã chạm tới deadlock bằng hai khoá trừu tượng tên A và B. Hôm nay là bản có thật: một hàm chuyển tiền sáu dòng, trông hoàn toàn hợp lý, và treo cứng.
Quan trọng hơn là phần sau: cách tìm ra nó trên một tiến trình đang chạy mà bạn không có mã nguồn trong tay.
Hàm gây ra sự cố
static void chuyenTien(TaiKhoan tu, TaiKhoan den, long tien) {
synchronized (tu) {
synchronized (den) {
tu.soDu -= tien;
den.soDu += tien;
}
}
}
Khoá cả hai tài khoản trước khi động vào số dư — đúng nguyên tắc, không có gì để chê khi đọc.
Cho tám luồng chuyển tiền lung tung giữa năm tài khoản:
lần 1: DEADLOCK sau 0,11 giây, 3 luồng bị kẹt
lần 2: DEADLOCK sau 0,11 giây, 3 luồng bị kẹt
lần 3: DEADLOCK sau 0,11 giây, 2 luồng bị kẹt
Một phần mười giây. Ba lần chạy đều vậy.
Và chú ý "3 luồng bị kẹt": chu trình chờ không nhất thiết chỉ có hai luồng. Luồng 1 chờ luồng 2, luồng 2 chờ luồng 3, luồng 3 chờ lại luồng 1 — đúng cái ngã tư bốn xe ở trên, vòng tròn khép kín ở độ dài bất kỳ. Đây là lý do soi từng cặp trong mã rất khó ra vấn đề.
Triệu chứng thì đúng như bài 68 đã nói: không ngoại lệ, không log, không hết giờ chờ. Chương trình đứng im, và phần còn lại của ứng dụng vẫn chạy bình thường.
Bốn điều kiện, và điều kiện bạn phá được
Deadlock cần đủ bốn điều kiện cùng lúc:
Loại trừ lẫn nhau — khoá chỉ một luồng giữ được. Không phá được, đó là mục đích của khoá.
Giữ và chờ — đang giữ một khoá thì đi xin khoá khác. Phá được bằng tryLock như bài hôm qua: xin không được thì nhả cái đang giữ ra.
Không cướp được — không ai giật khoá khỏi tay luồng khác. Java không cho phép cướp.
Chờ vòng tròn — A chờ B, B chờ A. Đây là điều kiện dễ phá nhất, và là cách sửa của bài này.
Tìm nó bằng jstack
Đây là phần đáng giá nhất, vì trên máy chủ thật bạn chỉ có một tiến trình đang treo và một cửa sổ dòng lệnh.
jcmd -l # tìm PID của tiến trình Java
jstack <pid> # hoặc: jcmd <pid> Thread.print
Kết quả thật, chụp từ tiến trình đang treo ở trên:
Found one Java-level deadlock:
=============================
"chuyen-A-sang-B":
waiting to lock monitor 0x0000ffff04001930 (object 0x0000000715e0f640, a NganHang$TaiKhoan),
which is held by "chuyen-B-sang-A"
"chuyen-B-sang-A":
waiting to lock monitor 0x0000fffef8000f70 (object 0x0000000715e0f5f8, a NganHang$TaiKhoan),
which is held by "chuyen-A-sang-B"
Java stack information for the threads listed above:
===================================================
"chuyen-A-sang-B":
at NganHang.chuyenTien(NganHang.java:12)
- waiting to lock <0x0000000715e0f640> (a NganHang$TaiKhoan)
- locked <0x0000000715e0f5f8> (a NganHang$TaiKhoan)
at NganHang.lambda$main$0(NganHang.java:21)
"chuyen-B-sang-A":
at NganHang.chuyenTien(NganHang.java:12)
- waiting to lock <0x0000000715e0f5f8> (a NganHang$TaiKhoan)
- locked <0x0000000715e0f640> (a NganHang$TaiKhoan)
at NganHang.lambda$main$1(NganHang.java:22)
Found 1 deadlock.
JVM tự phát hiện và tự chẩn đoán. Bạn không phải suy luận gì cả.
Ba thứ cần đọc trong đó:
Dòng Found one Java-level deadlock — có thì chắc chắn là deadlock, không phải đoán.
Cặp waiting to lock và locked trong mỗi ngăn xếp. Luồng thứ nhất đã locked <...f5f8> và đang waiting to lock <...f640>; luồng thứ hai đúng ngược lại. Hai địa chỉ đó là hai đối tượng cụ thể, và việc chúng bắt chéo nhau chính là chu trình.
Số dòng: NganHang.java:12. Nó chỉ thẳng vào dòng synchronized (den). Không cần đọc cả tệp.
Tôi khuyên tập đọc kết quả này trước khi cần tới nó. Lúc sự cố xảy ra lúc nửa đêm thì không phải lúc học định dạng mới.
Vài lưu ý thực tế khi dùng trên máy chủ:
jstack phải chạy cùng người dùng với tiến trình Java, nếu không sẽ báo không gắn được.
Trong container, jcmd và jstack có sẵn nếu bạn dùng image JDK. Image chỉ có JRE thì không có — đó là lý do đáng cân nhắc khi chọn image cơ sở.
Hãy chụp hai ba lần cách nhau vài giây. Ngăn xếp giống hệt nhau giữa các lần là dấu hiệu treo thật, chứ không phải đang chạy chậm.
Trong mã, ManagementFactory.getThreadMXBean().findDeadlockedThreads() cho cùng thông tin — hợp để đưa vào một endpoint kiểm tra sức khoẻ.
Sửa: thứ tự khoá nhất quán
Vấn đề không nằm ở việc lấy hai khoá. Nó nằm ở việc lấy theo thứ tự khác nhau — mà thứ tự đó lại do người gọi quyết định, vì mã khoá theo thứ tự tham số.
Cách sửa là chọn một thứ tự tuyệt đối, không phụ thuộc lời gọi:
static void chuyen(TaiKhoan tu, TaiKhoan den, long tien) {
if (tu == den) return;
TaiKhoan truoc = tu.thuTu < den.thuTu ? tu : den;
TaiKhoan sau = tu.thuTu < den.thuTu ? den : tu;
synchronized (truoc) {
synchronized (sau) {
tu.soDu -= tien;
den.soDu += tien;
}
}
}
8 luồng, 1,6 triệu lượt chuyển giữa 5 tài khoản
hoàn thành trong hạn? true (104 ms)
tổng tiền trước: 5.000.000 | sau: 5.000.000 | KHỚP
Chạy xong trong 104 mili giây và không mất đồng nào.
Ý tưởng gọn trong một câu: nếu mọi luồng đều lấy khoá theo cùng một thứ tự, chu trình chờ không thể hình thành. Luồng giữ khoá có thứ tự cao nhất trong nhóm luôn đi tiếp được, nên hệ thống không bao giờ kẹt hoàn toàn.
Chú ý dòng if (tu == den) return. Chuyển tiền cho chính mình làm truoc và sau thành cùng một đối tượng. Ở đây không gây deadlock vì khoá Java tái nhập được, nhưng nó là trường hợp biên nên xử lý tường minh — và trong phần lớn nghiệp vụ, tự chuyển cho mình vốn cũng nên bị chặn.
Khi không có khoá thứ tự sẵn
Ví dụ trên may mắn có sẵn một trường sắp xếp được. Thường thì bạn có id của bản ghi, và dùng nó là xong.
Khi không có gì, System.identityHashCode là ứng viên:
System.identityHashCode(x) = 574434418
System.identityHashCode(y) = 150268540
Nhưng nó không đảm bảo duy nhất. Hai đối tượng khác nhau có thể cho cùng một giá trị, và khi đó thứ tự lại nhập nhằng — đúng cái ta đang cố tránh.
Mẫu chuẩn cho trường hợp đó là thêm một khoá phụ để phá hoà:
int ha = System.identityHashCode(a), hb = System.identityHashCode(b);
if (ha < hb) { synchronized (a) { synchronized (b) { lam(); } } }
else if (ha > hb) { synchronized (b) { synchronized (a) { lam(); } } }
else { synchronized (KHOA_PHU) { // hiếm khi tới đây
synchronized (a) { synchronized (b) { lam(); } } } }
Nhánh thứ ba gần như không bao giờ chạy, nhưng thiếu nó là để lại một lỗi xuất hiện một lần trong nhiều triệu — loại lỗi không bao giờ tái hiện được.
Ba biến thể mà jstack không nói cho bạn
Đây là phần tôi thấy ít được nhắc, và là chỗ đi tìm mất nhiều thời gian nhất.
Deadlock ở cơ sở dữ liệu. Hai giao dịch khoá hai dòng theo thứ tự ngược nhau. JVM hoàn toàn không biết — với nó, các luồng chỉ đang chờ mạng và hiện là RUNNABLE. Điểm may: PostgreSQL và MySQL tự phát hiện và huỷ một bên, nên bạn nhận được ngoại lệ chứ không bị treo. Cách sửa giống hệt: cập nhật nhiều dòng thì luôn sắp theo khoá chính trước.
Cạn pool luồng. Một tác vụ trong pool nộp một tác vụ khác vào cùng pool rồi chờ kết quả. Pool hết luồng rảnh, tác vụ con không bao giờ được chạy, tác vụ cha chờ mãi. jstack không gọi đây là deadlock vì không có khoá nào — chỉ thấy một đám luồng ở WAITING. Đây là lý do đừng bao giờ chờ kết quả của tác vụ cùng pool.
Cạn pool kết nối. Đúng chuyện của bài 66: một luồng giữ kết nối rồi đi xin kết nối thứ hai, trong khi pool đã hết. Cũng không phải deadlock theo nghĩa của JVM, nhưng triệu chứng y hệt.
Ba biến thể này có chung một dấu hiệu: nhiều luồng ở WAITING mà không có dòng Found deadlock. Thấy vậy thì đừng dừng ở kết luận "không có deadlock" — hãy hỏi chúng đang chờ ai.
Bốn thói quen phòng ngừa
Đừng giữ hai khoá nếu tránh được. Deadlock cần ít nhất hai. Gộp thành một khoá lớn hơn thường vẫn nhanh hơn thời gian đi tìm sự cố.
Có thứ tự thì viết nó vào comment, ngay tại chỗ lấy khoá. Người sửa sau sẽ không tự đoán ra, và họ sẽ thêm một lời gọi theo thứ tự ngược.
Đừng gọi mã của người khác khi đang giữ khoá — callback, listener, phương thức bị ghi đè. Bạn không biết nó sẽ xin khoá nào.
Đặt hạn chờ ở mọi chỗ có thể. tryLock(timeout), connectionTimeout, hạn chờ truy vấn. Hạn chờ biến một vụ treo vĩnh viễn thành một ngoại lệ có ngăn xếp — và ngoại lệ thì gỡ được.
Muốn tự thấy tận mắt: chạy chương trình chuyển tiền bản sai ở đầu bài, đợi nó treo, rồi mở cửa sổ khác gõ jcmd -l && jstack <pid> | grep -A 20 "Found one". Nửa phút đó cho bạn thấy JVM đã làm sẵn toàn bộ phần chẩn đoán, kèm số dòng. Lần sau gặp một ứng dụng đứng im trên máy chủ, bạn sẽ biết gõ gì thay vì đọc lại mã và đoán.
Mẫu số chung
Cái ngã tư bốn xe không phải chuyện riêng của Java — nó là một định luật của mọi hệ thống có tài nguyên độc quyền và nhiều bên tranh nhau, nên mỗi nền tảng lại đối phó một kiểu, và so chúng lại là cách nhớ lâu nhất. Go đi xa nhất về phát hiện: runtime của nó có một bộ dò, khi mọi goroutine đều ngủ chờ nó in thẳng fatal error: all goroutines are asleep - deadlock! rồi kết thúc chương trình — nhưng nó chỉ bắt được deadlock toàn cục, còn hai goroutine kẹt nhau trong khi phần khác vẫn chạy thì nó im như jstack im với ba biến thể ở trên. Cơ sở dữ liệu chọn hướng ngược lại: PostgreSQL và MySQL không phòng mà chữa — chúng dựng đồ thị chờ, thấy chu trình thì huỷ một nạn nhân và trả lỗi, biến bế tắc vĩnh viễn thành một ngoại lệ bạn thử lại được. Hệ điều hành thì phần lớn buông tay: khoá tệp và semaphore giữa các tiến trình mà xếp vòng thì kernel để nguyên, đúng như Java để nguyên hai luồng.
Điểm chung nằm dưới mọi phiên bản: deadlock luôn là một chu trình trong đồ thị "ai chờ ai", và cách chữa gốc luôn là áp một thứ tự toàn cục lên tài nguyên — khoá theo id nhỏ trước, cập nhật dòng theo khoá chính trước, xin tài nguyên theo cùng một trật tự ở mọi nơi. Phát hiện (jstack, bộ dò của Go, đồ thị chờ của CSDL) chỉ giúp bạn thấy chu trình sau khi nó xảy ra; phá vòng chờ bằng thứ tự nhất quán mới là thứ khiến nó không bao giờ hình thành. Một bên là đèn báo cháy, một bên là không để có lửa.
Ngày mai: wait và notify — viết tay mô hình sản xuất – tiêu thụ để thấy nó khó đúng tới đâu, rồi viết lại bằng BlockingQueue cho ngắn bằng một phần ba.