Hình dung hai người làm việc chung, nhưng mỗi người có một cuốn sổ tay riêng và chỉ thỉnh thoảng mới chép lại lên tấm bảng chung giữa phòng. Người A ghi "dừng lại" vào sổ của mình rồi đinh ninh người B đã biết. Nhưng B chỉ nhìn sổ của B — và không ai bảo B rằng đến lúc phải ngó lên bảng. Đó gần như đúng cách mỗi nhân CPU làm việc với cache riêng của nó, và là lý do một luồng ghi xong một biến mà luồng khác có thể không bao giờ thấy. Không phải thấy muộn. Là không bao giờ.
Bài hôm qua nói synchronized bảo vệ dữ liệu khỏi bị sửa đồng thời. Nhưng đồng thời chỉ là một nửa vấn đề. Nửa còn lại lạ hơn nhiều: một luồng ghi xong rồi, mà luồng khác vẫn không thấy.
Không phải thấy muộn. Là không bao giờ thấy.
Cờ dừng không bao giờ được nhìn thấy
Mẫu mã này xuất hiện trong gần như mọi ứng dụng có luồng nền:
static boolean chay = true; // thiếu volatile
// luồng con
while (chay) { n++; }
// luồng chính, sau 1 giây
chay = false;
luồng chính đã đặt chay=false, chờ luồng con tối đa 3 giây...
>>> LUỒNG CON VẪN CHẠY: nó không bao giờ thấy chay=false
Tôi chạy hai lần trong hai JVM riêng, cả hai lần đều vậy. Không phải chuyện xác suất — với vòng lặp chặt như trên, nó hỏng gần như chắc chắn.
Thêm volatile:
static volatile boolean chay = true;
luồng con đã dừng sau 3.638.774.541 vòng
dừng sau 1,8 ms kể từ lúc đặt cờ, còn sống? false
Ba tỷ sáu vòng lặp, rồi dừng sau chưa tới hai mili giây. Khác biệt là một từ khoá.
Vì sao lại thế
Có hai thủ phạm, và cả hai đều hợp lệ.
Bộ biên dịch JIT. Nó thấy chay không đổi bên trong vòng lặp, nên đọc một lần vào thanh ghi rồi lặp trên thanh ghi đó. Xét trên một luồng thì tối ưu này hoàn toàn đúng — và JIT được phép giả định như vậy trừ khi bạn nói ngược lại.
Bộ nhớ đệm của CPU. Mỗi nhân có cache riêng. Luồng chính ghi false vào cache của nhân nó; luồng con đọc từ cache của nhân khác. Phần cứng có giao thức đồng bộ, nhưng Java không hứa khi nào việc đó xảy ra nếu bạn không yêu cầu.
Điểm mấu chốt: đây không phải lỗi của JVM. Mô hình bộ nhớ Java cho phép cả hai, vì cấm chúng thì mọi chương trình đơn luồng đều chậm đi. Đổi lại, bạn được cho những công cụ để nói "chỗ này thì đừng".
Sắp xếp lại lệnh: quan sát được, không chỉ là lý thuyết
Phần này tôi vẫn nghĩ là chuyện sách vở cho tới khi đo được nó.
Hai luồng chạy song song, mỗi luồng ghi một biến rồi đọc biến kia:
// luồng 1 // luồng 2
x = 1; y = 1;
r1 = y; r2 = x;
Thử liệt kê mọi thứ tự chạy có thể: dù xen kẽ kiểu gì, ít nhất một trong hai luồng cũng phải chạy lệnh ghi trước lệnh đọc của luồng kia. Nên r1 == 0 && r2 == 0 là kết quả không thể xảy ra.
Hai triệu lần thử, trên máy aarch64:
lần 21.835: r1=0 VÀ r2=0
lần 601.538: r1=0 VÀ r2=0
lần 641.023: r1=0 VÀ r2=0
biến thường : 10 / 2.000.000 lần cho kết quả 'không thể' (0,0005%)
biến volatile: 0 / 2.000.000 lần (0,0000%)
Mười lần. Kết quả mà lập luận logic nói là không thể, xảy ra thật.
Vì bộ xử lý và bộ biên dịch được phép đổi thứ tự hai lệnh không phụ thuộc nhau. Trong một luồng, x = 1 và r1 = y chẳng liên quan gì tới nhau, nên đảo chúng không đổi kết quả — xét trên luồng đó. Từ luồng kia nhìn sang thì đổi hẳn.
Chú ý tỷ lệ: 0,0005%. Một lỗi xuất hiện năm lần trên một triệu sẽ không bao giờ bị bắt bởi test, và sẽ xuất hiện vài lần mỗi ngày trên một hệ thống có tải thật.
Chú ý thêm dòng cuối: cùng phép thử ấy với volatile cho 0 trên 2 triệu.
Một lưu ý về môi trường đo: con số sắp-xếp-lại quan sát được phụ thuộc rất mạnh vào kiến trúc CPU — aarch64 (ARM) có mô hình bộ nhớ lỏng hơn x86 nhiều, nên cùng đoạn mã này trên một máy x86 có thể cho 0 lần "không thể" mà vẫn sai về lý thuyết. Đừng đọc nó là "x86 an toàn hơn"; đọc nó là "x86 giấu lỗi kỹ hơn".
volatile hứa gì
Hai điều, và chỉ hai điều:
Nhìn thấy được. Ghi một biến volatile thì mọi luồng đọc sau đó thấy ngay giá trị mới. Không đọc từ thanh ghi, không đọc từ cache cũ.
Không bị sắp xếp lại vượt qua. Mọi lệnh viết trước lời ghi volatile sẽ thật sự xảy ra trước nó, và luồng nào đọc được giá trị mới ấy cũng thấy được tất cả những gì viết trước đó. Đây là quan hệ xảy-ra-trước, khái niệm trung tâm của mô hình bộ nhớ Java.
Điều thứ hai mạnh hơn vẻ ngoài. Nó cho phép mẫu này chạy đúng:
duLieu = tinhToanNangNe(); // biến thường
sanSang = true; // volatile — ghi SAU
Luồng khác thấy sanSang == true thì chắc chắn thấy cả duLieu đã hoàn chỉnh, dù duLieu không hề volatile. Lời ghi volatile như một cái chốt: mọi thứ trước nó được đẩy sang cùng.
Đảo thứ tự hai dòng đó là hỏng hẳn.
volatile không hứa gì
Đây là hiểu nhầm phổ biến nhất. volatile int với ++:
lần 1: volatile int -> 602.566 AtomicInteger -> 1.000.000 (mong đợi 1.000.000)
lần 2: volatile int -> 630.059 AtomicInteger -> 1.000.000
lần 3: volatile int -> 520.640 AtomicInteger -> 1.000.000
Mất gần một nửa số lần cộng.
Vì volatile lo chuyện nhìn thấy, không lo chuyện nguyên tử. dem++ vẫn là ba bước đọc–cộng–ghi như bài hôm qua; volatile chỉ đảm bảo bước đọc lấy được giá trị mới nhất, chứ không ngăn luồng khác chen vào giữa ba bước ấy.
volatile đúng khi một luồng ghi và các luồng khác chỉ đọc (cờ dừng, tham chiếu cấu hình được thay nguyên khối). Sai ngay khi giá trị mới phụ thuộc giá trị cũ — đếm, cộng dồn, kiểm-rồi-làm. Những chỗ đó cần lớp Atomic* hoặc khoá.
Giá của nó
Một luồng, một trăm triệu lần cộng:
biến thường : 4 ms
biến volatile : 176 ms
AtomicLong : 176 ms
LongAdder : 444 ms
Con số 4 ms cần giải thích cho đúng, kẻo hiểu sai: nó không có nghĩa phép cộng thường nhanh gấp bốn mươi lần. Nó có nghĩa JIT đã gộp cả trăm triệu lần cộng vào rất ít lệnh — vì trên một biến thường, nó được phép làm vậy. volatile cấm chính việc đó, nên 176 ms mới là chi phí thật của một trăm triệu lần đọc-ghi có bảo đảm.
Nói cách khác, cái đắt không phải bản thân lệnh mà là những tối ưu bạn vừa từ chối.
Và LongAdder chậm nhất bảng — nhưng chỉ khi một luồng. Cho nhiều luồng cùng tranh nhau, 5 triệu lần cộng mỗi luồng:
1 luồng: AtomicLong 13 ms | LongAdder 23 ms
4 luồng: AtomicLong 171 ms | LongAdder 42 ms
8 luồng: AtomicLong 1123 ms | LongAdder 24 ms
Tám luồng: 1123 ms so với 24 ms, gần năm mươi lần.
AtomicLong bắt mọi luồng tranh nhau đúng một ô nhớ, nên càng nhiều luồng càng tệ — thời gian tăng gần mười lần khi đi từ 1 lên 8 luồng. LongAdder giữ nhiều ô riêng rồi cộng lại lúc sum(), nên hầu như không bị ảnh hưởng.
Chọn thế nào: cần đếm và chỉ đọc tổng thi thoảng (đếm request, đếm lỗi, số liệu giám sát) thì LongAdder. Cần đọc giá trị hiện tại liên tục hoặc cần compareAndSet thì AtomicLong.
Khoá đôi lần kiểm tra
Đây là chỗ volatile từng là chủ đề của cả một bài báo học thuật:
private static volatile KetNoi ins; // volatile là BẮT BUỘC
static KetNoi lay() {
if (ins == null) {
synchronized (KetNoi.class) {
if (ins == null) ins = new KetNoi();
}
}
return ins;
}
Bỏ volatile đi thì mã vẫn biên dịch, vẫn chạy, và hỏng theo cách rất khó tin: một luồng có thể nhận về tham chiếu khác null nhưng trỏ tới đối tượng chưa dựng xong.
Vì ins = new KetNoi() cũng là nhiều bước: cấp bộ nhớ, chạy hàm khởi tạo, gán tham chiếu. Hai bước cuối được phép đảo. Luồng khác chạy tới if (ins == null), thấy khác null, trả về ngay — và cầm một đối tượng mà hàm khởi tạo chưa chạy xong.
Với Java hiện đại, cách đúng thường là tránh hẳn mẫu này: một trường static final khởi tạo trực tiếp, hoặc lớp giữ chỗ bên trong — JVM đảm bảo lớp chỉ được nạp một lần, và bạn không phải suy nghĩ gì thêm.
Trường final được bảo đảm riêng
Một điểm ít người biết: trường final được gán trong hàm khởi tạo có bảo đảm riêng của nó. Sau khi hàm khởi tạo xong, mọi luồng thấy được đối tượng đều thấy các trường final đã có giá trị đúng — không cần đồng bộ gì thêm.
Đây là lý do sâu xa vì sao đối tượng bất biến an toàn với đa luồng, và vì sao final không chỉ là chuyện phong cách. Nhưng bảo đảm này mất hiệu lực nếu hàm khởi tạo để lọt this ra ngoài trước khi chạy xong — ví dụ đăng ký một listener ngay trong hàm khởi tạo.
Bốn cách tạo quan hệ xảy-ra-trước
Đây là danh sách đáng thuộc, vì mọi mã đa luồng đúng đều dựa vào ít nhất một trong số này:
Ghi rồi đọc một biến volatile.
Nhả rồi lấy cùng một khoá — synchronized hay ReentrantLock. Đây là lý do synchronized cũng giải quyết được vấn đề nhìn thấy, chứ không chỉ vấn đề loại trừ.
Thread.start() — mọi thứ luồng cha làm trước đó đều nhìn thấy được trong luồng con. Và join() theo chiều ngược lại.
Các lớp trong java.util.concurrent — BlockingQueue, CountDownLatch, Future, ExecutorService. Bỏ một đối tượng vào hàng đợi rồi lấy ra ở luồng khác là đã có bảo đảm; đây là lý do dùng chúng thay vì tự dựng bằng wait/notify.
Lời khuyên thật lòng của tôi: hãy dùng cách thứ tư. Mã đa luồng viết bằng volatile và synchronized trần rất khó chứng minh là đúng, còn BlockingQueue hay ExecutorService thì đã có người chứng minh hộ.
Nếu muốn tự tay thấy cái lỗi quỷ quái nhất của chương này, hãy làm thử: chạy đoạn cờ dừng ở đầu bài, không có volatile, rồi thêm -Xint vào lệnh chạy để tắt JIT. Với -Xint nó sẽ dừng bình thường; bỏ -Xint ra thì nó chạy mãi. Ba mươi giây đó cho thấy điều đáng nhớ nhất về mô hình bộ nhớ: mã đa luồng của bạn có thể đúng khi chạy chậm và sai khi chạy nhanh. Trình thông dịch, chế độ gỡ lỗi và máy ít nhân đều che giấu lỗi; máy chủ sản xuất thì không.
Mẫu số chung
Cái bẫy "ghi rồi mà luồng kia không thấy" không phải chuyện riêng của Java — nó là hệ quả tất yếu của phần cứng hiện đại (cache nhiều tầng, CPU xử lý ngoài thứ tự), nên mọi ngôn ngữ cho viết đa luồng đều phải có một câu trả lời. Và so sánh các câu trả lời đó làm sáng ra chính volatile.
- C/C++ phơi bày đầy đủ nhất, tới mức đáng sợ:
std::atomicvới cả một thangmemory_order—relaxed,acquire,release,seq_cst.volatilecủa Java tương đương với cặp acquire/release cộng thứ tự tuần tự; nó chọn sẵn cho bạn mức an toàn nhất, đổi lấy việc không tinh chỉnh được. - Go đặt cược vào một khẩu hiệu: "đừng giao tiếp bằng cách chia sẻ bộ nhớ; hãy chia sẻ bộ nhớ bằng cách giao tiếp". Channel của Go chính là "cách thứ tư" ở trên được nâng lên thành công dân hạng nhất — gửi vào channel và nhận ra thiết lập quan hệ xảy-ra-trước, y như
BlockingQueue. - Rust giải bài toán ở tầng kiểu:
SendvàSynckhiến phần lớn lỗi đua-dữ-liệu không biên dịch được. Cái mà Java để bạn tự kỷ luật (nhớ gõvolatile), Rust biến thành lỗi biên dịch. - C# dùng đúng từ khoá
volatilevới ngữ nghĩa gần Java, cộngInterlockedđóng vaiAtomic*.
Điểm chung đáng mang theo: mỗi ngôn ngữ đều có khái niệm xảy-ra-trước dưới một lớp áo khác nhau, và bài học không đổi — phần cứng không tự đồng bộ giúp bạn, bạn phải nói rõ chỗ nào cần. Java nói bằng volatile và khoá; Go nói bằng channel; Rust bắt trình biên dịch nói hộ. Ba lối, một sự thật: đọc-ghi chung không có điểm đồng bộ là một con bug đang chờ chạy đủ nhanh để lộ ra.
Ngày mai đi sâu vào các lớp Atomic* mà bài này mới chạm tới: so sánh-và-đổi hoạt động ra sao ở mức phần cứng, bài toán ABA, và vì sao LongAdder thắng đậm khi tranh chấp cao.