Atomicity: compound action và race condition trong Java
count++ thật ra là ba thao tác đọc-cộng-ghi: read-modify-write, check-then-act, race condition khác data race ra sao, và giới hạn một-biến của atomic class.
TL;DR: Atomicity là một trong hai vấn đề mà shared mutable state gây ra (vấn đề còn lại là visibility): một cụm thao tác trông như một hành động nhưng thực ra gồm nhiều bước, và thread khác chen được vào giữa. Hai dạng compound action kinh điển là read-modify-write (count++ thật ra là đọc, cộng, ghi) và check-then-act (kiểm tra còn chỗ rồi mới bán). Race condition là khái niệm về tính đúng đắn; data race là khái niệm hình thức của Java Memory Model — hai thứ khác nhau. Atomic class cho bạn atomicity của một biến, không phải của invariant trải trên nhiều biến.
1. Bug oversell của BookingService hỏng ở bước nào?
Gốc của mọi bug concurrency là dữ liệu vừa được nhiều thread dùng chung vừa thay đổi được, và nó sinh ra hai vấn đề độc lập — atomicity và visibility. Bài này mổ vấn đề thứ nhất.
BookingService v0 của TicketFlow đọc số vé đã bán, kiểm tra còn chỗ, rồi ghi tăng một. Đêm mở bán, 2.000 người bấm nút cùng lúc, và sáng hôm sau database ghi nhận 503 vé bán cho 500 ghế. Bài này trả lời chính xác: hỏng ở bước nào, và vì sao mọi unit test đều xanh.
Hãy neo vào một hình ảnh quen. Hai nhân viên kho cùng làm việc trên một tờ giấy kiểm kê: liếc xem còn bao nhiêu, nếu còn thì đi lấy hàng, rồi sửa con số. Rắc rối nằm ở khoảng giữa lúc liếc và lúc sửa — người kia có thể đã lấy mất món cuối. Quy trình tự nó sai.
| Trong ví von | Trong máy |
|---|---|
| Tờ giấy kiểm kê dùng chung | Shared mutable state (sold) |
| Liếc xem còn hàng rồi mới đi lấy | Check-then-act |
| Đọc con số, cộng một, viết lại | Read-modify-write (count++) |
| Khoảng trống giữa lúc liếc và lúc sửa | Cửa sổ để thread khác chen ngang |
| Cả cụm "liếc — lấy — sửa" phải liền mạch | Compound action cần nguyên tử |
| Quy tắc "ai cầm bút thì người kia đợi" | Khóa (bài 08) |
2. Vì sao count++ không nguyên tử?
Giả sử ta đế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.
Đọc đoạn code dưới đây. bookingAttempts đang là 5, rồi hai thread cùng chạy ++bookingAttempts, mỗi thread đúng một lần. Giá trị cuối cùng là bao nhiêu? Viết ra con số bạn tin chắc, rồi đọc tiếp.
public class CountingBookingService {
private long bookingAttempts = 0;
public long getAttempts() { return bookingAttempts; }
public Booking book(String eventId, String userId) {
++bookingAttempts;
// ... phan con lai cua 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à ba thao tác rời rạc. Đây không phải lời nói suông — bytecode do javap -c in ra cho thấy đúng ba bước đó:
aload_0
dup
getfield bookingAttempts : J // (1) doc gia tri hien tai
lconst_1
ladd // (2) cong mot
putfield bookingAttempts : J // (3) ghi gia tri moi tro lai
Scheduler có quyền tạm dừng thread giữa bất kỳ hai lệnh nào trong số đó. Đâ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 đó. Với một thời điểm xui xẻo, cả hai thread cùng đọc thấy 5, cùng cộng thành 6, cùng ghi 6.
sequenceDiagram
participant A as Thread A
participant M as bookingAttempts
participant B as Thread B
A->>M: đọc → 5
B->>M: đọc → 5
A->>M: ghi 6
Note over M: giá trị 6
B->>M: ghi 6
Note over M: vẫn 6 — mất 1 lần tăngMột lần tăng bị 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ố đó sinh ID hay seatNumber duy nhất, thì hai thực thể trùng ID là 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. Race condition khác data race thế nào?
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ứ 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.
Data race hẹp và hình thức hơn, được Java Memory Model định nghĩa trong JLS §17.4.5: hai truy cập xung đột vào cùng một biến — cùng chạm một vị trí bộ nhớ, ít nhất một bên là ghi — mà giữa chúng không có quan hệ happens-before (quan hệ thứ tự mà bài Visibility sẽ mổ kỹ).
| Tiêu chí | Race condition | Data race |
|---|---|---|
| Thuộc trục | Tính đúng đắn của chương trình | Định nghĩa hình thức của JMM |
| Nguồn định nghĩa | Không có spec — khái niệm thiết kế | JLS §17.4.5 |
| Điều kiện | Kết quả phụ thuộc thứ tự xen kẽ | Hai truy cập xung đột, thiếu happens-before |
| Dính một mà không dính kia? | vector.contains rồi vector.add: không data race, vẫn race condition | Ghi thống kê vào biến thường, thread khác đọc để log xấp xỉ: có data race, chưa chắc sai nghiệp vụ |
Không phải mọi race condition đều là data race, và ngược lại. book của ta dính cả hai.
3.1 Check-then-act — quan sát bị vô hiệu hóa
Dạng race condition phổ biến nhất gọi là check-then-act: 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.
BookingService.book là ví dụ điển hình. Sự kiện concert-01 có capacity = 1, đã bán 0 vé, hai thread cùng phục vụ hai khách:
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 cùng đọc current = 0 trước khi ai kịp ghi, cùng kết luận "còn chỗ", cùng phát ra seat #1. Đ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: nó pass mọi test đơn luồng, ẩn nhiều tháng, rồi bùng đúng lúc tải cao nhất. (Ghi concurrent vào HashMap không đồng bộ còn sai theo một cách khác nữa — mất entry, hỏng bucket, hoặc ném exception bất ngờ.)
Để ý book chứa cả hai dạng: check-then-act ở bước kiểm tra, và read-modify-write ở bước đọc current rồi ghi current + 1. Cả hai đều là compound action — cụm thao tác mà để đúng thì phải chạy như một khối không thể chia cắt. Tính chất đó gọi là atomicity: A và B nguyên tử với nhau nếu, từ góc nhìn của thread chạy A, thì B hoặc đã xong hẳn, hoặc chưa bắt đầu — không có trạng thái "B đang dở".
4. Khi nào AtomicInteger là đủ, khi nào phải dùng khóa?
Với bộ đếm ở mục 2, lời giải nằm trong java.util.concurrent.atomic. Thay long bằng AtomicLong:
private final AtomicLong bookingAttempts = new AtomicLong();
// trong book():
bookingAttempts.incrementAndGet(); // doc-cong-ghi goi tron thanh mot thao tac nguyen tu
incrementAndGet gói trọn read-modify-write bằng một thao tác nguyên tử của CPU: trên x86-64 là một chỉ thị fetch-and-add (lock xadd), còn trên kiến trúc không có nó thì là một vòng lặp compare-and-swap — đọc giá trị cũ, thử ghi giá trị mới, thất bại thì thử lại. Bài Atomic & CAS sẽ mổ kỹ.
Cẩn thận một bước suy luận hay bị nhảy cóc ở đây: việc này chỉ vá đúng field đếm, không làm cả CountingBookingService trở nên thread-safe — thân book vẫn còn nguyên check-then-act và ghi HashMap không đồng bộ. Nếu class ngoài bộ đếm ra là stateless, thì khi ấy nó mới thật sự thread-safe. Quy tắc rút ra: ở đâu khả thi, hãy dùng đối tượng thread-safe có sẵn — 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.
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. Dashboard cần đọc số chỗ còn lại thật nhanh, nên ta cache nó thành một biến riêng bên cạnh sold. Yêu cầu nghiệp vụ đi kèm: tổng sold và remaining phải luôn luôn bằng capacity, ở mọi thời điểm mà bất kỳ thread nào quan sát.
Trong đoạn dưới, cả hai field đều là AtomicInteger, và mọi thao tác lẻ trên chúng đều nguyên tử. Class này có giữ được yêu cầu nghiệp vụ vừa nêu không? Nếu không, nó hỏng ở mấy chỗ, và ở đâu? Trả lời trước khi đọc tiếp.
// Vi du rut gon: service phuc vu dung MOT su kien, capacity = 100.
public class CachingBookingService {
private final AtomicInteger sold = new AtomicInteger(0);
private final AtomicInteger remaining = new AtomicInteger(100);
public Booking book(String userId) {
if (remaining.get() <= 0) throw new SoldOutException("concert-01");
int seat = sold.incrementAndGet();
remaining.decrementAndGet();
return new Booking("concert-01", userId, seat);
}
}
Class vẫn không thread-safe, vì hai lý do. Thứ nhất, 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. 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 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; thread khác đọc đúng lúc đó 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, nên phải được cập nhật trong cùng một thao tác nguyên tử. Nói gọn: atomic variable cho atomicity của một biến, không phải của invariant. Cơ chế quây được cả cụm là khóa — chủ đề bài volatile & synchronized.
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 đủ.
Một ghi chú về ranh giới: mọi thứ vừa mổ đều là atomicity của một cụm thao tác. Có đúng một trường hợp mà ngay cả một lần truy cập đơn lẻ cũng không nguyên tử — long/double không volatile có thể bị torn read (JLS §17.7). Vì cách vá rẻ nhất là volatile, chủ đề đó thuộc bài volatile & synchronized.
5. Liên hệ các bài khác
- Thread safety — nơi định nghĩa shared mutable state và đặt ra hai vấn đề độc lập mà bài này giải quyết vấn đề thứ nhất.
- Visibility — vấn đề thứ hai, hoàn toàn độc lập với atomicity. Đọc ngay sau bài này để thấy vì sao vá atomicity chưa đủ.
- 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 — khi một object thread-safe vẫn không cứu được compound action phía client, và kỹ thuật client-side locking mong manh ra sao.
6. 📚 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.
- Java Concurrency in Practice (Goetz et al.), chương 2 — nguồn gốc của định nghĩa race condition, compound action, và ví dụ bộ đếm.
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".
7. Tóm tắt
count++là ba thao tác (đọc, cộng, ghi) — bytecodegetfield / ladd / putfieldcho thấy rõ; 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.
- Cả hai đều là compound action: để đúng thì cả cụm phải chạy như một khối không chia cắt.
- 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ử. Vá bộ đếm bằng
AtomicLongkhông làm cả class thread-safe.
Atomicity mới là một nửa câu chuyện. Ngay cả khi không ai chen ngang một cụm thao tác nào, một thread vẫn có thể không bao giờ nhìn thấy thứ thread khác vừa ghi. Bài kế tiếp mổ vấn đề thứ hai đó.
8. 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? Chỉ ra hai chỗ hỏng khác nhau.▸
Q5Đổi bookingAttempts từ long sang AtomicLong có làm cả CountingBookingService trở nên thread-safe không? Vì sao?▸
Q6Một service chỉ có duy nhất một field long tổng doanh thu, được nhiều thread cộng dồn bằng revenue += amount. Chỉ có MỘT biến và không invariant nào ràng buộc nó — vì sao vẫn mất update?▸
Bài tiếp theo: Visibility — vì sao thread không thấy thay đổi của nhau
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