Hệ điều hành & Tiến trình/Race condition — vì sao count++ sai khi hai luồng đua nhau
23/28
Bài 23 / 28~12 phútĐồng bộ & Phối hợpMiễn phí lượt xem

Race condition — vì sao count++ sai khi hai luồng đua nhau

Vì sao count++ không nguyên tử, interleaving sinh ra kết quả sai thế nào, và vì sao bug race chỉ xuất hiện một phần triệu lần chạy — khó tái hiện nhất.

TL;DR: Một race condition xảy ra khi tính đúng đắn của chương trình phụ thuộc vào thứ tự đan xen (interleaving) các thao tác của nhiều luồng — mà thứ tự đó do scheduler quyết định, không do bạn. Gốc rễ: các thao tác trông "một bước" như count++ thực ra là ba bước máy — load giá trị, add 1, store lại. Scheduler có thể cắt ngang giữa ba bước (preemption). Khi hai luồng cùng load một giá trị cũ, cùng cộng, cùng store, một update bị đè mất — kết quả nhỏ hơn kỳ vọng. Bug này timing-dependent nên gần như không tái hiện trên máy dev nhưng nổ ở production tải cao. Cách trị là làm thao tác đó nguyên tử (atomic) hoặc bảo vệ bằng khoá — chủ đề bài sau.

Bạn viết một bộ đếm request đơn giản: một biến count, mỗi lần có request thì count++. Test trên máy: chạy một luồng, đếm 1 triệu lần, ra đúng 1.000.000. Deploy. Production có nhiều luồng xử lý song song, và cuối ngày con số báo cáo hụt vài nghìn so với số log thật. Bạn chạy lại y hệt trên máy với hai luồng, 10.000 lần — vẫn ra đúng. Bug biến mất mỗi khi bạn nhìn vào nó.

Vấn đề không nằm ở logic. Nó nằm ở một giả định ngầm: bạn tưởng count++một hành động không thể chia cắt. Bài này mổ giả định đó ra tới mức bytecode, cho hai luồng đua nhau, và chỉ đúng khoảnh khắc một update bị nuốt mất. Học xong, bạn giải thích được race sinh từ interleaving và chỉ đúng chỗ scheduler chen vào giữa count++.

1. Analogy — hai người cùng rút tiền một tài khoản

Tài khoản chung có 100 nghìn. Hai người, An và Bình, đứng ở hai cây ATM khác nhau, cùng muốn rút 100 nghìn. Máy ATM làm ba bước: đọc số dư (100k), kiểm tra và trừ (100k − 100k = 0), ghi số dư mới (0).

Nếu hai máy chạy đan xen xui xẻo: cả hai cùng đọc thấy 100k trước khi máy nào kịp ghi 0. An kiểm tra: 100k đủ, trừ, ghi 0. Bình cũng đã đọc 100k từ trước, kiểm tra: đủ, trừ, ghi 0. Kết quả: ngân hàng phát ra 200 nghìn nhưng số dư chỉ trừ một lần. Một lần rút bị "nuốt".

Điểm mấu chốt: cả hai đều làm đúng luật, không ai gian. Lỗi nằm ở chỗ ba bước không được làm liền một mạch — có kẽ hở giữa "đọc" và "ghi" để người kia chen vào với dữ liệu đã cũ.

Đời thường (ATM)Chương trình đa luồng
Số dư tài khoảnBiến chia sẻ count
An, BìnhHai luồng (thread)
Đọc số dưLoad count từ bộ nhớ vào thanh ghi
Trừ tiềnCộng/trừ trong thanh ghi
Ghi số dư mớiStore thanh ghi về bộ nhớ
Người kia chen giữa đọc và ghiScheduler preempt giữa load và store
Một lần rút bị nuốtLost update — một lần ++ biến mất
💡 Cách nhớ

Race condition = kết quả phụ thuộc ai chạy tới trước. Chừng nào một chuỗi thao tác đọc-rồi-ghi trên dữ liệu chung không liền một mạch, luôn có kẽ hở cho luồng khác chen vào bằng dữ liệu đã cũ.

