Java Internals & Concurrency/Atomicity: compound action và race condition trong Java
5/47
Bài 5 / 47~15 phútConcurrency cơ bảnMiễn phí lượt xem

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?

Nhớ lại Shared mutable state

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í vonTrong máy
Tờ giấy kiểm kê dùng chungShared mutable state (sold)
Liếc xem còn hàng rồi mới đi lấyCheck-then-act
Đọc con số, cộng một, viết lạiRead-modify-write (count++)
Khoảng trống giữa lúc liếc và lúc sửaCửa sổ để thread khác chen ngang
Cả cụm "liếc — lấy — sửa" phải liền mạchCompound 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.

Thử đoán

Đọ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ăng

Mộ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 conditionData race
Thuộc trụcTính đúng đắn của chương trìnhĐịnh nghĩa hình thức của JMM
Nguồn định nghĩaKhông có spec — khái niệm thiết kếJLS §17.4.5
Điều kiệnKế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 conditionGhi 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-01capacity = 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 soldremaining 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.

Thử đoán

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 incrementAndGetdecrementAndGet 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

📚 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) — bytecode getfield / ladd / putfield cho 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 AtomicLong khô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

Tự kiểm tra
Q1
Vì 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ó?
Ở mức bytecode lẫn mức CPU, count++ là ba thao tác rời rạc: đọc giá trị hiện tại (getfield), cộng một (ladd), ghi giá trị mới trở lại (putfield) — một read-modify-write điển hình. Scheduler có thể tạm dừng thread giữa bất kỳ hai bước nào. Nếu hai thread cùng đọc thấy 5 trước khi ai kịp ghi, cả hai cùng tính ra 6 và cùng ghi 6 — một lần tăng bốc hơi (lost update). Bộ đếm lệch thì còn chấp nhận được, nhưng nếu giá trị đó sinh ID hay seat number duy nhất thì hai thực thể trùng ID là lỗi toàn vẹn dữ liệu nghiêm trọng.
Q2
Check-then-act vì sao không atomic? Điều gì làm cho quan sát trở nên vô giá trị?
Check-then-act gồm hai bước tách rời: quan sát một điều kiện, rồi hành động dựa trên quan sát đó. Giữa hai bước tồn tại một cửa sổ thời gian mà thread khác có thể chen vào và làm thay đổi chính điều kiện vừa quan sát — quan sát bị vô hiệu hóa. Trong BookingService, hai thread cùng đọc thấy còn một chỗ, cùng kết luận bán được, và cùng phát ra vé cho một ghế. Để đúng, cả cụm quan-sát-rồi-hành-động phải được quây thành một khối nguyên tử mà không thread nào chen vào giữa được.
Q3
Phâ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ụ.

Q4
CachingBookingService 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.
Vì atomic variable chỉ cho atomicity của một biến, không cho atomicity của invariant. Chỗ hỏng thứ nhất: cụm if remaining.get() rồi mới incrementAndGet vẫn là check-then-act không nguyên tử — nhiều thread cùng vượt qua bước kiểm tra rồi cùng tăng, bán vượt capacity. Chỗ hỏng thứ hai: invariant sold + remaining == capacity ràng buộc hai biến với nhau; giữa hai lệnh cập nhật tồn tại một cửa sổ mà sold đã tăng còn remaining chưa giảm, thread đọc đúng lúc đó thấy invariant vỡ. Khi nhiều biến cùng tham gia một invariant, chúng phải được cập nhật trong cùng một thao tác nguyên tử — cần khóa quây cả cụm.
Q5
Đổi bookingAttempts từ long sang AtomicLong có làm cả CountingBookingService trở nên thread-safe không? Vì sao?
Không. AtomicLong chỉ làm cho phép tăng bộ đếm trở nên nguyên tử, tức vá đúng một field. Thân method book vẫn còn nguyên hai lỗi đã mổ ở mục 3: cụm check-then-act trên sold, và việc ghi concurrent vào một HashMap không đồng bộ. Lập luận 'state của class nằm trong một object thread-safe nên cả class thread-safe' chỉ đúng khi class ngoài object đó ra là stateless và không áp thêm invariant nào lên giá trị của nó. Đây là bẫy thường gặp khi chép lập luận từ ví dụ bộ đếm thuần túy sang một class có state khác.
Q6
Mộ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?
Vì số lượng biến không phải điều quyết định; điều quyết định là phép ghi có đọc giá trị cũ hay không. revenue += amount là read-modify-write: đọc revenue, cộng amount, ghi lại. Hai thread cùng đọc một giá trị cũ rồi lần lượt ghi đè, và lần cộng của thread đầu bốc hơi — y hệt count++. Một biến duy nhất không cứu được, vì cửa sổ chen ngang nằm giữa bước đọc và bước ghi của chính biến đó. Muốn đúng, phép cộng dồn phải trở thành một thao tác nguyên tử duy nhất, chứ không phải ba bước rời rạc.

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

Đặt 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