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 = 1r1 = 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.

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.

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.

Quy tắc gọn: 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.

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.

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.concurrentBlockingQueue, 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 volatilesynchronized trần rất khó chứng minh là đúng, còn BlockingQueue hay ExecutorService thì đã có người chứng minh hộ.

Thử ba mươi giây

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.

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.