← Về danh sách
Điện toán & Nhúng
#경쟁상태#동시성#임계구역#뮤텍스#126회#125회
Cập nhật lần cuối · 2026-09-06

Điều kiện tranh chấp (Race Condition)

1. Tổng quan

A. Định nghĩa

Điều kiện tranh chấp (Race Condition) là tình huống lỗi trong đó khi hai hay nhiều tiến trình, luồng đồng thời truy cập tài nguyên dùng chung, kết quả cuối cùng thay đổi tùy theo thứ tự (thời điểm) thực thi của chúng. Nói cách khác, đó là trạng thái mà tính đúng đắn của chương trình phụ thuộc vào sự ngẫu nhiên không thể kiểm soát là 'ai thực thi trước'.

Lý do điều kiện tranh chấp đặc biệt nguy hiểm là 'không biết khi nào sẽ phát nổ, thậm chí khó tái hiện'. Khi nhiều luồng đồng thời đọc và ghi cùng một biến, kết quả mỗi lần mỗi khác tùy theo bộ lập lịch chen từng luồng vào thực thi ở thời điểm nào. Ví dụ, giả sử hai luồng đồng thời rút 10 won từ tài khoản có số dư 100 won. Mỗi lần rút gồm ba bước 'đọc số dư hiện tại → trừ 10 → ghi lại', và nếu hai luồng gần như đồng thời đọc được 100 thì mỗi bên tính ra 90 rồi ghi, dẫn đến việc dù rút hai lần nhưng số dư là 90 chứ không phải 80. 10 won đã biến mất vào hư không.

Điều quái ác của vấn đề này là nó chỉ xảy ra 'thỉnh thoảng'. Trong phần lớn lần thực thi, thứ tự tình cờ khớp nên hoạt động bình thường, và chỉ trong những trường hợp cực hiếm khi thời điểm cụ thể trùng nhau mới phát sinh lỗi. Vì vậy, ở môi trường phát triển và kiểm thử thì ổn nhưng lại phát nổ ở môi trường vận hành tải cao, mà cũng chỉ lác đác, nên rất khó tìm nguyên nhân. Do tính không tất định (non-determinism) này, điều kiện tranh chấp được gọi là trường hợp tiêu biểu của 'Heisenbug (lỗi biến mất khi cố quan sát)'.

Nguyên nhân gốc là thao tác cập nhật gồm nhiều lệnh không nguyên tử (atomic) nên luồng thực thi khác có thể chen vào giữa. Đoạn mã mà sự chen ngang như vậy phá vỡ tính nhất quán dữ liệu được gọi là vùng găng (Critical Section), và cốt lõi của việc giải quyết vấn đề là bảo vệ vùng găng này để mỗi lần chỉ một luồng thực thi đi qua.

Ở đây cần chỉ ra một hiểu lầm phổ biến. Suy nghĩ "một dòng mã là nguyên tử" là sai. Ngay cả một dòng balance = balance - 10; trong ngôn ngữ bậc cao, khi biên dịch cũng bị chia thành nhiều lệnh máy 'đọc giá trị từ bộ nhớ vào thanh ghi → trừ → ghi vào bộ nhớ', và bộ lập lịch có thể chen luồng khác vào giữa. Hơn nữa, trên đa lõi, do bộ nhớ đệm CPU và sắp xếp lại bộ nhớ (reordering), còn chồng thêm vấn đề khả kiến (visibility) — giá trị một luồng ghi không hiển thị ngay cho luồng khác — khiến điều kiện tranh chấp vượt khỏi vấn đề thứ tự đơn giản để mở rộng thành vấn đề ở cấp mô hình bộ nhớ (memory model).

B. Điều kiện phát sinh và bối cảnh