2. count++ không phải một bước — mổ ở mức bytecode

Trong mã nguồn, count++ gọn một dòng. Nhưng CPU không có lệnh "tăng biến trong bộ nhớ lên 1 và đảm bảo không ai chen vào". Trình biên dịch phải dịch nó thành nhiều lệnh. Với một biến int chia sẻ (ví dụ một field static), javac sinh bytecode như sau. Xem bằng javap -c:

static int count = 0;

static void inc() {
    count++;
}
// javap -c cho method inc()
static void inc();
  Code:
     0: getstatic     #7   // Field count:I   <-- LOAD: doc count tu bo nho
     3: iconst_1                              //     day hang so 1 len stack
     4: iadd                                  // ADD: cong 1 (tren stack)
     5: putstatic     #7   // Field count:I   <-- STORE: ghi ket qua ve bo nho
     8: return

Ba bước then chốt, đúng như analogy ATM:

  1. getstatic — đọc giá trị hiện tại của count từ bộ nhớ.
  2. iadd — cộng 1 (làm việc trên bản sao trong stack/thanh ghi, chưa đụng bộ nhớ).
  3. putstatic — ghi kết quả về bộ nhớ.

Giữa bước 1 và bước 3 có một khoảng thời gian. Scheduler preemptive (Module 3) có quyền cắt ngang luồng đang chạy tại bất kỳ ranh giới lệnh nào — kể cả ngay sau getstatic, trước khi putstatic kịp chạy. Đúng khoảnh khắc đó, giá trị mới chưa được ghi ra, nhưng một luồng khác đã có thể đọc giá trị .

Vì sao ngay cả hardware cũng không cứu bạn miễn phí

Trên x86, có lệnh đơn inc [mem] tăng thẳng ô nhớ. Nhưng ngay cả lệnh đó cũng không nguyên tử với đa lõi trừ khi thêm prefix lock (lock inc) — vì read-modify-write đi qua cache của từng core. Trình biên dịch Java/C mặc định không phát lock cho count++ thường, vì nó tốn hơn nhiều lần. Nói cách khác: "một dòng code" gần như không bao giờ tương đương "một thao tác nguyên tử" — bạn phải yêu cầu tính nguyên tử một cách tường minh.

3. Interleaving nào làm mất update?

Với một luồng, ba bước chạy liền: load 0 → add → store 1 → load 1 → add → store 2... Không vấn đề gì.

Với hai luồng A và B cùng gọi inc(), scheduler có thể xếp các bước theo nhiều thứ tự khác nhau. Đa số thứ tự cho kết quả đúng. Nhưng có những thứ tự "xui" — và chỉ cần một lần trúng thứ tự xui là mất update:

Thời điểmLuồng ALuồng Bcount trong bộ nhớ
t1load → đọc 00
t2load → đọc 00
t3add → 0+1=10
t4add → 0+1=10
t5store → ghi 11
t6store → ghi 11

Hai luồng cùng gọi inc() — đáng lẽ count phải thành 2. Nhưng vì cả A và B đều load giá trị 0 trước khi luồng kia kịp store, cả hai cùng tính "0+1=1" và cùng ghi 1. Lần tăng của một luồng bị luồng kia đè mất (lost update). Đây là dạng race phổ biến nhất: atomicity violation — một chuỗi lẽ ra phải nguyên khối lại bị chia cắt.

sequenceDiagram
    participant A as Luong A
    participant M as count (bo nho)
    participant B as Luong B
    A->>M: load (doc 0)
    B->>M: load (doc 0)
    Note over A,B: ca hai deu thay 0
    A->>A: add -> 1
    B->>B: add -> 1
    A->>M: store 1
    B->>M: store 1
    Note over M: count = 1, khong phai 2<br/>mot update bi nuot

