Một lỗi đua là một vụ va chạm cần hai luồng cùng lọt qua một khe cửa hẹp đúng một khoảnh khắc. Trên máy nhanh, khe cửa ấy hẹp tới mức hai người chạy gần như không bao giờ tới cùng lúc — nên test xanh. Nhưng xanh không có nghĩa cửa an toàn; nó chỉ nghĩa là lần này bạn không trúng. Ném phi tiêu vào một tấm bia có lỗ kim, trượt chín trăm lần không chứng minh cái lỗ không tồn tại. Chặng đồng thời của sê-ri (bài 67 tới 80) lặp đi lặp lại một câu: chạy thử không chứng minh được gì. Bài này đo chính xác câu đó đúng tới mức nào, và làm gì với nó. (Các con số dưới đây chạy 300 lần mỗi ô trên máy tôi; tỷ lệ mới là thứ đáng đọc.)
Một lỗi đua rất kinh điển
class Vi {
private int soDu = 100;
boolean rut(int tien){
if (soDu >= tien) { // kiểm
soDu -= tien; // rồi làm
return true;
}
return false;
}
}
Kiểm-rồi-làm không nguyên tử. Hai luồng cùng rút 100 từ ví có 100 đồng thì cả hai đều thấy đủ tiền, và số dư về âm 100.
Lỗi này có mặt trong mọi lần chạy. Câu hỏi là test bắt được bao nhiêu lần.
Bảng trả lời cả bài
Tôi chạy 300 lần cho mỗi ô, với ba mức "nới khe hở" giữa lệnh kiểm và lệnh làm:
cách 2 luồng 4 luồng 8 luồng
không nới khe 0,0% 0,0% 0,0%
Thread.yield() 0,7% 0,0% 0,0%
sleep(1ms) 99,7% 100,0% 100,0%
Dòng đầu là dòng đáng sợ. Chín trăm lần chạy, không lần nào test đỏ — trong khi lỗi luôn ở đó.
Dòng thứ hai cho thấy Thread.yield() gần như vô dụng: 0,7% với hai luồng, và 0% khi thêm luồng. Trực giác "nhiều luồng hơn thì dễ lộ hơn" sai ở đây — thêm luồng làm mỗi luồng chạy lâu hơn giữa các lần chuyển ngữ cảnh, nên khe hở lại khó trúng.
Dòng thứ ba: thêm đúng một dòng Thread.sleep(1) vào giữa lệnh kiểm và lệnh làm, và lỗi lộ ra gần như mọi lần. Đó là nới cái lỗ kim ra thành cửa lớn.
Vậy làm gì
Ba kỹ thuật, theo thứ tự tôi dùng.
Một: nới khe hở có chủ đích
Chèn điểm dừng vào mã sản xuất thì không được. Nhưng bạn dựng được khe hở từ bên ngoài:
class ViCoMoc extends Vi {
Runnable giuaKiemVaLam = () -> {}; // mặc định không làm gì
@Override boolean rut(int tien){
if (soDu >= tien) { giuaKiemVaLam.run(); soDu -= tien; return true; }
return false;
}
}
// trong test:
vi.giuaKiemVaLam = () -> Thread.sleep(1);
Hoặc gọn hơn: tách phần "kiểm" và phần "làm" thành hai phương thức có thể ghi đè, rồi trong test cho luồng thứ hai chen vào giữa. Bảng ở trên là bằng chứng rằng công này đáng bỏ ra.
Hai: thả các luồng cùng lúc
Tạo luồng rồi start() lần lượt thì luồng đầu đã chạy xong trước khi luồng cuối kịp khởi động. Dùng hai chốt:
var sanSang = new CountDownLatch(soLuong);
var banSung = new CountDownLatch(1);
Runnable r = () -> {
sanSang.countDown();
banSung.await(); // mọi luồng chờ ở đây
viec();
};
// ...
sanSang.await(); // chờ TẤT CẢ đã sẵn sàng
banSung.countDown(); // thả cùng lúc
Trong phép đo của tôi, mọi kịch bản đều dùng khuôn này. Nó không tự làm lộ lỗi, nhưng thiếu nó thì việc nới khe hở cũng vô ích.
Ba: chạy lại nhiều lần
@RepeatedTest(200)
void khongDuocAmSoDu(){ ... }
Rẻ và đáng có. Nhưng bảng đầu bài cho thấy giới hạn của nó: 900 lần chạy vẫn cho 0% nếu khe hở không được nới. Lặp lại là công cụ bổ trợ, không phải công cụ chính.
Cái bẫy thứ hai: Thread.sleep trong khẳng định
Đây là nguồn test chập chờn phổ biến nhất mà tôi gặp, và nó chẳng liên quan gì tới lỗi đua thật.
Một tác vụ nền xong sau 10–150 mili giây. Hai cách chờ nó:
// A. ngủ cố định
Thread.sleep(100);
assertTrue(xong.get());
// B. hỏi lại tới khi đúng, có hạn
await().atMost(2, SECONDS).until(xong::get);
ngủ cố định 100ms : hỏng 59/200 (29,5%) | tổng chờ 20 095 ms
hỏi lại tới khi ok : hỏng 0/200 ( 0,0%) | tổng chờ 16 334 ms
Cách hỏi lại vừa tin cậy hơn vừa nhanh hơn.
Lý do rất đơn giản khi nhìn ra: ngủ cố định luôn sai theo cả hai chiều. Ngủ ít hơn thời gian thật thì test đỏ oan; ngủ nhiều hơn thì bạn phí thời gian ở mọi lần chạy. Không có con số nào đúng, vì thời gian thật thay đổi theo máy và theo tải.
Hỏi lại thì trả về ngay khi điều kiện đúng, và chỉ tốn hạn chờ tối đa trong trường hợp thật sự hỏng.
Thư viện Awaitility gói sẵn việc này:
await().atMost(2, SECONDS).pollInterval(10, MILLISECONDS).until(() -> kho.dem() == 5);
Và một quy tắc gọn: thấy Thread.sleep trong test là thấy một test chập chờn đang chờ ngày ra mắt. Ngoại lệ duy nhất là khi bạn cố ý nới khe hở như mục trên.
Khi cần chắc chắn hơn: jcstress
Với mã đụng tới mô hình bộ nhớ — volatile, khoá không chặn, xuất bản an toàn như bài 69 và 70 — thì những kỹ thuật trên vẫn chưa đủ. Chúng đo trên một máy, một kiến trúc, một lịch trình.
jcstress là công cụ của chính nhóm JDK cho việc này. Bạn khai một trạng thái chung và các "diễn viên", nó chạy hàng chục triệu lần với đủ kiểu xen kẽ, rồi liệt kê mọi kết quả quan sát được kèm số lần:
@JCStressTest
@Outcome(id = "1, 1", expect = ACCEPTABLE, desc = "cả hai thấy giá trị mới")
@Outcome(id = "0, 0", expect = FORBIDDEN, desc = "sắp xếp lại lệnh")
@State
public class ThuNghiem {
int x, y;
@Actor public void mot(II_Result r){ x = 1; r.r1 = y; }
@Actor public void hai(II_Result r){ y = 1; r.r2 = x; }
}
Đây chính là phép thử Dekker ở bài 69, nơi tôi quan sát được 10 lần "không thể xảy ra" trên 2 triệu. Với jcstress, con số đó ra tự động kèm phân loại.
Nó nặng và chậm, nên tôi chỉ dùng khi viết cấu trúc dữ liệu đồng thời. Nhưng nếu bạn đang viết thứ đó, đây là công cụ duy nhất đáng tin.
Bốn quy tắc rút ra
Test đồng thời xanh không phải bằng chứng. Nó chỉ là bằng chứng bạn chưa trúng khe hở. Tính đúng đắn phải lập luận ra, và test chỉ là lưới an toàn.
Nới khe hở có chủ đích, nếu không bạn đang chạy 900 lần để không bắt được gì.
Không bao giờ Thread.sleep để chờ kết quả bất đồng bộ. Hỏi lại có hạn, luôn luôn.
Test chập chờn phải sửa hoặc xoá, không được chạy lại cho qua. Cấu hình "thử lại 3 lần" trên CI là cách nhanh nhất để mất lòng tin vào cả bộ test — và một ngày nào đó nó sẽ giấu đi một lỗi thật.
Và điều tôi tin nhất sau cả chặng đồng thời: cách rẻ nhất để test mã đồng thời là đừng viết nó. Dùng BlockingQueue thay vì wait/notify, ConcurrentHashMap thay vì tự khoá, ExecutorService thay vì tự quản lý luồng. Những thứ đó đã được kiểm bằng jcstress bởi người viết ra chúng — bạn thừa hưởng công việc ấy miễn phí.
Muốn tự thấy khoảng cách 0%–100% trong ba mươi giây: lấy một test đồng thời trong dự án và chạy nó 200 lần liên tiếp bằng @RepeatedTest(200). Nếu nó xanh cả 200 lần, đừng vội mừng — hãy thêm một Thread.sleep(1) vào đúng giữa hai bước bạn nghĩ là nguyên tử rồi chạy lại. Khác biệt giữa hai lần chạy đó có thể là 0% và 100%, trên cùng một đoạn mã, với cùng một lỗi nằm sẵn ở đó.
Mẫu số chung
Cái lỗ kim mà phi tiêu hiếm khi trúng không phải chuyện riêng của Java — mọi ngôn ngữ có luồng đều có lỗi đua, và điều đáng học là chúng phát hiện lỗi đó bằng những cách rất khác nhau. Go có thứ Java thiếu: bộ dò đua dựng sẵn, chạy go test -race là nó cài đặt đo mọi lần truy cập bộ nhớ và báo lỗi đua kể cả khi test không trúng khe hở — cách tiếp cận mạnh hơn hẳn "ném phi tiêu", vì nó không cần trúng lỗ kim mới thấy. C++ và Rust có ThreadSanitizer (TSan) làm đúng vậy. Còn Java thì đi hướng vét cạn với jcstress — chạy đủ mọi kiểu xen kẽ rồi liệt kê mọi kết quả. Cái bẫy sleep thì ở đâu cũng có: JavaScript có "fake timers" và waitFor thay cho setTimeout cứng, Python có polling thay cho time.sleep trong test.
Điểm chung, và là thứ đáng mang theo, gồm ba ý mà nghề nào cũng phải học. Một — một test đồng thời xanh không chứng minh gì, vì nó chỉ lấy mẫu một trong vô số cách xen kẽ có thể; tính đúng đắn của mã đồng thời phải lập luận ra, còn test chỉ là lưới. Hai — muốn thấy lỗi đua thì phải chủ động, bằng một trong ba cách: nới khe hở (chèn sleep vào đúng chỗ), vét cạn cách xen kẽ (jcstress), hoặc đo mọi truy cập bộ nhớ (bộ dò đua của Go, TSan) — và nếu ngôn ngữ bạn dùng có bộ dò đua sẵn, hãy bật nó, đó là món quà lớn nhất. Ba — đừng bao giờ sleep để chờ trong test: hỏi lại có hạn vừa nhanh hơn vừa ổn định hơn ở mọi ngôn ngữ. Và bao trùm tất cả là bài học rẻ nhất: cách tốt nhất để test mã đồng thời là dùng lại thứ người khác đã test bằng những công cụ này, thay vì tự viết.
Ngày mai: đóng gói ứng dụng Java vào Docker — build nhiều tầng, chọn ảnh nền, jlink để ảnh nhỏ lại, và đặt tham số JVM cho đúng trong container.