Điều kiện tranh chấp thành lập khi (1) tồn tại tài nguyên dùng chung (biến toàn cục, tệp, bản ghi CSDL, phần cứng, v.v.), (2) hai bên trở lên truy cập đồng thời tài nguyên đó, (3) việc truy cập đó là phép toán không nguyên tử như đọc-sửa-ghi (read-modify-write), và (4) thứ tự thực thi không được kiểm soát. Lật ngược bốn điều kiện này chính là manh mối để giải quyết. Tức là loại bỏ chia sẻ (loại bỏ 1), tuần tự hóa truy cập đồng thời (loại bỏ 2), làm phép toán thành nguyên tử (loại bỏ 3), hoặc cưỡng chế thứ tự (loại bỏ 4) thì điều kiện tranh chấp sẽ biến mất. Các kỹ thuật giải quyết trình bày phía sau đều là cách phá vỡ ít nhất một trong bốn điều kiện này.

Khi CPU đa lõi trở nên phổ biến và lập trình bất đồng bộ, song song trở thành thường nhật, khiếm khuyết này — trước đây không lộ ra trên đơn luồng — đã nổi lên thành mối đe dọa cốt lõi về độ tin cậy và bảo mật của phần mềm hiện đại. Đặc biệt trong lĩnh vực bảo mật, nó bị lạm dụng bằng tấn công TOCTOU nhắm vào khe hở ngắn giữa thời điểm kiểm tra (Time-Of-Check) và thời điểm sử dụng (Time-Of-Use). Chẳng hạn, nếu sau khi kiểm tra quyền tệp và trước khi thực sự mở, kẻ tấn công tráo tệp đó bằng liên kết tượng trưng (symbolic link), thì việc kiểm tra vẫn qua nhưng thực tế lại truy cập tệp không có quyền.

2. Nguyên lý phát sinh

Bản chất của điều kiện tranh chấp là 'một cập nhật đáng lẽ phải nguyên tử bị chia cắt giữa chừng và luồng khác chen vào'. Biểu đồ tuần tự dưới đây cho thấy trong ví dụ rút tiền đã nêu, thao tác đọc-ghi của hai luồng đan xen (interleaving) như thế nào để gây mất cập nhật (lost update).

sequenceDiagram
  participant A as Luồng A
  participant M as Biến dùng chung (số dư 100)
  participant B as Luồng B
  A->>M: Đọc → 100
  B->>M: Đọc → 100
  A->>M: Tính (100-10) rồi ghi → 90
  B->>M: Tính (100-10) rồi ghi → 90
  Note over M: Rút hai lần nhưng số dư 90 (mất 10 won)

Điểm cốt lõi của hình trên là giữa 'đọc' và 'ghi' của A, khi A chưa phản ánh 90, B đã chen vào và đọc giá trị cũ 100. Nếu ba bước của A được gói nguyên tử để B không thể chen vào giữa, B sẽ đọc 90 và ghi 80, kết quả sẽ chính xác. Tức là điều kiện tranh chấp không bắt nguồn từ 'bản thân tính đồng thời' mà từ 'truy cập đồng thời vào vùng găng không được bảo vệ'.

Điều kiện tranh chấp có thể chia thành vài loại theo biểu hiện. Mất cập nhật (lost update) như ví dụ trên là phổ biến nhất, tiêu biểu còn có đọc bất nhất (dirty read) — trạng thái trung gian khi đang cập nhật hai tài nguyên bị lộ cho luồng khác — và TOCTOU là vấn đề trong bảo mật (lợi dụng thay đổi trạng thái giữa kiểm tra và sử dụng). Sơ đồ dưới đây tổng hợp có cấu trúc quan hệ giữa các yếu tố nguyên nhân của điều kiện tranh chấp và các tầng giải quyết.