Xác suất trúng đúng thứ tự xui rất nhỏ trong một lần, nhưng khi lặp hàng triệu lần dưới tải cao, nó chắc chắn xảy ra — chỉ là bạn không đoán được lần nào.

Lost update ở trên thuộc họ race phổ biến nhất — atomicity violation: một chuỗi lẽ ra nguyên khối bị chia cắt. Nhưng còn một họ thứ hai, order violation: tính đúng đắn phụ thuộc thứ tự giữa các luồng mà không có gì đảm bảo thứ tự đó. Ví dụ luồng A khởi tạo một object rồi gán vào field data, luồng B đọc data để dùng — nếu thiếu đồng bộ ép "A gán xong rồi B mới đọc", B có thể chạy trước và thấy data vẫn là null (hoặc một object nửa vời) rồi crash. Khác biệt then chốt: ở atomicity violation mỗi bước bị chen ngang giữa chừng; ở order violation mỗi thao tác tự nó có thể đã nguyên tử, chỉ trình tự giữa chúng là sai.

4. Demo Java — nhìn tận mắt con số sai

Chương trình dưới cho hai luồng cùng tăng một bộ đếm 1 triệu lần mỗi luồng. Kết quả đúng phải là 2.000.000.

public class RaceDemo {
    static int count = 0;   // shared, KHONG bao ve

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 1_000_000; i++) {
                count++;    // 3 buoc: load / add / store
            }
        };

        Thread a = new Thread(task);
        Thread b = new Thread(task);
        a.start();
        b.start();
        a.join();           // cho ca hai luong xong
        b.join();

        System.out.println("Expected: 2000000");
        System.out.println("Actual:   " + count);
    }
}
Thử đoán trước khi chạy

Trước khi chạy: bạn nghĩ dòng Actual in ra bao nhiêu? Đúng 2000000? Nhỏ hơn? Và quan trọng hơn: chạy lại lần thứ hai, con số đó có giống hệt lần đầu không?

Chạy thử, một kết quả điển hình:

Expected: 2000000
Actual:   1348925

Chạy lại lần nữa: 1521067. Lần nữa: 1789334. Mỗi lần một con số khác, và luôn nhỏ hơn hoặc bằng 2 triệu, không bao giờ vượt. Con số thiếu chính là số lần update bị nuốt. Kết quả không xác định (non-deterministic) — dấu hiệu kinh điển của race.

Điểm cần thấm: chương trình này sai, nhưng nó không crash, không ném exception, không có dòng code nào "trông sai". Nó chỉ âm thầm cho ra con số lệch. Đó là điều khiến race nguy hiểm hơn một NullPointerException — lỗi im lặng, dữ liệu hỏng dần.

5. Vì sao race condition khó tái hiện?

Đây là loại bug khó chịu nhất, vì nó vi phạm kỳ vọng "chạy lại y hệt thì lỗi y hệt". Ba lý do:

Timing-dependent. Bug chỉ nổ khi preemption rơi đúng vào kẽ hở giữa load và store. Trên máy dev tải nhẹ, mỗi luồng thường chạy trọn count++ trước khi bị cắt — kẽ hở hiếm khi bị chen. Trên production nhiều core, nhiều luồng, cache tranh chấp, xác suất trúng tăng vọt.

Quan sát làm nó biến mất. Thêm một dòng println để debug, hoặc chạy dưới debugger, làm chậm luồng lại và đổi timing — thường khiến bug ẩn đi. Vì thế race condition được gọi là Heisenbug: cứ nhìn vào là nó trốn. Bạn không thể "debug bằng cách thêm log" như bug thường.

Không tái hiện được theo yêu cầu. Bạn không ép được scheduler xếp đúng thứ tự xui. Chạy 10.000 lần có thể không trúng lần nào; production chạy vài tỷ lần thì trúng hàng nghìn lần.

Hệ quả thực hành quan trọng: không thể dựa vào test để phát hiện race. Test pass không chứng minh code thread-safe — nó chỉ chứng minh lần chạy đó không trúng thứ tự xui. An toàn concurrency phải đến từ thiết kế (làm thao tác nguyên tử, hoặc không chia sẻ dữ liệu), không từ việc chạy thử nhiều lần.

