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 — 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.
Thử ba mươi giây
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"
Ba mươi giây đó 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.
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.