flowchart TB
  subgraph C["Yếu tố phát sinh"]
    C1["Tài nguyên dùng chung"]
    C2["Truy cập đồng thời"]
    C3["Phép toán không nguyên tử (RMW)"]
    C4["Thứ tự không kiểm soát"]
  end
  C1 & C2 & C3 & C4 --> RC["Phát sinh điều kiện tranh chấp"]
  RC --> P["Vấn đề: mất cập nhật·bất nhất·TOCTOU"]
  P --> S["Giải quyết: bảo vệ vùng găng (loại trừ tương hỗ)"]
  S --> S1["Dựa trên khóa: mutex·semaphore·monitor"]
  S --> S2["Không khóa: phép toán nguyên tử (CAS)"]
  S --> S3["Dựa trên thiết kế: bất biến·cục bộ hóa·truyền thông điệp"]
  style RC fill:#fde8e8,stroke:#c0392b
  style S fill:#e8f0fe,stroke:#2f6fed

3. Phương pháp giải quyết

Giải pháp cho điều kiện tranh chấp rốt cuộc hội tụ về một nguyên lý. Đó là làm cho mỗi lần chỉ một luồng thực thi vào vùng găng (loại trừ tương hỗ, Mutual Exclusion), hoặc loại bỏ hẳn việc chia sẻ. Tuy nhiên, phương tiện hiện thực chia theo tầng thành ba nhóm lớn: dựa trên khóa (lock), không khóa (lock-free), và dựa trên thiết kế, mỗi nhóm có đánh đổi khác nhau. Trước bảng, hãy xem nguyên lý của từng cách tiếp cận.

A. Loại trừ tương hỗ dựa trên khóa (Lock). Đây là cách trực quan nhất: giành khóa trước khi vào vùng găng và trả lại khi ra để ngăn luồng khác vào. Mutex là khóa nhị phân chỉ cho một luồng đi qua, semaphore dùng thao tác P (chờ), V (báo hiệu) để kiểm soát số tài nguyên có thể truy cập đồng thời là N (mutex có thể xem là trường hợp đặc biệt N=1). Monitor như synchronized của Java đóng gói khóa và biến điều kiện ở cấp ngôn ngữ/runtime, giảm lỗi lập trình viên quên mở khóa. Dựa trên khóa dễ hiểu và mạnh mẽ, nhưng cái giá là khóa trở thành nút thắt hiệu năng và dùng sai sẽ gây bế tắc (deadlock).

Khi dùng kỹ thuật dựa trên khóa, có nguyên lý nhất định phải tuân thủ: giành và nhả khóa phải khớp cặp kể cả khi có ngoại lệ. Nếu ngoại lệ xảy ra trong vùng găng mà không nhả được khóa, mọi luồng khác sẽ chờ mãi mãi, nên phải bảo đảm nhả khóa bằng try-finally hoặc mẫu RAII (thu nhận tài nguyên là khởi tạo). Monitor được ưa chuộng ở cấp ngôn ngữ cũng chính vì ngôn ngữ ngăn thay rủi ro quên nhả khóa này.

B. Phép toán nguyên tử không khóa (Lock-Free). Dùng lệnh nguyên tử do phần cứng cung cấp, tiêu biểu là CAS (Compare-And-Swap), để cập nhật an toàn mà không cần khóa. CAS thực hiện so sánh-hoán đổi 'chỉ thay bằng giá trị mới khi giá trị bộ nhớ bằng giá trị kỳ vọng tôi đã đọc' trong một phép toán nguyên tử duy nhất, nên nếu luồng khác đã thay đổi giá trị ở giữa thì trả về thất bại và cho thử lại. Không có khóa nên không có bế tắc và hiệu năng tốt khi ít tranh chấp, nhưng khi tranh chấp gay gắt thì số lần thử lại bùng nổ, lại có những cạm bẫy tinh vi như 'vấn đề ABA' nên độ khó hiện thực cao.