Test xanh không có nghĩa là thread-safe

Một suite test đa luồng chạy 1000 lần đều pass không đảm bảo không có race — nó chỉ nói 1000 lần đó không trúng interleaving xấu. Công cụ đúng để soi race là phân tích tĩnh, stress test có chèn nhiễu timing (ví dụ Thread.yield() ngẫu nhiên), hoặc tốt nhất: thiết kế để race không thể xảy ra ngay từ đầu.

6. Áp dụng vào code của bạn

Race không chỉ ở count++. Nhận diện nó bằng câu hỏi: "Có dữ liệu nào bị nhiều luồng đọc-ghi mà không được bảo vệ không?"

Check-then-act. Mẫu "kiểm tra rồi hành động" là race điển hình, vì giữa "kiểm tra" và "hành động" có kẽ hở:

// RACE: giua containsKey va put, luong khac co the da put
if (!map.containsKey(key)) {   // check
    map.put(key, compute());   // act -- co the ghi de update cua luong khac
}

Lazy init cũng là check-then-act: if (instance == null) instance = new X(); — hai luồng cùng thấy null, cùng tạo, ra hai instance.

Read-modify-write. Mọi thao tác đọc giá trị cũ rồi ghi giá trị mới đều không nguyên tử: count++, balance -= amount, list.add() trên list không đồng bộ, cập nhật một field dựa trên chính nó.

Quy tắc thực hành:

  • Xác định shared mutable state — dữ liệu vừa bị chia sẻ giữa các luồng vừa thay đổi. Đó là chỗ duy nhất race sống được.
  • Nếu một chuỗi thao tác phải chạy "toàn bộ hoặc không gì cả" trên dữ liệu chung, nó là một critical section (vùng găng) và cần được bảo vệ — bài sau chỉ cách.
  • Ưu tiên loại bỏ chia sẻ trước khi nghĩ tới khoá: biến cục bộ, dữ liệu bất biến (immutable), hoặc mỗi luồng một bản riêng. Không chia sẻ thì không có race — cách rẻ nhất và an toàn nhất.

7. Pitfall tổng hợp

Nhầm 1 — tưởng thao tác "một dòng" là nguyên tử:

count++;              // 3 buoc, KHONG nguyen tu
flag = true;          // 1 buoc voi boolean, nhung...
balance += amount;    // read-modify-write, KHONG nguyen tu

✅ Chỉ những lệnh đọc/ghi đơn một biến (như gán flag = true) mới nguyên tử theo mô hình bộ nhớ Java — và cũng chỉ đúng cho int / boolean / kiểu tham chiếu; riêng longdouble không khai báo volatile thì theo JLS §17.7 một lần ghi có thể bị xé làm hai nửa 32-bit, luồng khác đọc trúng lúc đó thấy giá trị lai nửa cũ nửa mới. Mọi read-modify-write (++, +=, --) đều là nhiều bước và cần bảo vệ. Đừng đoán bằng số dòng code — đoán bằng số lần chạm bộ nhớ.

Nhầm 2 — "chạy test nhiều lần không lỗi nên code thread-safe":

✅ Race timing-dependent; test pass chỉ nói lần đó may. Thread-safety là thuộc tính của thiết kế, phải chứng minh bằng lập luận (dữ liệu này được bảo vệ bởi khoá nào / là immutable / không chia sẻ), không bằng số lần test xanh.

Nhầm 3 — thêm println để debug rồi thấy hết lỗi, tưởng đã sửa xong:

println (hay debugger) làm chậm luồng, đổi timing, che bug. Bug vẫn còn nguyên; bạn chỉ làm nó khó trúng hơn. Gỡ log ra là nó quay lại ở production. Đừng nhầm "che triệu chứng" với "sửa gốc".

8. 📚 Deep Dive — atomicity violation

