Thread Safety: Viết code đúng khi nhiều thread cùng chạm vào dữ liệu
Shared mutable state là gốc của mọi bug concurrency. Hai vấn đề độc lập — atomicity (read-modify-write, check-then-act) và visibility (JMM, reordering, volatile) — và bốn chiến lược xử lý: confinement, immutability, synchronization, delegation.
TL;DR: Viết concurrent code đúng quy về một việc: quản lý truy cập vào shared mutable state. Hai vấn đề độc lập làm nên cái khó — atomicity (compound action như check-then-act và read-modify-write phải diễn ra trọn vẹn, không ai chen vào giữa) và visibility (thread này phải thực sự nhìn thấy thay đổi của thread kia, điều mà Java Memory Model không hứa nếu thiếu đồng bộ hóa). volatile chỉ lo visibility; atomic class chỉ lo atomicity của một biến — invariant trải trên nhiều biến cần cơ chế mạnh hơn. Và một object thread-safe không làm compound action phía client tự động an toàn: đó là bài học của client-side locking. Bốn chiến lược xử lý: confinement, immutability, synchronization, delegation.
1. Giới thiệu
Bài Process và Thread khép lại bằng một nhận xét quan trọng: thread nhẹ và tiện vì các thread trong cùng một process dùng chung không gian địa chỉ — chung heap, chung object, chung field. Bài Thread API và vòng đời sau đó trang bị công cụ để tạo, dừng và phối hợp thread. Nhưng chính sự dùng chung kia, nếu không được phối hợp cẩn thận, sẽ sinh ra những lỗi khó đoán như race condition. Bài này đi sâu vào đúng mặt đó của thread.
Luận điểm trung tâm có thể nói thẳng ngay từ đầu: viết chương trình concurrent đúng, về cốt lõi, là quản lý việc truy cập vào trạng thái dùng chung và thay đổi được - shared mutable state. Cần để ý cả hai tính từ. "Shared" nghĩa là nhiều thread cùng với tới. "Mutable" nghĩa là giá trị có thể đổi. Một dữ liệu chỉ cần thiếu một trong hai tính chất đó thì phần lớn rắc rối tan biến: dữ liệu không chia sẻ thì không ai tranh, dữ liệu không đổi thì đọc lúc nào cũng cho cùng kết quả. Gần như mọi kỹ thuật trong bài này, xét cho cùng, chỉ là các cách khác nhau để triệt tiêu tính "shared", triệt tiêu tính "mutable", hoặc khi buộc phải giữ cả hai thì canh gác việc truy cập thật cẩn thận.
Một điểm cần làm rõ sớm: yêu cầu thread safety thường không đến từ quyết định "dùng thread" của bạn. Bạn hiếm khi tự tay gọi new Thread(). Nhưng khi bạn viết một servlet, một @RestController, một message listener, hay bất kỳ component nào chạy trong một framework, thì framework đó sẽ gọi code của bạn từ nhiều thread cùng lúc. Trách nhiệm làm cho component an toàn rơi vào tay bạn, ngay cả khi bạn không trực tiếp tạo ra thread nào.
Ta sẽ làm việc với một ví dụ cụ thể: BookingService của capstone TicketFlow, phiên bản v0, được viết để chạy đúng khi chỉ có một thread.
// TicketFlow v0 — single-thread baseline. CHƯA thread-safe (cố ý).
public class BookingService {
private final Map<String, Event> events = new HashMap<>();
private final Map<String, Integer> sold = new HashMap<>();
public void register(Event event) {
Objects.requireNonNull(event);
if (events.putIfAbsent(event.id(), event) != null) {
throw new IllegalArgumentException("Sự kiện đã tồn tại: " + event.id());
}
sold.put(event.id(), 0); // invariant: đã register thì có entry sold
}
public Booking book(String eventId, String userId) {
Event event = events.get(eventId);
if (event == null) {
throw new IllegalArgumentException("Không có sự kiện: " + eventId);
}
int current = sold.getOrDefault(eventId, 0); // (1) đọc số vé đã bán
if (current >= event.capacity()) { // (2) còn chỗ không?
throw new SoldOutException(eventId);
}
sold.put(eventId, current + 1); // (3) ghi lại số vé đã bán
return new Booking(eventId, userId, current + 1);
}
}
Có một invariant nghiệp vụ không được phép vi phạm: số vé bán ra không bao giờ vượt capacity. Khi chỉ một thread gọi book, code này giữ invariant đó hoàn hảo, và mọi test đơn luồng sẽ pass. Nhưng một hệ thống đặt vé thật phải phục vụ hàng nghìn request đồng thời. Câu hỏi cần trả lời, do đó, là: khi hai thread cùng gọi book cho một sự kiện chỉ còn đúng một chỗ, điều gì xảy ra? Trước khi trả lời, ta cần một định nghĩa rõ ràng cho chữ "đúng".
2. Thread safety là gì?
Định nghĩa thread safety khó một cách đáng ngạc nhiên. Các định nghĩa hình thức thì rối đến mức ít giúp được gì, còn các định nghĩa thông thường lại luẩn quẩn, kiểu "một class là thread-safe nếu nó có thể dùng an toàn từ nhiều thread". Câu đó không sai, nhưng cũng không giúp ta phân biệt một class an toàn với một class không.
Cốt lõi của mọi định nghĩa tử tế nằm ở khái niệm correctness - tính đúng đắn. Một class đúng nếu nó tuân thủ đặc tả của nó. Một đặc tả tốt thì gồm các invariant ràng buộc trạng thái của đối tượng và các postcondition mô tả hệ quả của từng thao tác. Với BookingService, invariant là "sold của mỗi sự kiện luôn nằm trong khoảng từ 0 đến capacity", còn postcondition của book là "sau mỗi lần đặt thành công, sold tăng đúng một và seatNumber trả về là duy nhất". Ta thường không viết đặc tả ra giấy, mà chỉ tin là code đúng khi thấy nó chạy đúng. Tạm chấp nhận sự tự tin đó là correctness trong môi trường đơn luồng, ta có thể định nghĩa thread safety bớt luẩn quẩn hơn:
Một class là thread-safe nếu nó tiếp tục hành xử đúng khi được truy cập từ nhiều thread, bất kể runtime lập lịch hay xen kẽ các thread đó như thế nào, và phía code gọi không cần thêm bất kỳ cơ chế đồng bộ hóa nào.
Ba mệnh đề trong định nghĩa này cần đọc kỹ. "Hành xử đúng" nghĩa là invariant và postcondition luôn được giữ. "Bất kể lập lịch hay xen kẽ thế nào" là phần khắc nghiệt nhất: tính đúng phải đứng vững trước mọi khả năng xen kẽ mà scheduler có thể tạo ra, kể cả những khả năng xui xẻo nhất, chứ không phải "thường thì đúng". "Không cần đồng bộ hóa ở phía gọi" nghĩa là gánh nặng an toàn nằm bên trong class. Một class thread-safe đóng gói trọn phần đồng bộ hóa nó cần. Nhưng phải nói ngay một bẫy lớn: thread-safe không có nghĩa là mọi tổ hợp nhiều lời gọi lên object đó cũng tự động atomic. Một Vector thread-safe, nhưng if (!v.contains(x)) v.add(x) thì không; ta sẽ thấy đây là một chủ đề xuyên suốt — ranh giới giữa một object thread-safe và một thao tác thread-safe ở cấp client.
Vì mọi chương trình đơn luồng cũng là một chương trình đa luồng hợp lệ, một class không thể thread-safe nếu nó còn chưa đúng khi chạy một mình. Và đây là hệ quả gọn gàng nhất, cũng là viên gạch đầu tiên: một đối tượng thật sự không có trạng thái - stateless - thì luôn luôn thread-safe.
public final class SeatPriceCalculator {
public long priceFor(long basePrice, int seatNumber) {
return basePrice + (seatNumber <= 10 ? 50_000 : 0); // 10 ghế đầu phụ thu VIP
}
}
Class này không có field nào. Mọi dữ liệu nó cần - basePrice, seatNumber, các biến trung gian - đều sống trên stack của thread đang gọi, mà stack thì riêng cho từng thread. Hai thread cùng gọi priceFor giống như hai thread đang dùng hai đối tượng khác nhau: chúng không chia sẻ gì, nên không có gì để tranh.
Cần kèm một điều kiện vào chữ "stateless". Nó phải nghĩa là: object vừa không giữ state mutable, vừa không có operation nào chạm tới shared mutable state bên ngoài - một static mutable, một singleton không thread-safe, một I/O resource dùng chung, hay một object mutable được truyền vào rồi bị sửa. Một class "trông như stateless" vẫn có thể không an toàn:
class StatelessLooking { // không field, nhưng KHÔNG thread-safe
public void f(List<String> xs) {
if (!xs.contains("a")) xs.add("a"); // check-then-act trên state của caller
}
}
Class này không có field, nhưng nếu nhiều thread truyền vào cùng một List, thao tác này vẫn dính race. Nói cách khác, "stateless" chỉ miễn nhiễm khi nó không tạo ra hay đụng vào shared mutable state ở bất kỳ đâu. Rắc rối thật sự bắt đầu khi đối tượng cần nhớ một thứ gì đó giữa các lần gọi. Ở hai phần tiếp theo, ta thêm state vào và xem hai loại vấn đề độc lập nảy sinh: atomicity và visibility.
3. Atomicity
3.1 Read-modify-write và một lần tăng bị mất
Giả sử ta muốn đo lượng tải bằng cách đếm tổng số lần book được gọi. Phản xạ tự nhiên là thêm một field long và tăng nó mỗi request.
public class CountingBookingService {
private long bookingAttempts = 0; // @NotThreadSafe
public long getAttempts() { return bookingAttempts; }
public Booking book(String eventId, String userId) {
++bookingAttempts; // trông như một thao tác, thực ra là ba
// ... phần còn lại của book ...
}
}
Đoạn này chạy đơn luồng thì hoàn hảo, nhưng không thread-safe, vì ++bookingAttempts không nguyên tử. Dù cú pháp gọn như một hành động, nó là viết tắt cho ba thao tác rời rạc ở mức bytecode lẫn mức CPU: đọc giá trị hiện tại, cộng một, ghi giá trị mới trở lại. Đây là một thao tác read-modify-write, nơi trạng thái kết quả được suy ra từ trạng thái trước đó.
Giả sử bookingAttempts đang là 5 và hai thread cùng tăng. Với một thời điểm xui xẻo, cả hai cùng đọc thấy 5, cả hai cùng cộng thành 6, cả hai cùng ghi 6. Sơ đồ sau vẽ đúng chuỗi xen kẽ đó:
sequenceDiagram
participant A as Thread A
participant M as bookingAttempts
participant B as Thread B
A->>M: read -> 5
B->>M: read -> 5
A->>M: write 6
Note over M: gia tri 6 (lan tang cua A)
B->>M: write 6
Note over M: van la 6 -- lan tang cua A bi ghi de (lost update)Một lần tăng bị mất, và bộ đếm vĩnh viễn lệch đi một. Một bộ đếm tải lệch chút ít nghe có vẻ chấp nhận được, nhưng nếu con số đó dùng để sinh ID hay seatNumber duy nhất, thì hai thực thể khác nhau mang cùng một ID là một lỗi nghiêm trọng về toàn vẹn dữ liệu. Khả năng cho ra kết quả sai do thời điểm xui xẻo quan trọng đến mức nó có một cái tên: race condition.
3.2 Race condition, data race, và mẫu check-then-act
Một race condition xảy ra khi tính đúng đắn của một phép tính phụ thuộc vào thời điểm hoặc thứ tự xen kẽ tương đối của nhiều thread; nói cách khác, khi muốn ra kết quả đúng thì phải gặp may về thời điểm.
Cần phân biệt hai thuật ngữ dễ lẫn. Race condition là khái niệm về tính đúng đắn: kết quả phụ thuộc thời điểm. Data race là một khái niệm hẹp và hình thức hơn, được Java Memory Model định nghĩa chính xác trong JLS §17.4.5: hai truy cập xung đột vào cùng một biến — tức cả hai cùng chạm một vị trí bộ nhớ và ít nhất một bên là ghi — mà giữa chúng không có quan hệ happens-before. Happens-before là quan hệ thứ tự mà JMM dùng để nói "ghi này chắc chắn hiển thị với đọc kia"; ta sẽ gặp nó kỹ hơn ở mục visibility bên dưới. Để ý định nghĩa không nhắc gì tới chuyện biến là final hay non-final — cốt lõi nằm ở việc thiếu happens-before giữa hai truy cập xung đột. Không phải mọi race condition đều là data race, và ngược lại; nhưng cả hai đều khiến chương trình sai theo cách khó lường. book của ta, như sẽ thấy, dính cả hai.
Dạng race condition phổ biến nhất gọi là check-then-act - kiểm tra rồi hành động: ta quan sát một điều kiện là đúng, rồi hành động dựa trên quan sát đó; nhưng giữa lúc quan sát và lúc hành động, một thread khác đã làm cho quan sát kia trở nên sai. Đặc trưng của hầu hết race condition chính là sự vô hiệu hóa của một quan sát: thứ ta vừa thấy đã không còn đúng vào lúc ta hành động dựa trên nó.
BookingService.book chính là một check-then-act điển hình. Sự kiện concert-01 có capacity = 1, đã bán 0 vé, hai thread A và B cùng phục vụ hai khách. Một thứ tự xen kẽ hoàn toàn hợp lệ có thể diễn ra như sau:
Thời điểm Thread A Thread B sold
t1 current = get() → 0 0
t2 current = get() → 0 0
t3 0 >= 1 ? Không → đi tiếp 0
t4 0 >= 1 ? Không → đi tiếp 0
t5 put(concert-01, 1) 1
t6 put(concert-01, 1) 1
t7 return Booking(seat #1) 1
t8 return Booking(seat #1) 1
Cả hai thread cùng đọc current = 0 trước khi bất kỳ ai kịp ghi. Cả hai cùng kết luận "còn chỗ". Cả hai cùng phát ra vé seat #1. Hai khách giữ cùng một chỗ, và sold chỉ ghi nhận 1 dù thực tế đã bán 2. Invariant "không vượt capacity" bị phá vỡ. Điều khiến lỗi này nguy hiểm là nó không phải lúc nào cũng xảy ra: chỉ khi cửa sổ xen kẽ rơi đúng vào giữa bước "kiểm tra" và bước "ghi". Nó pass mọi test đơn luồng, có thể ẩn nhiều tháng, rồi xuất hiện đúng vào lúc tải cao nhất.
Ở đây ta đang tập trung vào race nghiệp vụ của check-then-act. Nhưng cần nói thêm một tầng nữa: riêng việc dùng HashMap cho sold với ghi concurrent mà không đồng bộ đã là sai theo một cách khác. HashMap không thread-safe không cho phép sửa cấu trúc đồng thời; hành vi khi đó là không xác định theo contract của collection - có thể thấy trạng thái nội bộ hỏng chứ không chỉ là lost update. Kịch bản kinh điển hay được kể lại — hai thread cùng kích hoạt resize làm linked list trong bucket bị nối thành vòng, khiến get rơi vào vòng lặp vô tận chiếm 100% CPU — là chuyện của HashMap từ Java 7 trở về trước, nơi resize chèn phần tử theo kiểu đảo ngược thứ tự. Java 8 đổi cách chuyển bucket nên kịch bản nối vòng cụ thể đó không còn, nhưng đừng hiểu nhầm là đã an toàn: ghi đồng thời vẫn làm mất entry, hỏng cấu trúc cây trong bucket, hoặc ném exception bất ngờ — chỉ là hỏng theo cách khác. Nói cách khác, vấn đề không gói gọn trong current + 1.
Để ý rằng book còn chứa cả read-modify-write ở bước đọc current rồi ghi current + 1. Điểm chung của check-then-act và read-modify-write là cả hai đều là compound action - một cụm thao tác nhỏ, nhưng để đúng thì cả cụm phải diễn ra như một khối không thể chia cắt. Tính chất không ai chen được vào giữa chừng đó gọi là atomicity. Nói cho chính xác: hai thao tác A và B là nguyên tử với nhau nếu, từ góc nhìn của thread đang chạy A, khi thread khác chạy B thì hoặc toàn bộ B đã chạy xong, hoặc chưa có phần nào của B chạy, chứ không có trạng thái "B đang dở". Để tránh race condition, mọi check-then-act và read-modify-write trên trạng thái dùng chung phải được làm nguyên tử.
3.3 Khi một biến là đủ, khi nhiều biến thì không
Với bộ đếm độc lập ở mục 3.1, lời giải nhẹ và gọn nằm trong java.util.concurrent.atomic. Thay long bằng AtomicLong:
private final AtomicLong bookingAttempts = new AtomicLong();
// trong book():
bookingAttempts.incrementAndGet(); // đọc-cộng-ghi gói trọn thành một thao tác nguyên tử
incrementAndGet gói trọn read-modify-write bằng một chỉ thị nguyên tử của CPU — compare-and-swap (CAS) hoặc fetch-and-add (LOCK XADD trên x86) tuỳ kiến trúc và tuỳ cách JIT chọn dịch; bài Atomic & CAS sẽ mổ kỹ cơ chế này. Vì state của bộ đếm giờ là state của một đối tượng vốn đã thread-safe, và class không áp thêm ràng buộc nào lên giá trị bộ đếm, cả class trở nên thread-safe. Quy tắc rút ra: ở đâu khả thi, hãy dùng các đối tượng thread-safe có sẵn như AtomicLong để quản lý state, vì suy luận về một đối tượng đã được kiểm chứng bao giờ cũng dễ hơn tự dựng giao thức khóa. (Một lưu ý thực dụng cho bộ đếm thống kê dưới contention cao: nếu chỉ cần throughput và chấp nhận đọc xấp xỉ trong lúc đang cập nhật, LongAdder thường nhanh hơn AtomicLong; còn nếu cần kết quả từng bước chính xác và linearizable thì AtomicLong đúng hơn.)
Nhưng đi từ không biến lên một biến thì dễ, còn đi từ một biến lên nhiều biến thì không đơn giản như vậy. Hãy thêm một tính năng thật: một dashboard cần đọc số chỗ còn lại của mỗi sự kiện thật nhanh, nên ta cache nó thành một biến riêng bên cạnh sold.
public class CachingBookingService { // @NotThreadSafe — đừng làm thế này
private final AtomicInteger sold = new AtomicInteger(0);
private final AtomicInteger remaining = new AtomicInteger(/* capacity */ 100);
// INVARIANT: sold + remaining == capacity (luôn luôn)
public Booking book(String eventId, String userId) {
if (remaining.get() <= 0) throw new SoldOutException(eventId); // check ...
int seat = sold.incrementAndGet(); // ... rồi act, không nguyên tử
remaining.decrementAndGet();
return new Booking(eventId, userId, seat);
}
}
Cả sold lẫn remaining đều là AtomicInteger, mỗi thao tác lẻ trên chúng đều nguyên tử. Vậy mà class vẫn không thread-safe, vì hai lý do. Thứ nhất, phần if (remaining.get() <= 0) rồi mới incrementAndGet là một check-then-act không nguyên tử, y hệt mục 3.2 - nhiều thread cùng qua được phần kiểm tra rồi cùng tăng, bán vượt capacity. Thứ hai, có một invariant ràng buộc hai biến với nhau: sold + remaining luôn phải bằng capacity. Giữa lệnh incrementAndGet và decrementAndGet tồn tại một cửa sổ, dù chỉ vài nano giây, trong đó sold đã tăng còn remaining chưa giảm; một thread khác đọc đúng vào cửa sổ đó sẽ thấy invariant bị vi phạm.
Khi nhiều biến cùng tham gia một invariant, chúng không độc lập: giá trị của biến này giới hạn giá trị hợp lệ của biến kia, nên các biến đó phải được cập nhật trong cùng một thao tác nguyên tử. Nói gọn thành một nguyên tắc đáng nhớ: atomic variable cho bạn atomicity của một biến, không phải atomicity của invariant trong object của bạn. Các AtomicInteger riêng lẻ không gói nổi hai lần ghi thành một khối. Ta cần một cơ chế mạnh hơn, có thể quây cả cụm thao tác lại; cơ chế đó là khóa - chủ đề của nhóm bài về synchronization.
Checkpoint
- Nếu một thao tác tạo giá trị mới từ giá trị cũ, hãy nghi ngờ read-modify-write.
- Nếu một thao tác kiểm tra một điều kiện rồi hành động dựa trên nó, hãy nghi ngờ check-then-act.
- Nếu nhiều biến cùng tạo nên một invariant, atomic class riêng lẻ là chưa đủ - cả cụm phải được cập nhật nguyên tử.
Trước khi sang vấn đề thứ hai, hãy dừng lại ở một hệ quả của compound action mà mục 2 đã hé lộ — ranh giới giữa một object thread-safe và một thao tác thread-safe ở cấp client.
4. Client-side locking — vì sao mong manh
Mục 2 đã cảnh báo: Vector thread-safe, nhưng if (!v.contains(x)) v.add(x) thì không. Giờ ta có đủ ngôn ngữ để gọi tên vấn đề. Từng method của Vector — contains, add — đều được đồng bộ hóa bên trong, nên mỗi lời gọi lẻ là nguyên tử. Nhưng cụm "kiểm tra vắng mặt rồi mới thêm" (put-if-absent) là một check-then-act bắc cầu qua hai lời gọi; giữa contains trả về false và add được thực thi vẫn có một cửa sổ để thread khác chen vào thêm đúng phần tử đó. Kết quả: hai bản sao trong danh sách, dù không một lời gọi nào lên Vector là sai.
Muốn làm cụm đó nguyên tử, ta phải quây cả hai lời gọi vào cùng một khóa. Câu hỏi then chốt là: khóa nào? Thử cách trông tự nhiên nhất — viết một helper và đồng bộ hóa method của chính nó (từ khóa synchronized sẽ được mổ kỹ ở bài volatile & synchronized; ở đây chỉ cần hiểu nó quây một khối code bằng một khóa):
// SAI: lock cua helper khac lock noi bo cua Vector
public class ListHelper<E> {
public final Vector<E> list = new Vector<>();
public synchronized boolean putIfAbsent(E x) { // lock tren ListHelper instance
boolean absent = !list.contains(x); // Vector tu lock tren CHINH NO
if (absent) list.add(x);
return absent;
}
}
Đoạn này trông an toàn nhưng vô dụng: synchronized trên putIfAbsent khóa trên đối tượng ListHelper, trong khi Vector đồng bộ hóa nội bộ trên chính nó. Hai khóa khác nhau thì không loại trừ lẫn nhau — một thread khác gọi thẳng list.add(x) không hề bị chặn bởi khóa của helper, và cửa sổ check-then-act vẫn mở nguyên. Đây là lỗi "khóa nhầm khóa", và nó nguy hiểm vì code đọc lên rất giống code đúng.
Phiên bản đúng phải khóa trên đúng đối tượng mà Vector dùng để tự bảo vệ — tức chính list:
// DUNG: client-side locking -- lock tren chinh collection
public boolean putIfAbsent(E x) {
synchronized (list) { // cung lock voi lock noi bo cua Vector
boolean absent = !list.contains(x);
if (absent) list.add(x);
return absent;
}
}
Kỹ thuật này có tên: client-side locking — code phía client (code dùng collection) tự cầm khóa trên chính object collection để quây một compound action mà collection không cung cấp sẵn dạng nguyên tử. Nó chạy đúng vì Vector cam kết (qua tài liệu và lịch sử lâu đời) rằng mọi method của nó đồng bộ trên this; khi client cũng khóa trên cùng object đó, lời gọi contains và add của mọi thread khác đều phải xếp hàng sau khối synchronized của ta.
Nhưng hãy nhìn kỹ cái nền mà phiên bản "đúng" này đứng lên: nó phụ thuộc vào chính sách khóa nội bộ của một lớp khác — một chi tiết cài đặt, không phải một phần của interface. Sự mong manh nằm ở ba chỗ. Thứ nhất, nếu lớp kia đổi cách khóa — phiên bản sau đồng bộ trên một object nội bộ thay vì trên this — code của ta sai ngay lập tức mà không một cảnh báo compile nào. Thứ hai, kỹ thuật này không khái quát được: ConcurrentHashMap không dùng một khóa duy nhất nào cả (bài Delegation & concurrent collections sẽ cho thấy vì sao), nên không tồn tại object nào để client khóa lên — may mà nó cung cấp sẵn putIfAbsent nguyên tử. Thứ ba, sự đúng đắn giờ bị phân mảnh: logic đồng bộ của một collection nằm rải ở mọi nơi từng dùng nó, ai quên khóa một chỗ là vỡ tất cả. Vì vậy hãy coi client-side locking là phương án cuối: ưu tiên collection cung cấp sẵn compound action nguyên tử, hoặc đóng gói khóa vào một lớp duy nhất — hai hướng mà bài ReentrantLock & Condition và bài 10 sẽ phát triển tiếp.
Bây giờ sang vấn đề thứ hai mà shared mutable state gây ra, hoàn toàn độc lập với atomicity, và còn phản trực giác hơn: visibility.
5. Visibility
Atomicity nói về thứ tự các thao tác. 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ỉ về atomicity. Đồng bộ hóa còn một vai trò thứ hai, tinh vi và hay bị bỏ quên: bảo đảm visibility.
5.1 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.
public class NoVisibility { // @NotThreadSafe
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. Hai chuyện có thể sai. 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à hiện tượng reordering - sắp xếp lại thứ tự thực thi.
Đây không phải lỗi. Trong điều kiện 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à cache giá trị trong register hay trong cache riêng của từng core, miễn là kết quả nhìn từ bên trong một thread đơn lẻ không đổi. Một giá trị thread A vừa ghi có thể còn nằm trong cache của core chạy A, chưa kịp đẩy ra bộ nhớ chính, nên thread B trên core khác vẫn đọc thấy giá trị 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ì cả: 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.
5.2 Torn read/write với long và double không volatile
Còn một mặt tinh vi hơn. Java Memory Model bảo đảm một lần đọc hoặc ghi biến 32-bit (như int) luôn lấy ra một giá trị mà ai đó đã thực sự ghi vào. Bảo đảm này đúng với mọi biến, trừ một ngoại lệ: biến 64-bit (long và double) không khai báo volatile. Với chúng, JVM được phép tách một lần đọc hoặc ghi 64-bit thành hai thao tác 32-bit. Nếu một thread ghi và một thread khác đọc đồng thời, thread đọc có thể nhặt được 32 bit cao của giá trị mới ghép với 32 bit thấp của giá trị cũ, tạo ra một con số chưa ai từng ghi.
Hiện tượng này thường được gọi là torn read/write - đọc/ghi bị xé. Đừng nhầm nó với "word tearing" theo nghĩa rộng hơn trong memory model, vốn nói về việc các update tới những biến độc lập nằm sát nhau can thiệp lẫn nhau ở mức word hoặc cache unit. Ở đây ta nói về việc một giá trị 64-bit đơn lẻ bị đọc thành nửa cũ nửa mới.
Trên phần cứng 64-bit phổ biến ngày nay, các JVM thực tế hầu như luôn ghi long và double nguyên tử, nên lỗi này rất hiếm gặp. Nhưng tới tận JDK 25 nó vẫn nằm trong đặc tả, nghĩa là code dựa vào tính nguyên tử của một long chia sẻ là code sai về nguyên tắc. Lưu ý phân biệt rõ với atomicity của phép cập nhật: nếu TicketFlow chỉ có một writer và nhiều reader cần visibility cho doanh thu, một volatile long revenue có thể đủ. Nhưng nếu nhiều thread cùng cộng dồn, thì revenue += amount vẫn là read-modify-write, và volatile không cứu được; khi đó cần AtomicLong, LongAdder, hoặc khóa, tùy yêu cầu về độ chính xác và mức contention.
5.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 - synchronization order - 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. Vì vậy volatile cung cấp cả visibility lẫn bảo đảm về ordering mạnh hơn đọc/ghi thường.
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 được "ăn theo" happens-before của ready
Một ví dụ dùng đúng kiểu của volatile là cờ trạng thái. Cho TicketFlow, một công tắc đóng hoặc mở bán vé:
private volatile boolean salesOpen = true; // admin ghi, nhiều worker đọc
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 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 điều kiện. sold thì vi phạm điều đầu (read-modify-write) lẫn điều hai (ràng buộc với capacity), nên volatile vô dụng với nó.
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à ordering, 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.long/doublekhông volatile có thể bị torn read/write; chia sẻ là sai về nguyên tắc.
Đến đây ta đã có hai vấn đề nền tảng độc lập nhau: atomicity và visibility. Phần tiếp theo gọi tên bốn chiến lược để đối phó với chúng một cách có hệ thống.
6. Bốn chiến lược xử lý shared mutable state
Mọi vấn đề ta vừa mổ xẻ đều quy về một cụm chữ: shared mutable state. Điều an ủi là cũng chỉ có bấy nhiêu hướng để gỡ, mỗi hướng nhắm vào một chữ trong cụm đó. Bốn hướng ấy là xương sống của phần còn lại trong series, nên ở đây ta chỉ gọi tên và phân vai; mỗi hướng có bài riêng đi tới tận cùng.
Hướng thứ nhất là confinement - đừng chia sẻ. Một dữ liệu chỉ một thread chạm tới thì không có gì để tranh, và nó tự động an toàn ngay cả khi bản thân không thread-safe. SeatPriceCalculator ở mục 2 an toàn chính nhờ điều này. Bài kế tiếp — Confinement — đi sâu vào các mức của nó, từ stack tới ThreadLocal tới ScopedValue.
Hướng thứ hai là immutability - đừng cho thay đổi. Nếu trạng thái không bao giờ đổi sau khi khởi tạo, mọi hiểm họa atomicity lẫn visibility đều tan biến: đọc lúc nào cũng ra cùng một giá trị. Event và Booking của TicketFlow là record immutable chính vì vậy.
Hướng thứ ba là synchronization - canh gác truy cập. Khi một dữ liệu vừa buộc phải chia sẻ vừa buộc phải đổi, như số vé đã bán, không còn cách né: phải canh mọi lần đọc và ghi. Java có sẵn cả một phổ công cụ cho việc này, từ volatile nhẹ nhất, qua atomic và CAS, tới synchronized và explicit lock. Đây cũng là nơi BookingService v0 được vá thành v1.
Hướng thứ tư là delegation - giao cho thứ đã an toàn. Thay vì tự dựng giao thức khóa, ta ủy thác phần khó cho những component đã được thiết kế thread-safe sẵn: ConcurrentHashMap, BlockingQueue, các synchronizer trong java.util.concurrent. Cái khéo nằm ở chỗ biết khi nào ủy thác là đủ, và khi nào nó vỡ.
Bốn hướng này không loại trừ nhau. Một class tốt thường phối hợp cả bốn: giam những gì giam được, đóng băng những gì đóng băng được, ủy thác phần ủy thác được, và chỉ tự tay khóa đúng phần lõi mutable còn lại.
7. Liên hệ các bài khác
Bài này là điểm neo khái niệm của cả module — hầu hết các bài sau đều quay về đây:
- Thread API và vòng đời — race condition tồn tại vì scheduler xen kẽ thread tuỳ ý; bài đó cho thấy thread được tạo, dừng và phối hợp thế nào — nền để hiểu "mọi cách xen kẽ" nghĩa là gì.
- Confinement — chiến lược thứ nhất: triệt tính "shared". Đọc ngay sau bài này để thấy cách né vấn đề trước khi học cách giải nó.
- Immutability — chiến lược thứ hai: triệt tính "mutable", và dựng nốt nền safe publication mà mục visibility ở đây mới chạm tới.
- volatile & synchronized — cơ chế quây compound action thành khối nguyên tử, và bản đồ happens-before chi tiết của JMM.
- Atomic & CAS — cơ chế phần cứng đứng sau
incrementAndGet, và ranh giới "một biến" của atomic class. - Delegation & concurrent collections — phát triển tiếp câu chuyện client-side locking: khi nào ủy thác là đủ, khi nào nó vỡ.
8. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JLS §17.4.5 — Happens-before Order — định nghĩa hình thức của data race: hai truy cập xung đột không được sắp thứ tự bởi happens-before.
- JLS §17.7 — Non-Atomic Treatment of double and long — cơ sở spec của hiện tượng torn read/write với biến 64-bit không volatile.
- Java Concurrency in Practice (Goetz et al.), chương 2-3 — nguồn gốc của định nghĩa thread safety, ví dụ put-if-absent trên
Vectorvà thuật ngữ client-side locking.
Ghi chú: JLS chương 17 là nơi mọi tranh luận "code này có data race không" được phân xử. Đọc §17.4.5 một lần để thấy data race là khái niệm cơ học (thiếu happens-before), không phải cảm tính "nhiều thread cùng đụng một biến".
9. Tóm tắt
Viết concurrent code đúng, rút lại, là quản lý truy cập vào shared mutable state. Hai vấn đề độc lập làm nên cái khó. Atomicity: các compound action như check-then-act và read-modify-write phải diễn ra trọn vẹn, không ai chen vào giữa chừng. Visibility: một thread phải thực sự nhìn thấy thay đổi của thread khác, điều mà nếu không đồng bộ thì Java Memory Model không hề hứa. BookingService v0 dính cả hai, và trớ trêu thay, mọi test đơn luồng của nó vẫn xanh.
- Thread safety = giữ invariant và postcondition đúng dưới mọi cách xen kẽ, không cần caller đồng bộ thêm.
count++là ba thao tác (đọc, cộng, ghi) — read-modify-write không nguyên tử; hai thread cùng tăng có thể mất một lần tăng (lost update).- Check-then-act sai vì quan sát bị vô hiệu hóa: điều vừa kiểm tra có thể đã đổi trước khi kịp hành động.
- Data race (JLS §17.4.5) = hai truy cập xung đột (ít nhất một ghi) không có quan hệ happens-before — khác với race condition, vốn là khái niệm về tính đúng đắn phụ thuộc thời điểm.
- Atomic class cho atomicity của một biến; invariant trải trên nhiều biến đòi cập nhật cả cụm trong một khối nguyên tử.
- Object thread-safe không làm compound action phía client tự động an toàn; client-side locking vá được nhưng mong manh vì dựa vào chính sách khóa nội bộ của lớp khác.
- 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.
volatilecho visibility + ordering, không cho atomicity — chỉ hợp với cờ trạng thái độc lập, một writer.
Khi đứng trước một mảnh state, câu hỏi đầu tiên không phải "khóa thế nào" mà là "mảnh này có buộc phải shared không, có buộc phải mutable không". Trả lời được hai câu đó là đã chọn được trong bốn chiến lược. Bài kế tiếp bắt đầu từ hướng dễ và an toàn nhất: nếu được, đừng chia sẻ gì cả - thread confinement.
10. Tự kiểm tra
Q1Vì sao count++ chỉ là một dòng code nhưng không nguyên tử? Chuyện gì có thể xảy ra khi hai thread cùng thực hiện nó?▸
Q2Check-then-act vì sao không atomic? Điều gì làm cho quan sát trở nên vô giá trị?▸
Q3Phân biệt race condition và data race. Một chương trình có thể dính cái này mà không dính cái kia không?▸
Race condition là khái niệm về tính đúng đắn: kết quả của phép tính phụ thuộc vào thứ tự xen kẽ tương đối của các thread — muốn đúng thì phải gặp may về thời điểm. Data race là khái niệm hình thức của JMM (JLS §17.4.5): hai truy cập xung đột vào cùng một biến (ít nhất một bên ghi) mà không có quan hệ happens-before giữa chúng.
Hai khái niệm độc lập nên có thể dính một mà không dính cái kia. Ví dụ: hai thread gọi vector.contains rồi vector.add — mọi truy cập đều qua method đồng bộ của Vector nên không có data race, nhưng cụm check-then-act vẫn là race condition. Ngược lại, một thread ghi thống kê vào biến thường mà thread khác chỉ đọc để log xấp xỉ: có data race theo định nghĩa, nhưng nếu chương trình chấp nhận giá trị xấp xỉ thì chưa chắc sai về nghiệp vụ.
Q4CachingBookingService dùng hai AtomicInteger cho sold và remaining, mọi thao tác lẻ đều nguyên tử. Vì sao class vẫn không thread-safe?▸
Q5Vì sao phiên bản client-side locking khóa trên list chạy đúng, nhưng vẫn bị coi là mong manh?▸
Nó đúng vì khóa trên cùng object mà Vector dùng để tự bảo vệ: mọi lời gọi contains/add từ thread khác đều phải xếp hàng sau khối synchronized (list) của ta, nên cửa sổ check-then-act bị đóng.
Mong manh vì sự đúng đắn dựa trên chính sách khóa nội bộ của lớp khác — một chi tiết cài đặt, không phải contract của interface. Nếu phiên bản sau của lớp đó đồng bộ trên một object nội bộ thay vì trên this, code client sai ngay mà không có cảnh báo compile. Kỹ thuật cũng không khái quát được: ConcurrentHashMap không có một khóa duy nhất nào để client cầm. Và logic đồng bộ bị phân mảnh ra mọi nơi dùng collection — quên khóa một chỗ là vỡ tất cả.
Q6Chươ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.▸
Q7volatile 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?▸
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