Thread Safety: shared mutable state và bốn chiến lược
Định nghĩa thread safety qua invariant và postcondition, vì sao đối tượng stateless luôn an toàn, và bản đồ bốn chiến lược xử lý shared mutable state.
TL;DR: Viết concurrent code đúng quy về một việc: quản lý truy cập vào shared mutable state. Thiếu một trong hai tính 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 như nhau. Một class là thread-safe nếu nó giữ đúng invariant và postcondition dưới mọi cách xen kẽ của scheduler, mà phía gọi không cần đồng bộ thêm. Từ shared mutable state sinh ra hai vấn đề độc lập — atomicity và visibility — và có đúng bốn hướng gỡ: confinement, immutability, synchronization, delegation.
1. Vì sao viết concurrent code quy về quản lý shared mutable state?
Như bài Process và Thread đã chỉ ra, 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 rồi trang bị công cụ tạo, dừng và phối hợp chúng. Chính sự dùng chung kia, nếu không phối hợp cẩn thận, sinh ra những lỗi khó đoán.
Đêm mở bán, TicketFlow có 500 ghế; sáng hôm sau hệ thống đã in ra 503 vé. Không một dòng code nào sai khi chạy đơn luồng. Học xong bài này, bạn giải thích được vì sao một class là thread-safe hay không, bằng đúng hai công cụ: invariant và postcondition.
Luận điểm trung tâm 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 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. Thiếu một trong hai 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ả.
Cũng cần 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 hay một message listener, 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 vẫn rơi vào tay bạn.
Ta làm việc với BookingService của capstone TicketFlow, phiên bản v0, viết để chạy đúng khi chỉ có một thread.
// TicketFlow v0 — single-thread baseline. CHUA thread-safe (co y).
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("Su kien da ton tai: " + event.id());
}
sold.put(event.id(), 0); // invariant: da register thi co entry sold
}
public Booking book(String eventId, String userId) {
Event event = events.get(eventId);
if (event == null) {
throw new IllegalArgumentException("Khong co su kien: " + eventId);
}
int current = sold.getOrDefault(eventId, 0); // (1) doc so ve da ban
if (current >= event.capacity()) { // (2) con cho khong?
throw new SoldOutException(eventId);
}
sold.put(eventId, current + 1); // (3) ghi lai so ve da ban
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 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".
Hãy neo bài toán vào một hình ảnh quen. Một quầy vé có một tấm bảng chung ghi số ghế đã bán, và nhiều nhân viên cùng phục vụ khách. Tấm bảng ấy vừa dùng chung vừa sửa được — đó là toàn bộ nguồn cơn.
| Trong ví von | Trong máy |
|---|---|
| Tấm bảng chung ở quầy | Shared mutable state (sold) |
| Nhiều nhân viên cùng phục vụ | Nhiều thread gọi cùng một object |
| "Số trên bảng không vượt sức chứa" | Invariant của class |
| "Bán xong thì số phải tăng đúng một" | Postcondition của method |
| Thứ tự ngẫu nhiên ai chạm bảng trước | Interleaving do scheduler quyết định |
2. Thread safety là gì?
Cốt lõi của mọi định nghĩa tử tế nằm ở correctness — tính đúng đắn. Một class đúng nếu nó tuân thủ đặc tả của nó: các invariant ràng buộc trạng thái, 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".
Từ đó:
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 đề 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ẽ, kể cả 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à class đó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 — giữa contains và add vẫn có cửa sổ để thread khác chen vào. Cái bẫy này tên là check-then-act, và bài Atomicity mổ kỹ nó; cách vá mong manh của nó — client-side locking — thuộc bài Delegation & concurrent collections.
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à 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 ghe dau phu thu VIP
}
}
Class này không có field nào. Mọi dữ liệu nó cần đều sống trên stack của thread đang gọi, mà stack thì riêng cho từng thread — không chia sẻ gì thì không có gì để tranh.
Nhưng chữ "stateless" cần một điều kiện kèm theo, và điều kiện đó dễ trượt hơn ta tưởng.
Class dưới đây cũng không có field nào. Theo lập luận vừa rồi, nó có thread-safe không? Trả lời trước khi đọc tiếp.
class StatelessLooking {
public void f(List<String> xs) {
if (!xs.contains("a")) xs.add("a");
}
}
Không. 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 kiểm-tra-rồi-thêm chạy trên state của caller và vẫn dính race. Nói cách khác, "stateless" chỉ miễn nhiễm khi object vừa không giữ state mutable, vừa không chạm tới shared mutable state bên ngoài — một static mutable, một singleton không thread-safe, hay một object mutable được truyền vào rồi bị sửa.
3. Atomicity và visibility khác nhau thế nào?
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. Khi đó shared mutable state sinh ra hai vấn đề, và điều quan trọng nhất cần nắm ngay từ đầu là chúng độc lập với nhau: vá được cái này không có nghĩa đã vá cái kia.
Atomicity — một cụm thao tác có bị xen ngang giữa chừng hay không. Một cụm nhỏ như "đọc sold, kiểm tra còn chỗ, ghi sold + 1" trông là một hành động, thực ra là ba. Nếu thread khác chen vào giữa chừng, invariant vỡ. Đây là vấn đề của BookingService.book ở trên. Bài Atomicity mổ xẻ nó.
Visibility — thread này có thấy thay đổi của thread kia không. Phản trực giác hơn nhiều: ngay cả khi không ai chen ngang, một thread ghi giá trị mới vẫn có thể không bao giờ hiển thị với thread khác, vì compiler, JIT và CPU được phép cache và sắp xếp lại thao tác bộ nhớ. Bài Visibility mổ xẻ nó.
Nhầm lẫn hai vấn đề này là nguồn gốc của phần lớn code concurrency sai một cách tinh vi — điển hình là tin rằng volatile (vốn chỉ lo visibility) sẽ làm cho count++ trở nên nguyên tử.
Điều an ủi là dù hai vấn đề có khác nhau, cả hai đều mọc từ một gốc, nên cũng chỉ có bấy nhiêu hướng để gỡ.
flowchart TB
SMS["Shared mutable state<br/>gốc của mọi bug concurrency"]
SMS --> AT["Vấn đề 1: Atomicity<br/>compound action bị xen ngang"]
SMS --> VI["Vấn đề 2: Visibility<br/>không thấy thay đổi của thread khác"]
AT --> ST["Bốn chiến lược<br/>gỡ shared mutable state"]
VI --> ST
ST --> C1["Confinement"]
ST --> C2["Immutability"]
ST --> C3["Synchronization"]
ST --> C4["Delegation"]4. Bốn chiến lược xử lý shared mutable state
Mỗi hướng nhắm vào một chữ trong cụm "shared mutable state".
Confinement — đừng chia sẻ. Dữ liệu chỉ một thread chạm tới thì không có gì để tranh, và tự động an toàn ngay cả khi bản thân không thread-safe. SeatPriceCalculator an toàn chính nhờ điều này. Bài Confinement đi từ stack tới ThreadLocal tới ScopedValue.
Immutability — đừng cho thay đổi. Nếu trạng thái không bao giờ đổi sau khi khởi tạo và mọi field đều final, 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ị. (Thiếu final, object chỉ effectively immutable và vẫn cần được công bố an toàn — bài 07 mổ kỹ.) Event và Booking của TicketFlow là record immutable chính vì vậy. Xem bài Immutability.
Synchronization — canh gác truy cập. Khi 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ó cả một phổ công cụ, 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, ở bài volatile & synchronized.
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 component đã 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ỡ — chủ đề bài Delegation & concurrent collections.
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.
5. 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:
- Atomicity — vấn đề thứ nhất, và là nơi bug oversell của
BookingServicev0 được mổ xẻ tới tận bytecode. - Visibility — vấn đề thứ hai: thread không thấy giá trị thread khác vừa ghi. Độc lập hoàn toàn với atomicity — vá cái này không vá cái kia.
- Confinement — chiến lược thứ nhất: triệt tính "shared". Đọc ngay sau hai bài trên để 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ền safe publication.
- 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.
6. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JLS §17.4 — Memory Model — nơi Java định nghĩa hình thức thế nào là một chương trình đa luồng đúng.
- Java Concurrency in Practice (Goetz et al.), chương 2 — nguồn gốc của định nghĩa thread safety dùng trong bài, và ví dụ stateless servlet.
Ghi chú: Định nghĩa thread safety trong bài cố tình tránh mọi hình thức hóa. Điều đáng nhớ không phải câu chữ, mà là hai câu hỏi nó buộc bạn đặt ra trước mỗi mảnh state: "cái này có buộc phải shared không?" và "có buộc phải mutable không?".
7. Tóm tắt
- Thread safety = giữ invariant và postcondition đúng dưới mọi cách xen kẽ, không cần caller đồng bộ thêm.
- Gốc của mọi bug concurrency là shared mutable state — thiếu một trong hai tính từ thì rắc rối phần lớn biến mất.
- Yêu cầu thread safety thường do framework áp lên bạn, không phải do bạn tự tạo thread.
- Đối tượng stateless luôn thread-safe, nhưng "stateless" phải bao gồm cả việc không chạm shared mutable state bên ngoài.
- Object thread-safe không làm cho tổ hợp nhiều lời gọi lên nó tự động nguyên tử — bẫy
Vectorput-if-absent. - Shared mutable state sinh ra hai vấn đề độc lập: atomicity (bị xen ngang) và visibility (không thấy thay đổi).
- Bốn chiến lược gỡ: confinement, immutability, synchronization, delegation — thường phối hợp cả bốn trong một class.
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. Nhưng trước khi chọn, phải hiểu rõ hai vấn đề mà chúng gỡ. Bài kế tiếp mổ vấn đề thứ nhất: atomicity.
8. Tự kiểm tra
Q1Vì sao định nghĩa thread safety lại nhấn mạnh cụm 'bất kể runtime lập lịch hay xen kẽ thế nào'? Bỏ cụm đó đi thì định nghĩa hỏng ở chỗ nào?▸
Q2Vì sao cả hai tính từ trong 'shared mutable state' đều cần thiết? Nêu ví dụ dữ liệu thiếu một trong hai và giải thích vì sao nó an toàn.▸
Q3SeatPriceCalculator không có field nào và thread-safe, còn StatelessLooking cũng không có field nào nhưng lại không thread-safe. Sự khác nhau nằm ở đâu?▸
Q4Viết ra invariant và postcondition của BookingService.book. Với mỗi cái, chỉ ra một cách xen kẽ hai thread làm nó vỡ.▸
Q5Vì sao atomicity và visibility được gọi là hai vấn đề độc lập? Hệ quả thực tế của việc nhầm lẫn chúng là gì?▸
Q6Vì sao một class chưa đúng khi chạy đơn luồng thì không thể nào thread-safe? Lập luận này dựa trên điều gì?▸
Bài tiếp theo: Atomicity — compound action và race condition
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
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