📚 Deep Dive (tuỳ chọn)

Nguồn chính:

  • OSTEP — Chapter 32: Concurrency Bugs (Arpaci-Dusseau) — phân loại bug concurrency thành atomicity-violation (chuỗi lẽ ra nguyên khối bị chia cắt — chính là count++ mất update) và order-violation (thứ tự giữa các luồng bị đảo). Đọc mục 32.2 cho ví dụ atomicity-violation kinh điển trong MySQL.
  • OSTEP — Chapter 28: Locks — định nghĩa critical section và đặt nền cho cách trị (bài 02 của module này).

Ghi chú: Bug race không phải chuyện hàn lâm — nghiên cứu của OSTEP dẫn khảo sát bug thực tế cho thấy phần lớn lỗi concurrency non-deadlock rơi vào đúng hai loại atomicity/order-violation ở trên. Hiểu hai loại này giúp bạn đặt tên được cái sai khi đọc một report bug lạ, thay vì mò mẫm.

9. Liên hệ các bài khác

10. Tóm tắt

  • Race condition là khi kết quả phụ thuộc thứ tự đan xen (interleaving) các thao tác của nhiều luồng — thứ tự do scheduler quyết định, không do bạn.
  • count++ba bước máy load / add / store (thấy rõ qua javap -c: getstatic / iadd / putstatic), không nguyên tử.
  • Scheduler preemptive cắt ngang giữa các bước → hai luồng cùng đọc giá trị cũ, cùng ghi → lost update (một lần tăng biến mất).
  • Demo Java hai luồng đếm cho kết quả non-deterministic, luôn nhỏ hơn kỳ vọng, khác nhau mỗi lần chạy.
  • Race timing-dependent, là Heisenbug: thêm log/debugger đổi timing làm nó ẩn; gần như không tái hiện trên máy dev nhưng nổ ở production tải cao.
  • Không dựa vào test để bắt race. An toàn phải đến từ thiết kế: làm thao tác nguyên tử, khoá, hoặc — tốt nhất — không chia sẻ dữ liệu.
  • Chỗ duy nhất race sống được là shared mutable state. Nhận diện check-then-act và read-modify-write.

11. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao count++ lại có thể sai khi có hai luồng, dù nó chỉ là "một dòng code"? Mô tả ba bước bên dưới.

count++ gọn một dòng nhưng CPU phải làm ba bước tách rời: load (đọc giá trị hiện tại từ bộ nhớ vào thanh ghi), add (cộng 1 trên bản sao trong thanh ghi), store (ghi kết quả về bộ nhớ). Thấy rõ qua bytecode getstatic / iadd / putstatic.

Giữa bước load và store có một khoảng thời gian. Scheduler preemptive có quyền cắt ngang luồng tại ranh giới lệnh — kể cả ngay sau load, trước khi store. Nếu một luồng khác load cùng giá trị cũ trong khoảng đó, cả hai sẽ tính cùng kết quả và cùng ghi đè, làm một lần tăng biến mất. "Một dòng code" không đồng nghĩa "một thao tác nguyên tử".

Q2
Trong demo hai luồng đếm tới 2 triệu, vì sao kết quả luôn nhỏ hơn hoặc bằng 2000000 mà không bao giờ vượt?

Lỗi duy nhất có thể xảy ra là lost update: hai luồng cùng đọc một giá trị cũ, cùng +1, cùng ghi lại cùng một giá trị — nên hai lần ++ chỉ làm count tăng một đơn vị thay vì hai. Mỗi lần trúng interleaving xấu, ta mất một đơn vị so với kỳ vọng.

Không có cơ chế nào khiến count tăng thừa: mỗi lệnh store chỉ ghi "giá trị đã đọc + 1", không bao giờ ghi nhiều hơn. Vì thế sai số luôn về phía thiếu. Con số hụt chính bằng số lần update bị nuốt, và vì timing ngẫu nhiên nên nó khác nhau mỗi lần chạy (non-deterministic).