C. Né tránh dựa trên thiết kế. Giải pháp căn bản nhất là 'loại bỏ chính việc chia sẻ'. Dùng đối tượng bất biến (immutable) mà giá trị không thay đổi thì đọc đồng thời cũng không sao, còn biến cục bộ luồng (thread-local) đặt trạng thái riêng cho mỗi luồng thì không có chia sẻ, tranh chấp bị chặn từ gốc. Xa hơn, mô hình Actor (Erlang, channel của Go) không chia sẻ trạng thái mà chỉ giao tiếp bằng truyền thông điệp (message passing) loại bỏ phần đáng kể lỗi đồng thời ngay ở giai đoạn thiết kế.

Kỹ thuật Tầng Nguyên lý cốt lõi Lưu ý
Mutex Khóa Loại trừ tương hỗ vùng găng (N=1) Deadlock, nút thắt
Semaphore Khóa Kiểm soát số truy cập N bằng P/V Deadlock khi sai thứ tự
Monitor Khóa Đóng gói đồng bộ hóa ở cấp ngôn ngữ Cần ngôn ngữ hỗ trợ
Phép toán nguyên tử (CAS) Không khóa So sánh-hoán đổi, thử lại ABA, bùng nổ thử lại khi tranh chấp
Bất biến/cục bộ/thông điệp Thiết kế Loại bỏ chính việc chia sẻ Chi phí thay đổi thiết kế

Chỉ dẫn thực tiễn cho việc chọn kỹ thuật thay đổi theo 'tính chất tranh chấp'. Nếu vùng găng ngắn và tranh chấp hiếm, các kỹ thuật không khóa như spinlock hay CAS có lợi vì tiết kiệm chi phí chuyển ngữ cảnh; nếu vùng găng dài hoặc thời gian chờ có thể kéo dài, mutex cho luồng ngủ sẽ tránh lãng phí CPU. Khi đọc áp đảo và ghi hiếm, khóa đọc-ghi (RW Lock) — cho phép nhiều thao tác đọc đồng thời nhưng chỉ khóa độc quyền khi ghi — nâng thông lượng đáng kể. Tức là điểm cân bằng giữa hiệu năng và an toàn không phải 'cứ dùng mutex' mà là phân tích mẫu truy cập để chọn công cụ phù hợp.

4. Trường hợp thực tiễn và quan hệ với deadlock

Điều kiện tranh chấp không phải vấn đề lý thuyết mà đã là nguyên nhân của các tai nạn lớn thực tế. Tiêu biểu, sự cố máy xạ trị Therac-25 thập niên 1980 được biết là do điều kiện tranh chấp giữa đầu vào của người vận hành và luồng điều khiển thiết bị khiến kiểm tra an toàn bị vượt qua, chiếu xạ quá liều cho bệnh nhân, và còn lại như bài học kinh điển về khiếm khuyết đồng thời trong phần mềm dẫn đến thiệt hại nhân mạng. Trong dịch vụ web, bán vượt (overselling) — hai đơn hàng cùng đến cho sản phẩm chỉ còn 1 tồn kho và cả hai đều được xử lý thành công — và trong ngân hàng, rút tiền kép như đã thấy là các trường hợp điển hình. Những vấn đề này phải được kiểm soát đồng thời không chỉ bằng khóa ở ứng dụng mà cả bằng mức cô lập giao dịch (isolation level) và khóa lạc quan/bi quan của cơ sở dữ liệu.

Ở tầng cơ sở dữ liệu, điều kiện tranh chấp xuất hiện dưới dạng vấn đề mức cô lập giao dịch (isolation level). Mức cô lập thấp (ví dụ Read Uncommitted) thì hiệu năng tốt nhưng các hiện tượng tranh chấp như đọc bẩn, mất cập nhật bị lộ nguyên vẹn; mức cao (Serializable) thì an toàn nhưng thông lượng giảm do tranh chấp khóa. Trong thực tế, dùng khóa bi quan (Pessimistic Lock) khóa trước bản ghi cần cập nhật, hoặc khóa lạc quan (Optimistic Lock, so sánh cột phiên bản) chỉ thử lại khi xung đột, để hiện thực ở tầng dữ liệu cùng nguyên lý như mutex, CAS ở tầng ứng dụng đã thấy. Tức là việc kiểm soát điều kiện tranh chấp phải được thiết kế bằng chiến lược nhất quán ở cả ứng dụng và cơ sở dữ liệu.

