Bài hôm qua nói về vòng đời một luồng. Hôm nay là chuyện xảy ra khi hai luồng chạm vào cùng một dữ liệu.
Bắt đầu bằng phép đo mà tôi nghĩ mọi người nên tự chạy một lần trong đời.
i++ không phải một thao tác
Hai luồng, mỗi luồng cộng một biến int lên một triệu lần. Kết quả mong đợi là hai triệu. Tôi chạy ba lần:
lần 1: mong đợi 2.000.000 -> thực tế 1.056.688 (mất 943.312)
lần 2: mong đợi 2.000.000 -> thực tế 1.346.504 (mất 653.496)
lần 3: mong đợi 2.000.000 -> thực tế 2.000.000 (mất 0)
Gần một triệu lần cộng biến mất. Nhưng hãy nhìn kỹ lần thứ ba.
Lần thứ ba cho kết quả đúng. Cùng mã, cùng máy, chạy liền nhau.
Nguyên nhân là demTran++ không phải một lệnh mà là ba: đọc giá trị, cộng một, ghi lại. Hai luồng xen kẽ nhau giữa ba bước đó thì cả hai cùng đọc ra 5, cùng tính 6, cùng ghi 6 — hai lần cộng chỉ tăng được một.
Thêm synchronized vào là hết:
static synchronized void tang() { dem++; }
mong đợi 2.000.000 -> thực tế 2.000.000
Khoá gắn vào đối tượng, không gắn vào phương thức
Đây là điều hay bị hiểu sai nhất về synchronized.
class Dem {
int n = 0;
synchronized void tang() { n++; }
}
hai đối tượng khác nhau -> hai khoá khác nhau:
p.n = 200.000, q.n = 200.000 (mỗi cái đúng)
cùng một đối tượng -> cùng một khoá:
p.n = 400.000 (đúng)
synchronized trên phương thức thường khoá vào this. Nên hai đối tượng khác nhau có hai khoá độc lập, và hai luồng làm việc trên hai đối tượng khác nhau không hề chờ nhau — điều này đúng và tốt.
Nhưng nó kéo theo hệ quả: synchronized trên phương thức static khoá vào đối tượng Class, tức là một khoá dùng chung cho toàn bộ lớp. Trộn lẫn hai loại là lỗi kinh điển:
synchronized void a() { ... } // khoá vào this
static synchronized void b() { ... } // khoá vào Dem.class — KHÁC khoá!
Hai phương thức này không loại trừ lẫn nhau. Nếu cả hai cùng đụng vào một dữ liệu tĩnh, bạn có một lỗi đua mà đọc mã thì thấy đầy synchronized.
Viết khối tường minh thì rõ ràng hơn hẳn, và tôi khuyên dùng dạng này:
private final Object khoa = new Object();
void tang() { synchronized (khoa) { n++; } }
Một trường private final làm khoá: không ai bên ngoài khoá vào nó được, và nhìn vào mã là biết chính xác đang bảo vệ cái gì.
Đừng khoá vào những đối tượng này
"khoa" và "kh"+duoi() cùng đối tượng? false
"khoa" và "kh"+"oa" cùng đối tượng? true <- vô tình dùng CHUNG khoá
Integer 127 == 127 ? true <- bộ nhớ đệm, chung khoá
Integer 128 == 128 ? false
Đừng khoá vào chuỗi. Các chuỗi hằng được gộp vào một bể chung, nên "khoa" ở lớp của bạn và "khoa" ở một thư viện nào đó là cùng một đối tượng. Bạn vừa chia sẻ khoá với mã mình chưa từng đọc, và deadlock sẽ đến từ hướng không ai ngờ.
Đừng khoá vào Integer. Java giữ sẵn các đối tượng từ -128 tới 127, nên hai Integer giá trị 127 là một, còn 128 thì không. Khoá của bạn hoạt động hay không tuỳ vào giá trị của biến — kiểu lỗi gần như không thể lần ra.
Cùng lý do, đừng khoá vào Boolean hay vào một trường có thể bị gán lại. Gán lại đối tượng khoá giữa chừng nghĩa là các luồng đang khoá vào hai thứ khác nhau, và loại trừ lẫn nhau biến mất. Vì vậy khoá phải là final.
Khoá tái nhập
vào ngoai(), đang giữ khoá
vào trong() — cùng khoá, vẫn vào được
Một luồng đang giữ khoá thì vào lại được các khối synchronized khác trên cùng khoá đó. JVM đếm số lần vào, và chỉ nhả khoá khi đếm về 0.
Nếu không có tính chất này, mọi phương thức synchronized gọi sang một phương thức synchronized khác của cùng đối tượng đều sẽ tự khoá chính mình — kể cả khi gọi qua kế thừa. Đây là thứ bạn hưởng mà không cần nghĩ tới, cho tới lúc chuyển sang các cơ chế khoá khác thì phải kiểm lại.
Phạm vi khoá: chỗ đáng bỏ công nhất
Bốn luồng, mỗi luồng làm 20.000 việc, mỗi việc gồm một đoạn tính toán rồi cộng kết quả vào biến chung.
Cách một, khoá cả phương thức — tính toán nằm trong khoá:
static synchronized void a(int k) { tong += tinh(k); }
Cách hai, tính trước, chỉ khoá đúng lúc ghi:
static void b(int k) {
long v = tinh(k); // ngoài khoá
synchronized (KHOA) { tong += v; } // trong khoá
}
tính toán NẰM TRONG khoá : 87 ms
tính toán NẰM NGOÀI khoá : 17 ms
Hơn năm lần, và hai đoạn mã cho kết quả giống hệt nhau.
Lý do đơn giản: phần trong khoá chạy tuần tự, dù bạn có bao nhiêu nhân CPU. Cách một đặt toàn bộ phép tính vào vùng tuần tự đó nên bốn luồng chẳng nhanh hơn một luồng chút nào. Cách hai để phần tính toán chạy song song thật, chỉ tuần tự hoá đúng một phép cộng.
Nguyên tắc: khoá cái ngắn nhất có thể, và không bao giờ đặt việc chậm vào trong khoá. Cụ thể là đừng gọi mạng, đừng truy vấn cơ sở dữ liệu, đừng đọc tệp, và đừng gọi ngược lại mã của người khác khi đang giữ khoá — bạn không biết mã đó sẽ giữ khoá nào.
Chú ý cách viết synchronized void rất dễ dẫn tới sai lầm này, vì nó khoá cả phương thức một cách mặc định trong khi thường chỉ vài dòng cần được bảo vệ. Đó là lý do thứ hai tôi thích khối tường minh.
Deadlock
Hai luồng, hai khoá, lấy theo thứ tự ngược nhau:
Thread t1 = new Thread(() -> { synchronized (A) { ngu(100); synchronized (B) { } } });
Thread t2 = new Thread(() -> { synchronized (B) { ngu(100); synchronized (A) { } } });
t1: BLOCKED t2: BLOCKED
findDeadlockedThreads() -> 2 luồng bị kẹt
t1 đang chờ khoá do t2 giữ
t2 đang chờ khoá do t1 giữ
Không ngoại lệ, không hết giờ chờ, không log. Hai luồng đứng im vĩnh viễn, và phần còn lại của ứng dụng vẫn chạy — nên triệu chứng chỉ là "một chức năng nào đó không phản hồi nữa".
JVM phát hiện được nhưng không tự xử lý. Bạn lấy chẩn đoán bằng jcmd <pid> Thread.print, hoặc trong mã bằng ManagementFactory.getThreadMXBean().findDeadlockedThreads() như trên. Nó chỉ thẳng ra luồng nào chờ khoá của ai — thông tin đắt giá khi đang xử lý sự cố.
Cách phòng hiệu quả nhất là luôn lấy nhiều khoá theo cùng một thứ tự. Nếu mọi nơi trong mã đều lấy A trước B, chu trình không bao giờ hình thành. Khi thứ tự phụ thuộc dữ liệu — chuyển tiền giữa hai tài khoản chẳng hạn — hãy sắp theo một khoá cố định như id, rồi lấy theo thứ tự đó.
Và khi cần chắc chắn, ReentrantLock.tryLock(timeout) cho bạn bỏ cuộc thay vì chờ mãi. synchronized không có lựa chọn đó.
synchronized làm luồng ảo chậm gấp bốn
Đây là phần liên quan trực tiếp tới Java 21, và là lý do mục trước không chỉ là chuyện phong cách.
64 luồng ảo, mỗi luồng một khoá riêng — nghĩa là không có tranh chấp nào cả — mỗi luồng ngủ 300 ms bên trong khoá của mình. Máy có 16 nhân. Không ghim thì tổng thời gian phải xấp xỉ 300 ms.
synchronized : 1217 ms (chậm hơn lý tưởng 4,1 lần)
ReentrantLock : 303 ms (1,0 lần)
Và JVM nói thẳng lý do khi bật -Djdk.tracePinnedThreads=short:
VirtualThread[#36]/runnable@ForkJoinPool-1-worker-3 reason:MONITOR
Ghim. Trên Java 21, khi một luồng ảo bị chặn bên trong khối synchronized, nó không nhả được luồng mang (luồng nền tảng đang chở nó). Luồng mang bị giữ nguyên suốt thời gian chờ, nên chỉ 16 luồng ảo chạy được cùng lúc — đúng bằng số nhân. 64 chia 16 là 4 lượt, mỗi lượt 300 ms, ra 1200 ms.
ReentrantLock thì biết cách nhả luồng mang, nên cả 64 luồng ảo chạy đồng thời và tổng thời gian bằng đúng một lần ngủ.
Điều này bào mòn chính lợi ích mà luồng ảo hứa hẹn: bạn tạo được một triệu luồng ảo như bài hôm qua đã đo, nhưng nếu chúng chặn bên trong synchronized thì mức song song thật vẫn bị chặn ở số nhân.
Lời khuyên cho mã chạy trên Java 21: ở những chỗ luồng ảo có thể chặn lâu — gọi mạng, truy vấn CSDL, đợi I/O — hãy dùng ReentrantLock thay cho synchronized. Với khối ngắn thuần tính toán trong bộ nhớ thì synchronized vẫn ổn, vì luồng có chặn đâu mà ghim.
Đây là hạn chế đã biết của bản hiện thực và đang được gỡ ở các phiên bản Java sau — nhưng nếu bạn đang chạy Java 21, nó vẫn đúng hôm nay.
Ba câu để rà lại mã của bạn
Dữ liệu chung này được bảo vệ bằng khoá nào? Trả lời được rõ ràng thì mã đúng. Trả lời "chắc là this" thì nên đọc lại.
Có gì chậm nằm trong khoá không? Lời gọi mạng, truy vấn, đọc tệp — tất cả phải ra ngoài.
Có chỗ nào lấy hai khoá không, và luôn cùng thứ tự chứ? Nếu có, viết thứ tự đó vào comment ngay tại chỗ, vì người sửa sau sẽ không tự đoán ra.
Thử ba mươi giây
Chạy lại chính phép đo mở bài — hai luồng, mỗi luồng dem++ một triệu lần — nhưng chạy năm lần liên tiếp trong một lần khởi động JVM.
Bạn sẽ thấy kết quả nhảy loạn, và ít nhất một lần ra đúng hai triệu. Ba mươi giây đó dạy được điều mà đọc lý thuyết mãi không thấm: mã đa luồng sai vẫn chạy đúng phần lớn thời gian, nên không thể tin vào việc chạy thử — chỉ có thể tin vào việc dữ liệu chung được khoá đầy đủ.
Ngày mai: volatile và mô hình bộ nhớ — vì sao một luồng ghi biến xong mà luồng khác vẫn đọc ra giá trị cũ mãi không thấy đổi.