Q3
Một đồng nghiệp nói: "Tôi chạy test đa luồng 500 lần đều pass, code thread-safe rồi." Phản biện.

Test pass 500 lần chỉ chứng minh 500 lần đó scheduler không xếp đúng interleaving xấu — không chứng minh interleaving xấu không thể xảy ra. Race là timing-dependent: xác suất trúng trong một lần rất nhỏ, nên trên máy dev tải nhẹ có thể chạy hàng nghìn lần không trúng, trong khi production chạy hàng tỷ lần thì trúng hàng nghìn lần.

Thread-safety là thuộc tính của thiết kế, phải chứng minh bằng lập luận: dữ liệu này được bảo vệ bởi khoá nào, hay là immutable, hay không chia sẻ giữa luồng. Không có lập luận đó thì số lần test xanh không nói lên điều gì về an toàn.

Q4
Vì sao race condition được gọi là "Heisenbug", và điều đó nói gì về cách debug nó?

Heisenbug là bug biến mất khi bạn quan sát nó. Với race, thêm println hoặc chạy dưới debugger làm chậm luồng lại và đổi timing — thường khiến mỗi luồng chạy trọn thao tác trước khi bị cắt, nên kẽ hở race hiếm bị chen và bug ẩn đi.

Hệ quả: không thể debug race bằng cách thêm log rồi thấy hết lỗi — bạn chỉ che triệu chứng, bug vẫn nguyên và sẽ quay lại ở production khi gỡ log. Phải suy luận về interleaving trên giấy (vẽ hai cột load/add/store), dùng công cụ chèn nhiễu timing để ép race lộ ra, hoặc thiết kế lại để race không thể xảy ra.

Q5
Đoạn if (!map.containsKey(k)) map.put(k, v); có phải race không? Giải thích bằng khái niệm bài này.

Có — đây là mẫu check-then-act, một dạng atomicity violation. "Check" (containsKey) và "act" (put) là hai thao tác tách rời; giữa chúng có kẽ hở. Hai luồng cùng chạy có thể cùng thấy containsKey trả false (key chưa có), rồi cùng put — luồng sau ghi đè giá trị luồng trước, hoặc chạy logic khởi tạo hai lần.

Về bản chất giống hệt count++: một chuỗi lẽ ra phải chạy nguyên khối ("nếu chưa có thì đặt") lại bị chia cắt để luồng khác chen vào với dữ liệu đã cũ. Cách trị: dùng thao tác nguyên tử sẵn có (putIfAbsent / computeIfAbsent) hoặc bảo vệ cả chuỗi bằng khoá — chủ đề bài sau.

Q6
Race ở count++atomicity violation hay order violation? Phân biệt hai loại và cho một ví dụ order violation.

Race ở count++atomicity violation: một chuỗi lẽ ra phải chạy nguyên khối (load / add / store) bị chia cắt để luồng khác chen vào, làm mất update. Cái sai không nằm ở thứ tự giữa các luồng, mà ở chỗ một khối lẽ ra nguyên khối lại không được giữ nguyên tử.

Order violation khác hẳn: hai thao tác chạy sai thứ tự mong đợi giữa các luồng. Ví dụ kinh điển: luồng A khởi tạo một object rồi gán vào field data, luồng B đọc data để dùng — nhưng nếu thiếu đồng bộ ép trình tự "A khởi tạo xong rồi B mới đọc", B có thể chạy trước và thấy data vẫn là null hoặc một object nửa vời rồi crash. Ở đây mỗi thao tác tự nó có thể đã nguyên tử; cái sai là trình tự A-trước-B không được đảm bảo. OSTEP Ch32 (mục Deep Dive) cho thấy phần lớn bug concurrency non-deadlock rơi vào đúng hai loại này — gọi đúng tên giúp chọn đúng cách trị: atomic/khoá cho atomicity violation, còn hàng rào thứ tự hoặc biến điều kiện cho order violation.

Bài tiếp theo: Mutex & atomic — bảo vệ critical section

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