Thread Safety — viết code đúng khi nhiều thread cùng chạm dữ liệu
Vì sao code chạy hoàn hảo đơn luồng lại hỏng khi nhiều thread cùng truy cập state. Atomicity vs visibility, race condition vs data race, ba chiến lược thread confinement / immutability / synchronization, safe publication, và cách thiết kế class thread-safe có hệ thống qua capstone BookingService.
1. Giới thiệu
Bài trước 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. Nhưng chính sự dùng chung đó, 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. NOT thread-safe (intentional).
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("Event already exists: " + event.id());
}
sold.put(event.id(), 0); // invariant: once registered, a sold entry exists
}
public Booking book(String eventId, String userId) {
Event event = events.get(eventId);
if (event == null) {
throw new IllegalArgumentException("No such event: " + eventId);
}
int current = sold.getOrDefault(eventId, 0); // (1) read tickets sold so far
if (current >= event.capacity()) { // (2) any seat left?
throw new SoldOutException(eventId);
}
sold.put(eventId, current + 1); // (3) write back the new sold count
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); // first 10 seats carry a VIP surcharge
}
}
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 { // no fields, but NOT thread-safe
public void f(List<String> xs) {
if (!xs.contains("a")) xs.add("a"); // check-then-act on the caller's state
}
}
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; // looks like one op, actually three
// ... rest of 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à 9 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 9, cả hai cùng cộng thành 10, cả hai cùng ghi 10. 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ủa Java Memory Model: hai thread cùng truy cập một biến non-final dùng chung mà không có quan hệ đồng bộ hóa, và ít nhất một bên ghi. 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 ? No -> continue 0
t4 0 >= 1 ? No -> continue 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, hoặc một thao tác put gây resize đúng lúc dẫn tới vòng lặp vô tận, chứ không chỉ là lost update. 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();
// inside book():
bookingAttempts.incrementAndGet(); // read-add-write fused into one atomic op
incrementAndGet dựa trên chỉ thị compare-and-swap của CPU để gói trọn read-modify-write. 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 - do not do this
private final AtomicInteger sold = new AtomicInteger(0);
private final AtomicInteger remaining = new AtomicInteger(/* capacity */ 100);
// INVARIANT: sold + remaining == capacity (always)
public Booking book(String eventId, String userId) {
if (remaining.get() <= 0) throw new SoldOutException(eventId); // check ...
int seat = sold.incrementAndGet(); // ... then act, not atomic
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, và nó nằm trong mục Chiến lược ở phần 5.
- 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ử.
Nhưng trước khi bàn về giải pháp, còn một 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.
4. 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.
4.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.
4.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.
4.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 piggybacks on ready's happens-before
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 writes, many workers read
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ó.
- 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 trình bày ba chiến lược để đối phó với chúng một cách có hệ thống.
5. Chiến lược xử lý shared mutable state
Mọi vấn đề ta vừa gặp đều quy về shared mutable state. Vì vậy có đúng ba hướng tấn công, mỗi hướng triệt một tính chất:
- Thread confinement: đừng chia sẻ, giam dữ liệu trong một thread.
- Immutability: đừng cho thay đổi, làm dữ liệu bất biến.
- Synchronization: nếu buộc phải chia sẻ dữ liệu thay đổi được, bảo vệ mọi truy cập bằng đồng bộ hóa.
Hai hướng đầu thường đơn giản và an toàn hơn, nên ta xét chúng trước.
5.1 Thread confinement
Cách dễ nhất để một dữ liệu an toàn là không bao giờ để nó được chia sẻ. Nếu một đối tượng chỉ được một thread duy nhất chạm tới, ta không cần đồng bộ hóa gì, và đối tượng đó tự động thread-safe ngay cả khi bản thân nó không thread-safe. Đó là thread confinement.
Có ba dạng, từ mong manh tới vững chắc:
- Ad-hoc confinement: trách nhiệm giam dữ liệu hoàn toàn nằm ở kỷ luật của lập trình viên, không có cơ chế ngôn ngữ nào trợ giúp, nên mong manh và chỉ nên dùng ít. Tuy vậy, quyết định làm cả một phân hệ chạy đơn luồng đôi khi đáng giá. Swing giam mọi thao tác lên UI component vào đúng một thread là event dispatch thread, và phần lớn lỗi concurrency trong ứng dụng Swing đến từ việc chạm vào các đối tượng bị giam đó từ thread khác.
- Stack confinement: đối tượng chỉ với tới được qua biến cục bộ. Biến cục bộ sống trên stack của thread, mà thread khác không truy cập được, nên chừng nào bạn không để một tham chiếu tới nó thoát ra ngoài thì nó tự động bị giam. Class
SeatPriceCalculatorở phần 2 an toàn chính nhờ điều này. Một biếnintcục bộ thì không cách nào phá vỡ stack confinement, vì không thể lấy tham chiếu tới một biến nguyên thủy. ThreadLocal: cơ chế hình thức nhất, cho mỗi thread một bản sao riêng của một biến. Dùng kinh điển cho những thứ không thread-safe nhưng cần "toàn cục" trong phạm vi một thread, chẳng hạn một JDBCConnection. Server thường lấy một connection từ pool cho trọn một request rồi trả lại; vì pool không phát cùng một connection cho hai thread đồng thời, mỗi connection bị giam ngầm vào thread đang xử lý request đó.
Với TicketFlow, một hướng confinement là biến phân hệ đặt vé thành đơn luồng: mọi lệnh book đi qua một hàng đợi, và đúng một thread tiêu thụ hàng đợi đó thực hiện. Khi ấy events và sold bị giam trong thread tiêu thụ, và book không cần khóa. Điểm yếu muôn thuở là ngôn ngữ không có cơ chế ép một đối tượng phải bị giam; chỉ cần một chỗ lỡ chia sẻ tham chiếu ra ngoài, bảo đảm an toàn sụp đổ mà compiler không hề cảnh báo.
5.2 Immutability
Lối thoát còn sạch sẽ hơn: nếu trạng thái của một đối tượng không bao giờ đổi sau khi khởi tạo, thì gần như mọi hiểm họa atomicity và visibility ta đã kể, từ stale value, lost update, đến thấy đối tượng dở dang, đều biến mất. Đối tượng immutable luôn luôn thread-safe. Invariant của nó được constructor thiết lập một lần, và vì state không đổi được, invariant đó vĩnh viễn đúng. Một đối tượng immutable chỉ có đúng một trạng thái, nên suy luận về nó trở nên đơn giản.
Một đối tượng là immutable khi thỏa cả ba điều kiện:
- Trạng thái không thể bị thay đổi sau khi khởi tạo;
- Mọi field đều là
final; và - Nó được khởi tạo đúng cách, tức tham chiếu
thiskhông thoát ra ngoài trong lúc constructor đang chạy.
Lưu ý immutability không đồng nghĩa với "mọi field đều final". Một field final vẫn có thể trỏ tới một đối tượng thay đổi được, ví dụ một List, và khi đó đối tượng bao ngoài vẫn mutable qua kẽ hở ấy. Một class có thể immutable mà bên trong vẫn dùng đối tượng mutable, miễn là nó không bao giờ để cái mutable đó lọt ra hay bị sửa sau khi dựng xong.
Đây là lý do Event và Booking trong TicketFlow được viết bằng record:
public record Event(String id, int capacity) {
public Event {
Objects.requireNonNull(id, "id must not be null");
if (capacity < 0) throw new IllegalArgumentException("capacity must be >= 0, got: " + capacity);
}
}
record cho ta các field final và không setter một cách mặc định; Event chỉ chứa String (immutable) và int, nên nó immutable thật. Một Event sau khi tạo có thể chia sẻ cho hàng nghìn thread mà không cần đồng bộ hóa.
Immutability còn giúp tiếp cận bài toán nhiều biến một invariant ở mục 3.3 theo một hướng khác. Mẹo là gom cả cụm biến liên đới vào một đối tượng holder immutable, rồi công bố holder đó qua một tham chiếu được đồng bộ. Áp vào dashboard cache số chỗ còn lại:
public record Availability(String eventId, int sold, int capacity) { // immutable holder
public int remaining() { return capacity - sold; }
public boolean soldOut() { return sold >= capacity; }
}
// Thread-safe for many readers and EXACTLY ONE writer. NOT safe for multiple writers.
public class SingleWriterCache {
private volatile Availability snapshot = new Availability("concert-01", 0, 100);
public Availability availability() { return snapshot; } // reader: one read, always consistent
void recordSale() { // only one thread may call this
Availability cur = snapshot;
snapshot = new Availability(cur.eventId(), cur.sold() + 1, cur.capacity());
}
}
sold và capacity giờ nằm trong cùng một đối tượng immutable, nên một reader không bao giờ thấy trạng thái "sold đã tăng mà capacity chưa": nó luôn nhặt được một Availability nhất quán. Tham chiếu volatile lo việc holder mới hiển thị kịp thời cho reader. Nhưng phải nói thẳng giới hạn, và đây là lý do annotation ở trên ghi rõ "đúng một writer": recordSale vẫn là read-modify-write trên snapshot (đọc cur, tạo mới, ghi lại). Nếu hai thread cùng recordSale, cả hai có thể đọc cùng cur rồi cùng ghi đè, và một sale bị mất — đúng kiểu lost update ở mục 3.1, chỉ là trên một tham chiếu.
Muốn thật sự an toàn cho nhiều writer mà vẫn không cần khóa, dùng AtomicReference với một vòng compare-and-set, mà updateAndGet gói sẵn:
private final AtomicReference<Availability> snapshot =
new AtomicReference<>(new Availability("concert-01", 0, 100));
void recordSale() {
snapshot.updateAndGet(cur ->
new Availability(cur.eventId(), cur.sold() + 1, cur.capacity()));
}
Một lưu ý quan trọng khi dùng updateAndGet: nếu CAS thất bại vì có thread khác chen vào, lambda sẽ được gọi lại với giá trị mới, nên hàm cập nhật phải thuần — side-effect-free — không được làm gì khác ngoài việc tính ra giá trị mới.
Tư tưởng thiết kế rút ra: đẩy được càng nhiều phần hệ thống về immutable thì bề mặt cần đồng bộ hóa càng nhỏ. Trong TicketFlow, phần thực sự buộc phải thay đổi chỉ là số vé đã bán, và đó là phần ta phải canh gác.
5.3 Synchronization
Khi dữ liệu vừa phải chia sẻ vừa phải đổi, như sold của BookingService, không tránh được mà phải canh gác mọi truy cập. Java cung cấp sẵn một cơ chế khóa để làm việc đó: khối synchronized.
Mỗi đối tượng Java đều ngầm mang theo một khóa, gọi là intrinsic lock hay monitor lock. Một khối synchronized(lock) { ... } tự động giành lock khi thread bước vào và tự động nhả khi thread rời khối, dù rời bằng đường bình thường hay vì một exception ném ra. Intrinsic lock hoạt động như một mutex — mutual exclusion lock: tại một thời điểm nhiều nhất một thread giữ được khóa. Khi A muốn giành một khóa đang nằm trong tay B, A phải chờ — block — cho tới khi B nhả.
Vì chỉ một thread tại một thời điểm chạy được khối code mà một khóa nhất định canh giữ, nên các khối synchronized cùng khóa thực thi nguyên tử so với nhau. Không thread nào thấy được một thread khác đang ở giữa chừng một khối synchronized cùng khóa. Đó chính xác là thứ ta cần để quây cả compound action của book:
public class BookingService {
@GuardedBy("this") private final Map<String, Event> events = new HashMap<>();
@GuardedBy("this") private final Map<String, Integer> sold = new HashMap<>();
public synchronized void register(Event event) {
Objects.requireNonNull(event);
if (events.putIfAbsent(event.id(), event) != null)
throw new IllegalArgumentException("Event already exists: " + event.id());
sold.put(event.id(), 0);
}
public synchronized Booking book(String eventId, String userId) {
Event event = events.get(eventId);
if (event == null) throw new IllegalArgumentException("No such event: " + eventId);
int current = sold.getOrDefault(eventId, 0);
if (current >= event.capacity()) throw new SoldOutException(eventId);
sold.put(eventId, current + 1);
return new Booking(eventId, userId, current + 1);
}
public synchronized int soldCount(String eventId) { // reader MUST lock too
return sold.getOrDefault(eventId, 0);
}
}
Một method synchronized chỉ là khối synchronized ôm trọn thân method, khóa trên chính đối tượng được gọi là this. Giờ kịch bản xen kẽ ở mục 3.2 không còn xảy ra: nếu A đã vào, B phải chờ tới khi A ghi xong và rời khóa; B vào sau đọc current = 1, thấy 1 >= 1, và đúng đắn ném SoldOutException. Invariant được giữ.
Với góc nhìn senior, có một cách nói chặt hơn cho điều này: một thao tác thread-safe thường có một linearization point — một thời điểm mà tại đó thao tác "có hiệu lực" như thể xảy ra tức thời. Với book đã khóa, linearization point là lúc ghi sold bên trong khối khóa: trước thời điểm đó chưa khách nào giữ chỗ này, sau thời điểm đó chỗ đã thuộc về booking này, và không tồn tại trạng thái "ở giữa" mà thread khác quan sát được. Đây là nền tảng để lập luận về tính đúng đắn một cách dứt khoát.
Quan trọng không kém, khóa đồng thời giải quyết cả vấn đề visibility ở phần 4. Khi thread A rời một khối synchronized, mọi thay đổi nó làm bên trong được bảo đảm hiển thị với thread B khi B vào một khối synchronized cùng khóa, vì việc nhả một khóa happens-before việc giành lại chính khóa đó. Khóa không chỉ ngăn hai thread vào vùng tới hạn cùng lúc, nó còn bắc một cây cầu bộ nhớ giữa chúng. Tóm lại: khóa bảo đảm cả visibility lẫn atomicity, còn volatile chỉ bảo đảm visibility.
Có vài điều cần nắm về intrinsic lock.
Khóa có tính tái nhập — reentrant. Nếu một thread đang giữ một khóa rồi lại gặp một khối synchronized khác trên cùng khóa đó, nó vào được luôn chứ không tự khóa chính mình. JVM cài đặt điều này bằng cách gắn cho mỗi khóa một bộ đếm số lần giành — acquisition count — và một thread sở hữu: khi thread sở hữu giành lại, count tăng; khi nó rời mỗi khối, count giảm; về 0 thì khóa mới thực sự được nhả. Reentrancy cứu ta khỏi deadlock trong tình huống rất tự nhiên là một method synchronized của lớp con gọi super cũng synchronized trên cùng đối tượng. Nếu khóa không reentrant, lệnh gọi super sẽ chờ một khóa mà chính thread đang giữ, và treo vĩnh viễn.
public class Widget {
public synchronized void doSomething() { /* ... */ }
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething(); // re-acquire the same lock - safe because reentrant
}
}
Còn một điều cần khắc cốt, vì nhiều người hiểu sai:
Giành khóa của một đối tượng không ngăn thread khác truy cập đối tượng đó; điều duy nhất nó ngăn là thread khác giành cùng cái khóa ấy. Không có liên hệ nội tại nào giữa intrinsic lock của một đối tượng và các field của nó.
Vì thế chỉ bọc compound action trong synchronized là chưa đủ: nếu một biến dùng chung được canh bằng khóa, thì mọi đường truy cập tới biến đó, đọc lẫn ghi, đều phải giữ cùng một khóa. Một sai lầm phổ biến là tưởng chỉ cần đồng bộ khi ghi; không phải — đó là lý do soldCount ở trên cũng synchronized. Nếu book khóa this mà soldCount đọc sold không khóa, thì soldCount vẫn có thể đọc trúng dữ liệu dở dang hoặc một giá trị cũ. Quy tắc cô đọng: mỗi biến dùng chung, thay đổi được nên được canh bởi đúng một khóa, và phải nói rõ cho người bảo trì biết đó là khóa nào; annotation @GuardedBy("this") chính là cách ghi lại điều đó. Và khi class có invariant ràng buộc nhiều biến, như sold + remaining == capacity ở mục 3.3, thì mọi biến tham gia invariant phải được canh bởi cùng một khóa, để có thể cập nhật chúng trong một thao tác nguyên tử duy nhất.
Nếu khóa chữa được race condition, sao không synchronized luôn mọi method cho xong? Vì làm vậy thường là quá tay hoặc chưa đủ. Chưa đủ: bản thân Vector đồng bộ mọi method, nhưng if (!v.contains(x)) v.add(x); vẫn dính race, vì hai thao tác nguyên tử riêng lẻ ghép lại không tự thành một compound action nguyên tử; ta sẽ giải quyết đúng tình huống này ở mục 7.3. Quá tay: khóa nguyên cả book khiến hai khách đặt vé cho hai sự kiện khác nhau vẫn phải xếp hàng chờ nhau dù chẳng liên quan. Đây là vấn đề về hiệu năng và liveness chứ không phải về an toàn: khóa hiện tại là coarse-grained, đúng nhưng thô; hướng tối ưu về sau là khóa chi tiết hơn theo từng sự kiện — per-event lock hoặc striped lock — để các sự kiện độc lập không chặn nhau. Ta sẽ bàn ở bài về performance. Nguyên tắc cân bằng trước mắt là giữ khóa đủ lâu để bao trọn compound action, nhưng nhả nó trước khi làm những việc dài hơi như I/O mạng hay tính toán nặng vốn không đụng tới shared state.
Ba chiến lược trên không loại trừ nhau. Một class tốt thường phối hợp cả ba: giam những gì giam được, đóng băng những gì đóng băng được, và chỉ khóa đúng phần lõi mutable còn lại. Nhưng cả ba đều ngầm giả định một điều mà ta chưa xét tới: rằng đối tượng đã đến tay thread khác một cách lành lặn. Hóa ra, ngay cả một đối tượng được xây hoàn hảo cũng có thể bị nhìn thấy ở trạng thái hỏng nếu khoảnh khắc trao nó đi bị làm sai.
6. Safe publication
Publication — công bố — là làm cho một đối tượng hiển thị với code ngoài phạm vi hiện tại của nó: lưu tham chiếu vào một field nơi code khác tìm thấy, trả nó về từ một method không private, hoặc truyền nó cho method của lớp khác. Phần lớn thời gian ta không muốn công bố nội bộ ra ngoài. Một đối tượng bị công bố khi đáng lẽ không nên thì gọi là đã escape — thoát ra.
6.1 Khi một đối tượng tốt bị công bố sai
Xét đoạn công bố trông vô hại sau:
public Holder holder; // unsafe publication
public void initialize() {
holder = new Holder(42);
}
public class Holder {
private int n;
public Holder(int n) { this.n = n; }
public void assertSanity() {
if (n != n) // "n not equal to itself?"
throw new AssertionError("This should never be true.");
}
}
Ví dụ hiền lành này có thể hỏng tệ hơn bạn nghĩ. Vì không dùng đồng bộ hóa khi công bố, một thread khác gọi assertSanity có thể ném AssertionError, vì nó có thể đọc n lần đầu ra một giá trị rồi đọc lần thứ hai ra giá trị khác. Vấn đề không nằm ở Holder, mà ở chỗ Holder được công bố không đúng cách. Khi holder = new Holder(42) chạy, có nhiều việc xảy ra: cấp phát bộ nhớ, constructor ghi n = 42, rồi gán tham chiếu vào holder. Java Memory Model không bảo đảm các việc này hiển thị với thread khác theo đúng thứ tự đó. Hai chuyện có thể sai: thread khác thấy holder còn null dù đã gán; hoặc tệ hơn, thấy holder khác null nhưng các field bên trong vẫn ở giá trị mặc định, tức một đối tượng nửa được xây. Đáng chú ý là constructor của Object ghi giá trị mặc định cho mọi field trước khi constructor lớp con chạy, nên giá trị mặc định 0 hoàn toàn có thể bị nhìn thấy như một stale value.
Một dạng escape đặc biệt nguy hiểm là để this thoát ngay trong lúc constructor đang chạy, thường gặp khi ta đăng ký một listener hoặc khởi động một thread từ trong constructor. Một đối tượng chỉ ở trạng thái nhất quán sau khi constructor trả về; công bố this từ bên trong constructor là công bố một đối tượng chưa dựng xong, kể cả khi đó là dòng cuối cùng. Nguyên tắc là đừng để this thoát trong lúc khởi tạo. Nếu cần đăng ký listener hay khởi động thread khi dựng, hãy dùng một private constructor cộng một factory method công khai, để chỉ trao đối tượng đi sau khi nó đã hoàn chỉnh.
6.2 Bốn cách công bố an toàn
Để safe publication một đối tượng, sao cho thread khác thấy nó ở trạng thái đầy đủ, cả tham chiếu lẫn trạng thái của đối tượng phải được làm hiển thị cùng lúc. Một đối tượng được dựng đúng cách có thể công bố an toàn bằng một trong bốn cách, mỗi cách đều thiết lập happens-before giữa lúc dựng xong và lúc đọc:
- Khởi tạo tham chiếu từ một static initializer, ví dụ
public static Holder holder = new Holder(42);, vì JVM đồng bộ việc nạp class. - Lưu tham chiếu vào một field
volatilehoặc mộtAtomicReference. - Lưu tham chiếu vào một field
finalcủa một đối tượng được dựng đúng cách. - Lưu tham chiếu vào một field được canh bởi khóa, bao gồm cả việc đặt nó vào một thread-safe collection.
Cách cuối đáng nhớ vì nó âm thầm xảy ra suốt: đặt một object vào ConcurrentHashMap, Vector, CopyOnWriteArrayList, hay một BlockingQueue sẽ tự động công bố an toàn object đó cho bất kỳ thread nào lấy nó ra. Trong TicketFlow, khi register đặt một Event vào map và map đó là thread-safe, chính map đã lo việc công bố an toàn Event cho các thread gọi book về sau.
6.3 Initialization safety của immutable, và các luật chơi khi chia sẻ
Immutability có một phần thưởng quý ở đây, gọi là initialization safety. Cần phát biểu cho chính xác, vì rất dễ nói quá tay. Nếu một object được dựng đúng cách — this không escape trong constructor — và state immutable của nó nằm trong các field final, thì Java Memory Model bảo đảm: một thread đã lấy được reference tới object đó sẽ thấy đúng giá trị khởi tạo của các field final, kể cả khi reference được công bố mà không đồng bộ. Nếu Holder ở mục 6.1 có field n là final, assertSanity sẽ không bao giờ ném được AssertionError.
Nhưng điều này không biến toàn bộ quá trình publication thành một cơ chế đồng bộ hóa tổng quát. Nó chỉ bảo đảm các field final không bị nhìn thấy ở trạng thái dở dang. Nó không bảo đảm rằng reference tới object sẽ được thread khác nhìn thấy kịp thời hay đúng thứ tự nếu chính reference đó được công bố qua một data race; và nếu object chứa reference tới mutable state, thì state đó vẫn cần được đồng bộ riêng. Vì vậy ba quy tắc thực dụng, tùy theo độ mutable, nên đọc như sau:
- Đối tượng immutable (final fields,
thiskhông escape): initialization safety bảo đảm thread nào đã có reference tới nó sẽ thấy đúng trạng thái khởi tạo của các fieldfinal. Tuy vậy, kênh công bố vẫn cần được thiết kế cẩn thận nếu ta cần reference được thấy đúng lúc, đúng thứ tự, hoặc nếu object trỏ tới mutable state. - Đối tượng effectively immutable (kỹ thuật thì sửa được nhưng thực tế không bị sửa sau khi công bố, ví dụ một
Datemà ta quyết không gọi setter): cần safe publication, sau đó chia sẻ thoải mái. - Đối tượng mutable: cần safe publication và phải đồng bộ hóa mọi truy cập về sau, tức tự nó thread-safe hoặc được canh bằng khóa.
Mỗi khi bạn cầm một tham chiếu tới đối tượng dùng chung, hãy biết rõ luật chơi với nó. Có bốn chính sách chia sẻ hữu ích nhất, cũng là khung để tài liệu hóa ý định:
- Thread-confined: thuộc về và bị giam trong một thread duy nhất, chỉ thread đó được sửa.
- Shared read-only: nhiều thread đọc đồng thời không cần đồng bộ, không ai sửa; gồm immutable và effectively immutable.
- Shared thread-safe: tự đồng bộ bên trong, nhiều thread tự do gọi qua API công khai, như
ConcurrentHashMap. - Guarded: chỉ truy cập được khi giữ một khóa nhất định.
Với đủ các công cụ này, ta có thể quay lại câu hỏi thực dụng nhất: làm sao thiết kế một class thread-safe một cách có hệ thống, thay vì rải synchronized theo cảm tính?
7. Thiết kế class thread-safe
Nền tảng của mọi thiết kế thread-safe tốt là encapsulation — đóng gói. Nếu state mutable bị nhốt kín bên trong và mọi đường ra vào đều do class kiểm soát, ta có một nơi duy nhất để áp đặt kỷ luật đồng bộ hóa, và có thể kết luận class an toàn mà không cần đọc cả chương trình. Quy trình thiết kế gói gọn trong ba câu hỏi. Thứ nhất, những biến nào tạo nên state của đối tượng? Với BookingService là events và sold. Thứ hai, những invariant nào ràng buộc các biến đó? Là 0 ≤ sold[e] ≤ e.capacity, cùng postcondition mỗi lần đặt thành công thì sold tăng đúng một. Thứ ba, chính sách nào quản lý việc truy cập đồng thời, tức kết hợp nào của confinement, immutability và locking, và biến nào được canh bởi khóa nào? Hai câu đầu quyết định câu thứ ba: nếu một thao tác suy trạng thái kế tiếp từ trạng thái hiện tại thì nó tất yếu là compound action và phải nguyên tử; nếu một invariant ràng buộc nhiều biến thì mọi biến đó phải được canh bởi cùng một khóa.
7.1 Java Monitor Pattern
Đẩy nguyên tắc đóng gói tới tận cùng, ta được Java Monitor Pattern: gói toàn bộ state mutable vào trong đối tượng, và canh nó bằng một khóa, sao cho mọi method chạm vào state đều giữ cùng khóa đó. BookingService ở mục 5.3 chính là mẫu này, khóa trên this. Một Counter tối giản cũng vậy:
@ThreadSafe
public final class Counter {
@GuardedBy("this") private long value = 0;
public synchronized long get() { return value; }
public synchronized long increment() {
if (value == Long.MAX_VALUE) throw new IllegalStateException("counter overflow");
return ++value;
}
}
Một tinh chỉnh được khuyến nghị là dùng private lock object thay vì khóa trên this:
public class BookingService {
private final Object lock = new Object();
@GuardedBy("lock") private final Map<String, Integer> sold = new HashMap<>();
public Booking book(String eventId, String userId) {
synchronized (lock) { /* ... entire compound action ... */ }
}
}
Lý do là khi khóa trên this, bất kỳ code bên ngoài nào giữ tham chiếu tới BookingService cũng có thể synchronized trên nó và tham gia, đúng hoặc sai, vào chính sách khóa của ta, thậm chí vô tình gây deadlock. Để kiểm chứng một khóa công khai được dùng đúng, bạn phải soi cả chương trình. Một lock riêng tư đóng kín chính sách đồng bộ hóa bên trong class, chỉ cần soi đúng một file. Đánh đổi là client không thể khóa từ bên ngoài (xem mục 7.3), nhưng đóng kín thường đáng giá hơn.
Monitor Pattern mạnh ở sự đơn giản, nhưng nó serialize toàn bộ: chỉ một thread vào được bất kỳ method nào tại một thời điểm, một điểm đã bàn ở cuối mục 5.3 về độ chi tiết của khóa.
7.2 Delegation
Hầu hết đối tượng là composite, tức ghép từ nhiều đối tượng con. Nếu các thành phần con vốn đã thread-safe, liệu ta có cần thêm một lớp khóa của riêng mình? Câu trả lời là còn tùy, và biết tùy vào cái gì chính là phần tinh túy nhất ở đây.
Khi state của một class chỉ gồm các biến độc lập với nhau, mỗi biến giao cho một thành phần thread-safe, thì class ủy thác — delegate — được toàn bộ trách nhiệm an toàn mà không cần một dòng synchronized:
@ThreadSafe
public class EventRegistry {
private final ConcurrentMap<String, Event> events = new ConcurrentHashMap<>();
public void register(Event event) {
if (events.putIfAbsent(event.id(), event) != null) // atomic check-then-act, handled by the map
throw new IllegalArgumentException("Event already exists: " + event.id());
}
public Event find(String eventId) { return events.get(eventId); }
}
ConcurrentHashMap lo trọn atomicity và visibility cho map, còn putIfAbsent gói nguyên tử cả thao tác "thêm nếu chưa có". EventRegistry thread-safe hoàn toàn nhờ ủy thác. Ta thậm chí có thể ủy thác cho nhiều biến thread-safe, miễn là chúng độc lập, ví dụ hai danh sách listener chuột và phím không có ràng buộc gì với nhau.
Nhưng phải nhấn mạnh một bẫy mà nhiều người sa vào: bản thân ConcurrentHashMap thread-safe không cứu được một compound action ở cấp client. Đoạn sau vẫn sai dù sold là ConcurrentHashMap:
if (sold.get(eventId) < capacity) { // check
sold.put(eventId, sold.get(eventId) + 1); // ... then act - not atomic together
}
Mỗi lời gọi get/put lẻ là nguyên tử, nhưng cụm "đọc, so sánh, ghi" thì không; hai thread vẫn cùng qua được phần kiểm tra rồi cùng ghi. Đây chính là ranh giới giữa một object thread-safe và một thao tác thread-safe ở cấp client.
Và ủy thác gãy hẳn khi các biến không còn độc lập. Đây là ranh giới quyết định, đã chạm ở mục 3.3. Hãy nhìn một class tưởng như vô hại:
public class NumberRange { // @NotThreadSafe - do not do this
// INVARIANT: lower <= upper
private final AtomicInteger lower = new AtomicInteger(0);
private final AtomicInteger upper = new AtomicInteger(0);
public void setLower(int i) {
if (i > upper.get()) throw new IllegalArgumentException("can't set lower > upper"); // check
lower.set(i); // act
}
public void setUpper(int i) {
if (i < lower.get()) throw new IllegalArgumentException("can't set upper < lower");
upper.set(i);
}
public boolean isInRange(int x) {
return x >= lower.get() && x <= upper.get(); // reader can also observe an invalid range
}
}
lower và upper đều là AtomicInteger thread-safe. Vậy mà NumberRange không thread-safe. Nếu khoảng đang là (0, 10) và một thread gọi setLower(5) trong khi thread khác gọi setUpper(4), với thời điểm xui xẻo cả hai cùng qua được phần kiểm tra, cả hai cùng ghi, và khoảng rơi vào trạng thái vô lý (5, 4). Tệ hơn, đây không chỉ là chuyện lý thuyết với writer: một reader gọi isInRange đúng lúc lower đã đổi mà upper chưa cũng có thể quan sát một range không hợp lệ. Vì lower và upper không độc lập, chúng bị trói bởi invariant lower ≤ upper, nên class không thể chỉ ủy thác cho các biến con. Đây đúng là vấn đề sold + remaining == capacity của TicketFlow khoác áo khác.
Quy tắc cô đọng: nếu một class có compound action bắc cầu qua nhiều biến, hoặc một invariant ràng buộc nhiều biến, thì ủy thác cho các thành phần thread-safe rời rạc là chưa đủ; class phải tự thêm một lớp khóa của riêng nó, hoặc gom cụm biến vào một holder immutable như mục 5.2. Để ý sự song song: điều kiện để ủy thác được cho nhiều biến giống hệt điều kiện để một biến được phép là volatile, đó là nó phải độc lập, không dính invariant với biến khác. BookingService rơi đúng vào trường hợp gãy này, nên Monitor Pattern mới là lời giải đúng cho nó, chứ không phải đổi hai HashMap thành hai ConcurrentHashMap.
7.3 Mở rộng class thread-safe hiện có
Tình huống cuối rất hay gặp: đã có một class thread-safe và ta muốn thêm một thao tác nguyên tử mới mà API gốc không có, ví dụ "đặt nếu chưa có" — put-if-absent — trên một List. Như chuông cảnh báo check-then-act đã reo ở mục 5.3, thao tác này phải nguyên tử thì mới đúng. Có bốn cách, theo thứ tự an toàn tăng dần.
Cách thứ nhất là sửa thẳng class gốc. An toàn nhất nếu bạn có quyền sửa source, vì toàn bộ code thực thi chính sách khóa nằm gọn trong một file. Tiếc là ta thường không có quyền đó.
Cách thứ hai là kế thừa — extension. Tạo subclass và thêm method synchronized:
@ThreadSafe
public class BetterVector<E> extends Vector<E> {
public synchronized boolean putIfAbsent(E x) {
boolean absent = !contains(x);
if (absent) add(x);
return absent;
}
}
Cách này đúng chỉ vì Vector ghi rõ trong đặc tả rằng nó khóa trên this, nên method mới khóa cùng một thứ với các method có sẵn. Nó mong manh: chính sách khóa giờ trải qua hai file ở hai nơi; nếu lớp cha đổi cách khóa, subclass âm thầm hỏng vì không còn dùng đúng khóa. Vector thì cố định đặc tả nên BetterVector an toàn, nhưng đừng trông cậy điều đó ở class khác.
Cách thứ ba là client-side locking. Khi đối tượng đến từ một factory như Collections.synchronizedList, bạn còn chẳng biết class thật của nó để mà kế thừa. Đặt code mới vào một class helper, nhưng khóa trên đúng cái khóa đối tượng gốc dùng:
public boolean putIfAbsent(List<E> list, E x) {
synchronized (list) { // must lock on the LIST itself, not on the helper
boolean absent = !list.contains(x);
if (absent) list.add(x);
return absent;
}
}
Cú lừa kinh điển ở đây là synchronized trên helper thay vì trên list; khi đó putIfAbsent khóa một cái khóa khác với cái mà các thao tác của list dùng, tạo ảo giác đồng bộ mà thực ra không nguyên tử với gì cả. Client-side locking còn mong manh hơn kế thừa, vì nó nhét code khóa của class C vào những class chẳng liên quan tới C, phá vỡ đóng gói của chính sách đồng bộ hóa.
Cách thứ tư là composition, và đây là cách được khuyến nghị. Bọc đối tượng gốc trong một class mới giữ khóa riêng của nó cho mọi method:
@ThreadSafe
public class ImprovedList<T> {
private final List<T> list; // need not be thread-safe itself
public ImprovedList(List<T> list) { this.list = list; }
public synchronized boolean putIfAbsent(T x) {
boolean absent = !list.contains(x);
if (absent) list.add(x);
return absent;
}
public synchronized void clear() { list.clear(); }
// ... delegate other List methods too, also synchronized
}
Composition không phụ thuộc chính sách khóa của list chút nào; ImprovedList áp khóa riêng lên mọi truy cập, nên đúng kể cả khi list bên dưới không thread-safe hay đổi cách khóa. Thực chất ta đang dùng lại Monitor Pattern để đóng gói một List có sẵn. Đổi lại là thêm một tầng khóa nữa, với chi phí nhỏ vì khóa lồng này gần như không bao giờ bị tranh, và đó thường là cái giá đáng trả để có tính đúng đắn đóng kín, không lệ thuộc tài liệu của lớp khác. Đây cũng đúng là cách Collections.synchronizedList được xây.
Dù chọn cách nào, hãy tài liệu hóa chính sách đồng bộ hóa. Người dùng cần biết class có thread-safe không, còn người bảo trì cần biết biến nào canh bởi khóa nào để không vô tình phá vỡ nó khi thêm một method mới. Một dòng @GuardedBy("lock") rẻ hơn nhiều so với một đêm truy lỗi race condition trên production.
8. Kết luận
Thread safety là khả năng một đoạn code tiếp tục đúng, giữ trọn invariant và postcondition, dưới mọi cách xen kẽ mà scheduler có thể tạo ra, và không cần code gọi tự đồng bộ thêm. Toàn bộ độ khó của nó quy về một thứ là shared mutable state. Nhìn lại BookingService: thêm một biến đếm làm lộ race condition, thêm một cache làm lộ invariant nhiều biến, vá bằng khóa làm lộ vấn đề visibility, và từ chuỗi vấn đề đó mà các chiến lược cùng mẫu thiết kế thành hình.
Những điểm cốt lõi cần mang theo:
- Một đối tượng thật sự stateless, không giữ state mutable và không đụng tới shared mutable state bên ngoài, thì luôn thread-safe. Nhưng thread-safe ở cấp object không kéo theo việc mọi tổ hợp lời gọi ở cấp client cũng atomic.
- Shared mutable state gây ra hai vấn đề độc lập. Atomicity: compound action như check-then-act và read-modify-write phải diễn ra như một khối không chia cắt. Visibility: không đồng bộ hóa thì không có gì bảo đảm một thread thấy thay đổi của thread khác, lại còn reordering, stale data, và torn read/write với
longvàdoublekhông volatile. Phân biệt race condition (về tính đúng đắn) với data race (truy cập biến chia sẻ không đồng bộ theo JMM). - Lớp atomic gọn cho một biến độc lập, nhưng cho atomicity của một biến chứ không phải của invariant. Khi nhiều biến cùng một invariant, phải cập nhật chúng trong cùng một thao tác nguyên tử, bằng khóa hoặc gom vào một holder immutable.
- Ba chiến lược: thread confinement nghĩa là đừng chia sẻ; immutability nghĩa là đừng cho đổi; synchronization nghĩa là canh mọi truy cập bằng cùng một khóa. Khóa lo cả atomicity lẫn visibility nhờ quan hệ nhả khóa happens-before giành lại, và mỗi thao tác đã khóa có một linearization point rõ ràng;
volatilechỉ lo visibility và ordering, chỉ hợp với cờ trạng thái độc lập. - Safe publication: một đối tượng dựng đúng vẫn có thể bị thấy dở dang nếu công bố sai. Bốn cách an toàn là static initializer,
volatilehoặcAtomicReference, fieldfinal, và khóa hoặc thread-safe collection. Initialization safety của fieldfinalchỉ bảo đảm thread đã có reference sẽ thấy đúng giá trị khởi tạo, chứ không biến mọi kênh công bố thành cơ chế đồng bộ tổng quát. Đừng đểthisthoát trong constructor. - Thiết kế dựa trên encapsulation và ba câu hỏi state, invariant, policy. Monitor Pattern với private lock là lời giải tổng quát. Delegation gọn nhưng chỉ đúng khi các biến độc lập, và gãy ngay khi có invariant bắc cầu. Khi mở rộng class thread-safe, composition an toàn hơn kế thừa hay client-side locking.
Quay lại TicketFlow: v0 hỏng vì book là check-then-act trên state dùng chung, và vì invariant "không vượt capacity" trói sold với Event, nên đổi sang ConcurrentHashMap thôi là chưa đủ, đúng kiểu giới hạn của delegation. Lời giải đúng là Monitor Pattern, gom cả compound action vào một khóa, và đó là bước v1 của capstone. Nhưng khóa nguyên cả BookingService cho mọi sự kiện sẽ serialize cả những request chẳng liên quan, và bản thân synchronized kéo theo những hiểm họa mới như deadlock, contention và suy giảm thông lượng. Đó là cánh cửa mở sang các bài tiếp theo: khóa chi tiết hơn theo từng sự kiện, các building block trong java.util.concurrent, và những liveness hazard mà chính việc khóa tạo ra.
Q1Một Vector đã đồng bộ mọi method, vậy mà if (!v.contains(x)) v.add(x) vẫn dính race. Vì sao?▸
Vector đã đồng bộ mọi method, vậy mà if (!v.contains(x)) v.add(x) vẫn dính race. Vì sao?Vì thread-safe ở cấp object không kéo theo atomicity ở cấp client. Mỗi lời gọi contains và add riêng lẻ đều nguyên tử, nhưng cụm "kiểm tra rồi thêm" là một compound action check-then-act — giữa lúc kiểm tra và lúc thêm, một thread khác có thể chen vào và làm quan sát ban đầu hết đúng.
Hai thread cùng thấy x chưa có, cả hai cùng thêm. Muốn đúng, cả cụm phải khóa chung (mục 7.3): kế thừa, client-side locking, hoặc composition.
Q2volatile đủ cho cờ salesOpen nhưng vô dụng với bộ đếm sold. Vì sao?▸
volatile đủ cho cờ salesOpen nhưng vô dụng với bộ đếm sold. Vì sao?volatile chỉ bảo đảm visibility và ordering, hoàn toàn không bảo đảm atomicity. Nó chỉ dùng được khi cả ba điều kiện đúng: (1) lệnh ghi không phụ thuộc giá trị hiện tại, hoặc chỉ một thread ghi; (2) biến không dính invariant với biến khác; (3) không cần khóa vì lý do nào khác.
salesOpen thỏa cả ba: admin ghi true/false độc lập, worker chỉ đọc. sold vi phạm điều (1) vì sold++ là read-modify-write, và điều (2) vì bị trói với capacity qua invariant không vượt sức chứa.
Q3Phân biệt race condition và data race. Hai khái niệm này có trùng nhau 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ời điểm hay thứ tự xen kẽ của các thread (ví dụ check-then-act của book).
Data race là khái niệm hình thức của Java Memory Model: hai thread cùng truy cập một biến non-final dùng chung mà không có quan hệ đồng bộ, và ít nhất một bên ghi.
Hai khái niệm không trùng nhau: có race condition mà không data race (ví dụ check-then-act trên một collection thread-safe), và có data race mà ta không coi là lỗi logic. Nhưng cả hai đều khiến chương trình sai khó lường.
Q4Trong CachingBookingService, cả sold và remaining đều là AtomicInteger. Vì sao class vẫn không thread-safe?▸
CachingBookingService, cả sold và remaining đều là AtomicInteger. Vì sao class vẫn không thread-safe?Vì atomic variable cho bạn atomicity của một biến, không phải atomicity của invariant. Có hai lỗi cùng lúc:
- Phần
if (remaining.get() <= 0)rồi mớiincrementAndGetlà một check-then-act không nguyên tử — nhiều thread cùng qua kiểm tra rồi cùng tăng, bán vượt capacity. - Có invariant
sold + remaining == capacitytrói hai biến. GiữaincrementAndGetvàdecrementAndGettồn tại một cửa sổ màsoldđã tăng cònremainingchưa giảm; reader đọc trúng cửa sổ đó thấy invariant bị vi phạm.
Lời giải: gom hai biến vào một holder immutable công bố qua một tham chiếu (mục 5.2), hoặc canh cả cụm bằng một khóa.
Q5Vì sao đổi hai HashMap trong BookingService thành ConcurrentHashMap không đủ để book thread-safe?▸
HashMap trong BookingService thành ConcurrentHashMap không đủ để book thread-safe?Vì ủy thác cho các thành phần thread-safe (delegation) chỉ đúng khi các biến độc lập. Ở đây book là một check-then-act bắc cầu qua trạng thái dùng chung, và invariant "không vượt capacity" trói sold của mỗi sự kiện với Event tương ứng.
Mỗi thao tác lẻ trên ConcurrentHashMap nguyên tử, nhưng cụm "đọc số đã bán, so với capacity, ghi lại" thì không — hai thread vẫn cùng qua kiểm tra rồi cùng ghi. Khi có compound action hoặc invariant bắc cầu, phải tự thêm một lớp khóa: đó là lý do Monitor Pattern mới là lời giải đúng cho BookingService.
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