Mặt khác, nếu lạm dụng khóa để ngăn điều kiện tranh chấp sẽ dẫn đến vấn đề ngược lại là bế tắc (Deadlock). Đây là tình huống hai luồng chờ khóa mà bên kia đang giữ và dừng mãi mãi, xảy ra khi đồng thời thỏa mãn bốn điều kiện: loại trừ tương hỗ, giữ và chờ, không trưng dụng, chờ vòng tròn.

Do đó, mấu chốt thực tiễn nằm ở sự cân bằng 'dùng khóa nhưng xác định thứ tự khóa nhất quán và tối thiểu hóa phạm vi khóa để kiểm soát đồng thời deadlock và suy giảm hiệu năng'. Thống nhất thứ tự khóa trên toàn cục sẽ phá điều kiện chờ vòng tròn, phòng ngừa deadlock, còn tối thiểu hóa vùng găng sẽ giảm suy giảm hiệu năng do tuần tự hóa. Điều kiện tranh chấp và deadlock gây ra lẫn nhau, nên chỉ nhìn một mà ứng phó thì cái kia sẽ xấu đi. Kiểm soát đồng thời là bài toán như hai mặt của đồng xu, phải xử lý cả hai như một vấn đề thiết kế duy nhất.

5. Chuyên sâu — Kỹ thuật phát hiện và ứng phó ở cấp ngôn ngữ

Vì điều kiện tranh chấp khó tái hiện, công cụ phát hiện trước và phòng ngừa ở cấp ngôn ngữ ngày càng quan trọng hơn gỡ lỗi sau sự việc. Công cụ phân tích động ThreadSanitizer (TSan) vừa chạy thực chương trình vừa theo dõi xem các luồng khác nhau có truy cập cùng bộ nhớ mà không đồng bộ hay không để bắt tranh chấp dữ liệu, và được dùng rộng rãi trong C/C++, Go, v.v. Ngôn ngữ Go cung cấp sẵn chức năng này qua cờ -race. Phân tích tĩnh tìm các mẫu tranh chấp tiềm ẩn trong mã mà không cần thực thi, còn kiểm thử stress, fuzzing thì xáo trộn lịch thực thi một cách nhân tạo để buộc các thời điểm hiếm gặp lộ ra.

Các cơ chế cấp ngôn ngữ xử lý vấn đề khả kiến cũng quan trọng. volatile của Java hay rào chắn bộ nhớ (memory barrier) bảo đảm giá trị một luồng ghi chắc chắn hiển thị cho luồng khác, và thiết lập quan hệ happens-before giới hạn việc sắp xếp lại lệnh. Tức là ứng phó điều kiện tranh chấp hiện đại chỉ hoàn chỉnh khi xử lý không chỉ 'loại trừ tương hỗ' mà cả 'bảo đảm khả kiến và thứ tự', điều này có nghĩa là phải hiểu chính xác mô hình bộ nhớ của mỗi ngôn ngữ.

Ứng phó ở cấp thiết kế ngôn ngữ cũng là dòng chảy đáng chú ý. Rust dùng quy tắc sở hữu (ownership) và mượn (borrow) để cưỡng chế ràng buộc 'tham chiếu khả biến chỉ được tồn tại một tại một thời điểm' ngay lúc biên dịch, qua đó chặn từ gốc ở giai đoạn biên dịch phần đáng kể tranh chấp dữ liệu ("fearless concurrency"). Đây là chuyển đổi căn bản, tương phản với cách cũ dựa vào kiểm tra lúc chạy, là trường hợp nâng an toàn đồng thời lên thành vấn đề của hệ thống kiểu.

