Visibility: vì sao thread không thấy thay đổi của nhau
Visibility trong Java Memory Model: vì sao reordering và caching khiến thread đọc stale data mãi mãi, và volatile khôi phục bảo đảm đó bằng happens-before ra sao.
TL;DR: Visibility là một trong hai vấn đề mà shared mutable state gây ra (vấn đề còn lại là atomicity), và nó độc lập hoàn toàn với vấn đề kia. Ngay cả khi không ai chen ngang giữa chừng, một thread ghi giá trị mới vẫn có thể không bao giờ hiển thị với thread khác: thiếu đồng bộ hóa, Java Memory Model cho phép compiler, JIT và CPU cache giá trị trong từng core và sắp xếp lại thao tác bộ nhớ. Hậu quả là stale data, thậm chí vòng lặp vô tận. Từ khóa volatile sửa được visibility — nhưng hoàn toàn không làm cho một thao tác trở nên nguyên tử.
1. Đồng bộ hóa không chỉ để loại trừ lẫn nhau
Bài trước chỉ ra vấn đề thứ nhất của shared mutable state: compound action như count++ và check-then-act bị thread khác chen ngang giữa chừng. Bài này mổ vấn đề thứ hai — và nó độc lập hoàn toàn.
TicketFlow đóng cổng bán vé lúc 20:00:03 — admin gọi closeSales(), cờ salesOpen chuyển thành false. Vậy mà log cho thấy trong 90 giây tiếp theo, bốn worker thread vẫn phát ra thêm 137 vé. Không thread nào chen ngang thread nào; không compound action nào bị xé giữa chừng. Chúng chỉ đơn giản không nhìn thấy giá trị mà admin vừa ghi. Học xong bài này, bạn giải thích được vì sao 90 giây đó là hành vi hợp lệ của Java Memory Model, chứ không phải bug của JVM.
Atomicity nói về việc một cụm thao tác có bị thread khác chen ngang giữa chừng hay không. Visibility nói về một câu hỏi cơ bản hơn: khi một thread ghi vào một biến, liệu thread khác có thực sự nhìn thấy giá trị mới đó không, và khi nào? Có một quan niệm sai phổ biến rằng đồng bộ hóa chỉ để loại trừ lẫn nhau, chỉ để lo atomicity. Nó còn một vai trò thứ hai, tinh vi và hay bị bỏ quên: bảo đảm visibility.
Hãy neo chuyện này vào một hình ảnh quen. Một công ty có trụ sở và nhiều chi nhánh. Mỗi chi nhánh giữ một bảng tin riêng để nhân viên tra cứu cho nhanh, thay vì gọi điện về trụ sở mỗi lần cần một con số. Khi trụ sở đổi chính sách, thông tin không tức khắc xuất hiện trên bảng tin của mọi chi nhánh: nó chỉ được chép xuống khi có người mang đi, và cho tới lúc đó nhân viên chi nhánh vẫn đọc bản cũ một cách hoàn toàn thành thật.
| Trong ví von | Trong máy |
|---|---|
| Trụ sở | Bộ nhớ chính (main memory) |
| Bảng tin của từng chi nhánh | Cache riêng của từng CPU core |
| Nhân viên đọc bảng tin chi nhánh | Thread đọc biến qua cache của core nó đang chạy |
| Chính sách mới chưa được chép xuống | Giá trị đã ghi nhưng chưa flush khỏi cache |
| Trưởng phòng sắp xếp lại thứ tự việc cho hiệu quả | Compiler/JIT/CPU reorder các lệnh đọc-ghi |
| Một cuộc gọi bắt buộc về trụ sở trước khi trả lời | Đọc/ghi một biến volatile |
2. Vì sao thread không nhìn thấy thay đổi của nhau?
Trực giác nói các thread dùng chung bộ nhớ thì tất nhiên thấy thay đổi của nhau. Trực giác đó sai, và cái sai này phản trực giác đến mức cần một ví dụ tối giản để tin.
Trước khi đọc tiếp, hãy đoán: chương trình dưới đây in ra gì, và nó có luôn luôn dừng không? Viết câu trả lời của bạn ra giấy rồi mới đối chiếu với phần giải thích ngay sau đoạn code.
public class NoVisibility {
private static boolean ready;
private static int number;
private static class Reader extends Thread {
public void run() {
while (!ready) Thread.yield();
System.out.println(number);
}
}
public static void main(String[] args) {
new Reader().start();
number = 42;
ready = true;
}
}
Tưởng chừng chương trình này chắc chắn in ra 42. Thực tế nó có thể in ra 0, hoặc không bao giờ dừng. Thứ nhất, không có gì bảo đảm giá trị ready = true mà main ghi sẽ đến lúc nào đó hiển thị với thread Reader; Reader có thể lặp mãi mãi. Thứ hai, kỳ lạ hơn, Reader có thể thấy ready thành true trước khi thấy number thành 42, rồi in ra 0. Đó là reordering — sắp xếp lại thứ tự thực thi.
Đây không phải lỗi. Khi không đồng bộ hóa, Java Memory Model cho phép compiler, JIT và CPU sắp xếp lại các thao tác đọc/ghi, và giữ giá trị trong register thay vì đọc lại từ bộ nhớ, miễn là kết quả nhìn từ bên trong một thread đơn lẻ không đổi. Cụ thể với NoVisibility: JIT được phép nâng ready ra khỏi vòng lặp, biến while (!ready) thành if (!ready) while (true) — Reader không bao giờ đọc lại biến nữa. Ở phía ghi, giá trị main vừa ghi có thể còn nằm trong store buffer của core, chưa hiển thị với core khác.
Một điểm dễ hiểu nhầm: cache L1 của các core hiện đại là coherent (giao thức MESI), nên "cache chưa kịp đẩy xuống RAM" không phải nguyên nhân. Bảo đảm mà bạn có là bảo đảm của JMM, phát biểu bằng happens-before — không phải của phần cứng.
Hai nguyên nhân độc lập, hai kết cục khác nhau. Trong sơ đồ dưới, nét đứt luôn có đúng một nghĩa: điều này có thể không xảy ra.
flowchart TB
W1["main ghi number = 42"] --> W2["main ghi ready = true"]
W2 -.->|"JMM cho phép ready tới trước number"| W1
W1 --> H["Reader: ready bị JIT giữ trong register"]
W2 --> V["Reader: thấy ready = true<br/>nhưng number vẫn là 0"]
H -.->|"giá trị mới có thể không bao giờ tới"| R1["lặp vô tận"]
V --> R2["in ra 0"]Nhánh trên là caching: Reader không đọc lại biến nữa nên không bao giờ thấy ready đổi. Nhánh dưới là reordering: Reader thấy ready trước number. Hai đường hoàn toàn tách biệt, và cùng một gốc — không có quan hệ happens-before nào bắt chúng phải theo thứ tự nào cả.
Tất cả là để khai thác tối đa hiệu năng phần cứng đa nhân hiện đại. Cái giá là: mọi nỗ lực suy luận về thứ tự các thao tác bộ nhớ trong một chương trình đa luồng thiếu đồng bộ hóa gần như chắc chắn sẽ sai.
Hậu quả trực tiếp là stale data — dữ liệu cũ. Tệ hơn, sự cũ này không phải tất-cả-hoặc-không-gì: một thread có thể thấy giá trị mới của biến này nhưng giá trị cũ của biến kia. Một bộ đếm tải lệch chút thì không sao, nhưng stale data trên một tham chiếu — ví dụ con trỏ next trong một linked list — có thể gây exception bất ngờ, cấu trúc dữ liệu hỏng, hoặc vòng lặp vô tận.
3. volatile: chỉ bảo đảm visibility
Có một lối thoát đơn giản cho riêng vấn đề visibility: từ khóa volatile. Khi một field là volatile, compiler và runtime được báo rằng biến này dùng chung. Các thao tác trên nó không bị reorder tùy tiện với các thao tác bộ nhớ khác, và nó không bị cache ở nơi khuất tầm các core khác.
Cần diễn đạt bảo đảm này cho chính xác, vì khái niệm "giá trị mới nhất" rất trơn trượt trong concurrency: không có một đồng hồ thời gian thực toàn cục mà mọi thread đều đồng ý, trừ khi đã có quan hệ đồng bộ hóa cụ thể. Điều volatile bảo đảm là: một lần đọc volatile không được lấy một giá trị cache tùy tiện như đọc thường, mà phải tôn trọng thứ tự đồng bộ hóa của các thao tác volatile trên cùng biến đó. Một lần ghi vào biến volatile happens-before mọi lần đọc sau đó của chính biến đó theo thứ tự ấy.
Quan trọng hơn, hiệu ứng này lan ra ngoài chính biến volatile. Khi A ghi một biến volatile rồi B đọc cùng biến đó và thấy giá trị A ghi, thì mọi ghi mà A thực hiện trước lúc ghi volatile cũng trở nên hiển thị với B. Đây chính là quan hệ happens-before. Áp vào NoVisibility: sau khi main ghi ready = true (volatile) và Reader đọc thấy ready == true, Reader cũng chắc chắn thấy number == 42 đã được ghi trước đó. Sửa lỗi do đó chỉ cần một từ khóa:
private static volatile boolean ready; // number duoc "an theo" happens-before cua ready
Một ví dụ dùng đúng kiểu của volatile là cờ trạng thái — chính là công tắc đã để lọt 137 vé ở đầu bài:
private volatile boolean salesOpen = true; // admin ghi, nhieu worker doc
public void closeSales() { salesOpen = false; }
public boolean isOpen() { return salesOpen; }
Nhưng phải nhớ kỹ giới hạn của volatile: nó chỉ lo visibility, hoàn toàn không lo atomicity. Một volatile int count mà count++ thì vẫn dính race read-modify-write y như cũ, vì ++ là ba thao tác và volatile không gói chúng thành khối nguyên tử. Bạn chỉ được dùng volatile khi cả ba điều kiện sau cùng đúng:
- Lệnh ghi vào biến không phụ thuộc giá trị hiện tại của chính nó, hoặc chỉ một thread duy nhất ghi; và
- Biến không tham gia invariant nào với biến trạng thái khác; và
- Không cần khóa vì bất kỳ lý do nào khác khi truy cập biến.
salesOpen thỏa cả ba: lệnh ghi là một hằng (false), không đọc giá trị cũ, nên dù nhiều admin thread cùng gọi closeSales() vẫn hợp lệ. Ngược lại, sold của BookingService vi phạm điều kiện đầu vì sold + 1 đọc chính giá trị cũ. Và cặp sold cùng remaining trong ví dụ CachingBookingService ở bài Atomicity vi phạm điều kiện hai, vì invariant sold + remaining == capacity ràng buộc hai biến mutable với nhau — volatile vô dụng với cả hai trường hợp.
Còn một mặt nữa của bộ nhớ mà volatile chạm tới: biến 64-bit long và double không volatile có thể bị torn read/write, tức đọc ra một giá trị chưa ai từng ghi. Đó là chuyện về tính nguyên tử của một lần truy cập đơn lẻ, và volatile cũng là cách vá nó — xem bài volatile & synchronized.
Checkpoint
- Không đồng bộ hóa thì không có bảo đảm một thread thấy thay đổi của thread khác — đó là stale data, do reordering và caching.
volatilelo visibility và thứ tự bộ nhớ, nhưng không biến read-modify-write thành nguyên tử. Chỉ dùng cho cờ trạng thái độc lập.- "Giá trị mới nhất" không có nghĩa nếu chưa có quan hệ đồng bộ hóa:
volatilebảo đảm happens-before, không bảo đảm một đồng hồ toàn cục.
4. Liên hệ các bài khác
- Atomicity — vấn đề thứ nhất. Đọc lại cạnh bài này để thấy vì sao
volatilevá được cái này mà không vá được cái kia. - Thread safety — nơi đặt ra hai vấn đề độc lập; bài này giải quyết vấn đề thứ hai.
- Confinement — chiến lược né cả hai vấn đề bằng cách triệt tính "shared".
- Immutability — triệt tính "mutable", và dựng nốt nền safe publication mà bài này mới chạm tới.
- volatile & synchronized — bản đồ happens-before đầy đủ của JMM, và cơ chế
synchronizedlo cả visibility lẫn atomicity trong một lần.
5. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JLS §17.4.5 — Happens-before Order — nơi định nghĩa quan hệ happens-before mà
volatiledựa vào. - JLS §17.4.4 — Synchronization Order — thứ tự đồng bộ hóa mà mọi thao tác volatile trên cùng một biến phải tôn trọng.
- Java Concurrency in Practice (Goetz et al.), chương 3 — nguồn gốc của ví dụ
NoVisibilityvà ba điều kiện dùngvolatile.
Ghi chú: Đọc §17.4.5 để thấy happens-before là một quan hệ thứ tự bộ phận, không phải "xảy ra trước theo đồng hồ". Hai thao tác không có quan hệ happens-before thì không có bất kỳ bảo đảm nào về việc bên nào thấy bên nào — kể cả khi bạn đo được rằng chúng cách nhau vài giây.
6. Tóm tắt
- Visibility tách rời atomicity: thiếu đồng bộ hóa, JMM cho phép reorder và cache nên thread có thể đọc stale data mãi mãi.
NoVisibilitycó thể in 0 hoặc lặp vô tận — cả hai đều bắt nguồn từ việc thiếu happens-before, hiện thực hoá qua reordering và qua việc giữ giá trị trong register. Cả hai đều là hành vi hợp lệ, không phải bug JVM.- JMM chỉ bảo toàn kết quả nhìn từ bên trong một thread đơn lẻ; suy luận về thứ tự bộ nhớ giữa các thread mà không đồng bộ hóa gần như chắc chắn sai.
- Ghi vào biến volatile happens-before mọi lần đọc sau đó, và kéo theo mọi ghi trước đó của writer cũng hiển thị.
volatilecho visibility và thứ tự bộ nhớ, không cho atomicity. Chỉ dùng khi ghi không phụ thuộc giá trị cũ hoặc chỉ một writer; biến không tham gia invariant với biến trạng thái khác; và không cần khóa vì lý do nào khác.
Đến đây ta đã có đủ hai vấn đề nền tảng: atomicity và visibility. Bài kế tiếp bắt đầu đi qua bốn chiến lược, từ hướng dễ và an toàn nhất: nếu được, đừng chia sẻ gì cả — thread confinement.
7. Tự kiểm tra
Q1Chương trình NoVisibility có thể in 0 hoặc không bao giờ dừng. Giải thích cơ chế cho từng khả năng.▸
Q2volatile giải quyết được vấn đề nào và bất lực trước vấn đề nào? Vì sao volatile int count với count++ vẫn sai?▸
Q3Trong NoVisibility, chỉ cần đánh dấu ready là volatile thì number cũng được nhìn thấy đúng, dù number không volatile. Cơ chế nào cho phép điều đó?▸
Q4Vì sao nói 'giá trị mới nhất' là một khái niệm trơn trượt trong concurrency? volatile bảo đảm chính xác điều gì thay cho nó?▸
Q5Cờ salesOpen được nhiều admin thread cùng gọi closeSales() để ghi false. Nhiều writer như vậy có phá vỡ điều kiện dùng volatile không?▸
Q6Một cache cấu hình được một thread admin ghi lại toàn bộ (thay object mới), và hàng trăm worker thread chỉ đọc. Dùng volatile cho tham chiếu cache có đủ không? Đối chiếu với ba điều kiện.▸
Bài tiếp theo: Confinement — thread safety bằng cách không chia sẻ
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên