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ó.
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.
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í.
Thử ba mươi giây
Lấy một test đồng thời trong dự án của bạn và chạy nó 200 lần liên tiếp:
@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 mà bạn nghĩ là nguyên tử, rồi chạy lại. Bảng đầu bài cho thấy 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 ở đó.
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.