Mô hình CSP (Communicating Sequential Processes) dựa trên channel của Go, theo triết lý "đừng giao tiếp bằng cách chia sẻ bộ nhớ, hãy chia sẻ bộ nhớ bằng cách giao tiếp", cho các goroutine trao đổi dữ liệu qua channel để giảm trạng thái khả biến dùng chung. Việc các ngôn ngữ hàm nhấn mạnh tính bất biến cũng cùng mạch này; chúng cùng hướng tới 'giảm trạng thái khả biến dùng chung để loại bỏ tranh chấp ngay ở giai đoạn thiết kế'. Dòng chảy này cho thấy trọng tâm ứng phó điều kiện tranh chấp đang dịch chuyển từ 'chặn bằng khóa khi chạy' sang 'làm cho không thể xảy ra ngay từ giai đoạn thiết kế, biên dịch'.

6. Những điều cần cân nhắc và hàm ý

Từ góc nhìn Kỹ sư chuyên nghiệp (Professional Engineer), khi xử lý điều kiện tranh chấp cần xem xét tổng hợp những điểm sau.

  1. Nhất định phải nhận thức cái giá của đồng bộ hóa. Khóa ngăn điều kiện tranh chấp, nhưng quá mức sẽ gây suy giảm hiệu năng (tuần tự hóa) và bế tắc. Thu hẹp phạm vi khóa (vùng găng) về mức tối thiểu cần thiết, và khi dùng nhiều khóa thì duy trì thứ tự giành khóa nhất quán toàn cục để phá vỡ chờ vòng tròn là cốt lõi phòng ngừa deadlock.

  2. Phải thiết kế với tiền đề đây là khiếm khuyết khó bắt bằng kiểm thử. Điều kiện tranh chấp lác đác và không tất định nên không tái hiện được bằng kiểm thử thông thường. Do đó phải đưa thường trực vào quy trình phát triển các công cụ phát hiện đồng thời như ThreadSanitizer, kiểm thử stress và fuzzing, rà soát mã tập trung vào truy cập tài nguyên dùng chung, hướng tới 'chặn trước' thay vì 'phát hiện sau'.

  3. Phải lưu ý rằng nó dẫn trực tiếp đến lỗ hổng bảo mật. Điều kiện tranh chấp như TOCTOU nhắm vào khe hở giữa kiểm tra quyền và sử dụng thực tế, bị lạm dụng để leo thang đặc quyền, vượt qua xác thực. Phải phòng thủ bằng thiết kế gói nguyên tử việc kiểm tra và sử dụng, hoặc loại bỏ chênh lệch thời điểm như truy cập dựa trên mô tả tệp (file descriptor), và phải đưa tường minh điều kiện tranh chấp vào hạng mục rà soát bảo mật.

  4. Phải ưu tiên xem xét thiết kế 'loại bỏ chia sẻ'. Thay vì bảo vệ sau bằng khóa, giảm chính trạng thái khả biến dùng chung bằng đối tượng bất biến, trạng thái cục bộ luồng, truyền thông điệp (actor/channel) là giải pháp căn bản và có khả năng mở rộng. Khi thiết kế hệ thống mới, nên chủ động xem xét mô hình an toàn đồng thời của các ngôn ngữ như Rust, Go để loại trừ khiếm khuyết ngay ở giai đoạn thiết kế.


Tóm tắt một câu: Điều kiện tranh chấp là lỗi không tất định trong đó kết quả thay đổi theo thứ tự thực thi do truy cập đồng thời không được bảo vệ vào tài nguyên dùng chung, được giải quyết bằng loại trừ tương hỗ vùng găng qua mutex, semaphore, phép toán nguyên tử (CAS) hoặc loại bỏ chính việc chia sẻ bằng bất biến, truyền thông điệp, đồng thời phải kiểm soát cả deadlock, suy giảm hiệu năng và mối đe dọa bảo mật TOCTOU.