👋 Chào mừng đến với Nocoem — kiến thức Backend, System Design và lập trình. Khám phá ngay

Race condition là gì? Cách Python ngăn chặn race condition

Race condition là gì? Cách Python ngăn chặn race condition
Race condition là gì? Cách Python ngăn chặn race condition
Race condition là gì? Cách Python ngăn chặn race condition
TL;DR: Điều kiện tương tranh (race condition) xảy ra khi nhiều luồng (thread) cùng đọc-sửa-ghi một tài nguyên dùng chung mà không có đồng bộ, khiến kết quả sai lệch tuỳ theo thứ tự "ai chạy trước". Trong CPython, khoá thông dịch toàn cục (Global Interpreter Lock - GIL) giữ cho bộ đếm tham chiếu (reference count) ở tầng C không bị tranh chấp. Nhưng lưu ý: GIL không biến mọi phép += ở tầng Python của bạn thành an toàn - chỗ này nhiều người hiểu lầm, tôi sẽ nói rõ ở cuối bài.

Bạn viết một script Python đa luồng để tăng một biến đếm. Chạy đơn luồng thì đúng, nhưng vừa bung ra vài chục thread, con số cuối cùng lại nhỏ hơn kỳ vọng - và mỗi lần chạy ra một kết quả khác nhau. Không có exception, không có log lỗi, chỉ là... sai âm thầm. Thủ phạm kinh điển ở đây là điều kiện tương tranh (race condition). Trong bài này tôi sẽ mổ xẻ nó bằng chính cơ chế quản lý bộ nhớ của Python, rồi giải thích vì sao GIL vừa cứu bạn ở tầng thấp, vừa không cứu bạn ở tầng cao.

Race condition là gì?

Điều kiện tương tranh (race condition) là tình huống mà kết quả của chương trình phụ thuộc vào thứ tự hoặc thời điểm các thao tác xen kẽ nhau - trong khi lẽ ra thứ tự đó không được phép ảnh hưởng đến tính đúng đắn. Nó thường xuất hiện khi hai hay nhiều luồng (thread) hoặc tiến trình (process) cùng truy cập một tài nguyên dùng chung, và ít nhất một trong số đó ghi vào tài nguyên ấy.

Mấu chốt nằm ở các thao tác kiểu đọc - sửa - ghi (read-modify-write). Với mắt thường, counter += 1 trông như một hành động liền mạch. Nhưng thực chất nó gồm ba bước: đọc giá trị hiện tại, cộng thêm một, rồi ghi lại. Nếu một luồng khác chen vào giữa ba bước đó, hai luồng có thể cùng đọc một giá trị cũ và cùng ghi đè lên nhau - một lần tăng bị "nuốt" mất.

Ví von: Hãy tưởng tượng hai người cùng chỉnh sửa một tờ tồn kho trên giấy. Cả hai cùng đọc "còn 10 cái", mỗi người bán đi 1 cái rồi cùng ghi lại "còn 9". Đáng lẽ phải còn 8, nhưng vì họ đọc-ghi chồng lên nhau nên một giao dịch bị mất dấu. Race condition là đúng như vậy, chỉ khác là diễn ra trong vài nano-giây.

Bối cảnh: bộ đếm tham chiếu trong Python

Để thấy race condition "đáng sợ" cỡ nào, ta soi vào nơi nó có thể phá hoại tận gốc: cơ chế đếm tham chiếu (reference counting) - cách chính CPython dùng để dọn rác bộ nhớ.

Mỗi đối tượng trong CPython mang theo một bộ đếm: có bao nhiêu chỗ đang trỏ tới nó. Khi bộ đếm về 0, đối tượng được giải phóng ngay lập tức. Ví dụ:

shared_list = [1, 2, 3]          # ref_count = 1
another_reference = shared_list  # ref_count = 2
del another_reference            # ref_count = 1

Con số ref_count này nằm trong cấu trúc C của đối tượng, và mỗi lần gán/xoá tham chiếu, CPython gọi các macro Py_INCREF / Py_DECREF để tăng giảm nó. Bạn có thể tự xem qua sys.getrefcount() (giá trị sẽ nhỉnh hơn 1 vì bản thân lời gọi hàm cũng tạo thêm một tham chiếu tạm).

Race condition tấn công bộ đếm tham chiếu như thế nào?

Giả sử tưởng tượng CPython không có gì bảo vệ, và hai luồng cùng giảm bộ đếm của một đối tượng đang có ref_count = 2. Vì thao tác giảm là đọc-sửa-ghi, chúng có thể xen kẽ như sau:

Thread 1 Thread 2 ref_count = 2 đọc ref_count -> 2 đọc ref_count -> 2 ghi 2 − 1 = 1 ghi 2 − 1 = 1 Kết quả: ref_count = 1 (SAI, đáng lẽ 0)
  • Ban đầu ref_count = 2.
  • Cả hai luồng cùng đọc ra 2.
  • Mỗi luồng tính 2 − 1 = 1 rồi ghi lại 1.
  • Kết quả cuối là 1, trong khi đáng lẽ phải là 0 (vì đã có hai lần giảm).

Một lần giảm bị mất trắng. Và hệ quả thì không hề nhẹ:

  • Rò rỉ bộ nhớ (memory leak): bộ đếm không bao giờ chạm 0, nên đối tượng không bao giờ được giải phóng - bộ nhớ bị giữ mãi.
  • Lỗi truy cập bộ nhớ (segmentation fault): tình huống ngược lại - nếu bộ đếm bị giảm về 0 quá sớm trong khi vẫn còn chỗ đang dùng, đối tượng bị giải phóng oan, và lần truy cập tiếp theo sẽ đọc vào vùng nhớ đã chết, khiến chương trình sập.

Mô phỏng bằng code chạy được

Ta không can thiệp được vào bộ đếm C thật, nhưng có thể tái hiện đúng cơ chế đọc-sửa-ghi bằng một lớp Python, rồi cố tình chèn một điểm nhường luồng (time.sleep(0)) vào đúng "khe hở" giữa đọc và ghi để phóng đại xác suất tranh chấp:

import threading
import time

class SharedResource:
    def __init__(self, data):
        self._data = data
        self.ref_count = 0

    def add_reference(self):
        current = self.ref_count   # ĐỌC
        time.sleep(0)              # ép nhường luồng, mở rộng khe hở
        self.ref_count = current + 1  # GHI (đè lên nhau)

resource = SharedResource([1, 2, 3])

def worker():
    for _ in range(1000):
        resource.add_reference()

threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print("Kỳ vọng:", 8 * 1000)
print("Thực tế:", resource.ref_count)
Kết quả (minh hoạ, tuỳ máy/phiên bản, mỗi lần chạy một khác):
Kỳ vọng: 8000
Thực tế: 37

Con số "thực tế" gần như luôn nhỏ hơn 8000 và thay đổi mỗi lần chạy - dấu hiệu điển hình của race condition. Vì current = self.ref_countself.ref_count = current + 1 là hai bước tách rời, nhiều luồng cùng đọc một giá trị cũ rồi cùng ghi đè, nên hàng nghìn lần tăng bị "nuốt".

Đừng hiểu lầm về time.sleep(0): nó KHÔNG phải nguyên nhân gây race condition. Race condition có sẵn trong đoạn đọc-sửa-ghi kia rồi; sleep(0) chỉ ép luồng nhường quyền đúng vào khe hở giữa đọc và ghi, làm lỗi dễ tái hiện khi demo. Bỏ dòng đó đi, lỗi vẫn xảy ra, chỉ là thưa hơn và khó bắt hơn nhiều.

GIL cứu bộ đếm tham chiếu như thế nào?

Đây là lúc khoá thông dịch toàn cục (Global Interpreter Lock - GIL) vào cuộc. GIL là một khoá loại trừ (mutex) đảm bảo tại mỗi thời điểm chỉ một luồng được thực thi Python bytecode trong một tiến trình (theo tài liệu chính thức của Python).

Ví von: Hãy hình dung một căn bếp đông đầu bếp nhưng chỉ có một con dao. Ai muốn thái đều được, nhưng phải chờ tới lượt cầm dao. Nhờ vậy không bao giờ có chuyện hai người cùng chém vào một củ hành. GIL chính là "con dao" duy nhất đó của trình thông dịch CPython.
Thread 1 (chạy) Thread 2 (chờ) GIL1 khoá CPython
Nhiều luồng, nhưng chỉ luồng đang giữ GIL mới chạy bytecode; luồng khác xếp hàng chờ.

Vì các macro Py_INCREF / Py_DECREF ở tầng C chạy gọn trong lúc luồng đang giữ GIL và không bị cắt ngang giữa chừng, nên thao tác tăng/giảm bộ đếm tham chiếu không bị hai luồng xen kẽ. Đó chính là một trong những lý do lịch sử khiến CPython giữ GIL: nó giúp việc quản lý bộ nhớ bằng đếm tham chiếu trở nên an toàn mà không cần khoá riêng cho từng đối tượng.

  • Không có tranh chấp khi đọc/ghi bộ đếm tham chiếu nội bộ.
  • Thao tác Py_INCREF/Py_DECREF ở tầng C an toàn với đa luồng.
  • Cơ chế thu hồi bộ nhớ hoạt động chính xác, tránh được memory leak lẫn segfault kiểu trên.

Cạm bẫy: GIL KHÔNG làm code của bạn "miễn nhiễm" race condition

Hiểu lầm phổ biến: "Có GIL rồi thì counter += 1 trong Python là atomic, khỏi cần lock." Sai. GIL bảo vệ bộ đếm tham chiếu nội bộ ở tầng C, chứ không biến mọi phép toán ở tầng Python của bạn thành nguyên tử (atomic).

Lý do: một câu lệnh như self.ref_count += 1 ở tầng Python biên dịch ra nhiều bytecode. Nhìn tận mắt bằng module dis:

import dis
counter = 0
def increment():
    global counter
    counter += 1
dis.dis(increment)
LOAD_GLOBAL 0 (counter) LOAD_CONST 1 (1) BINARY_OP 13 (+=) STORE_GLOBAL 0 (counter)

Thấy rõ: counter += 1 tách thành đọc (LOAD_GLOBAL), tính (BINARY_OP), rồi ghi (STORE_GLOBAL). GIL có thể được nhả cho luồng khác giữa các bytecode đó, nên hai luồng vẫn kịp đọc cùng một giá trị cũ. Nghĩa là chính đoạn add_reference ở ví dụ trên vẫn dính race condition dù đang chạy với GIL - đó là lý do nó in ra 37 chứ không phải 8000. GIL chỉ đảm bảo mỗi bytecode đơn lẻ chạy trọn vẹn, chứ không gộp cả cụm đọc-sửa-ghi của bạn thành một khối liền. (Output chụp ở Python 3.11; phiên bản khác tên/số opcode có thể khác.)

Muốn an toàn ở tầng Python, bạn phải tự đồng bộ. Cách chuẩn là dùng threading.Lock (theo tài liệu module threading):

import threading

class SafeResource:
    def __init__(self, data):
        self._data = data
        self.ref_count = 0
        self._lock = threading.Lock()

    def add_reference(self):
        with self._lock:            # chỉ một luồng vào vùng này mỗi lúc
            self.ref_count += 1

resource = SafeResource([1, 2, 3])

def worker():
    for _ in range(1000):
        resource.add_reference()

threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print("Thực tế:", resource.ref_count)
Kết quả (ổn định qua mọi lần chạy):
Thực tế: 8000

Khối with self._lock: biến cụm đọc-sửa-ghi thành một vùng loại trừ lẫn nhau (critical section): tại mỗi thời điểm chỉ một luồng được vào, nên không còn ai ghi đè ai. Ngoài Lock, bạn còn có RLock, Semaphore, Queue (hàng đợi an toàn luồng) hay các cấu trúc trong concurrent.futures tuỳ bài toán.

Thêm một lưu ý về tương lai: Từ Python 3.13, CPython có thêm bản dựng free-threaded (không GIL) theo PEP 703. Khi không còn GIL, CPython dùng các kỹ thuật khác (như biased reference counting) để giữ an toàn cho việc đếm tham chiếu nội bộ. Nhưng điều đó không thay đổi kết luận trên: code đa luồng ở tầng Python của bạn vẫn cần tự đồng bộ như thường.

Tóm tắt

  • Race condition = kết quả phụ thuộc vào thứ tự xen kẽ của các thao tác trên tài nguyên dùng chung; nguy hiểm nhất ở các thao tác đọc-sửa-ghi.
  • Ở tầng thấp, nó có thể phá bộ đếm tham chiếu của CPython, gây memory leak hoặc segfault.
  • GIL giữ cho Py_INCREF/Py_DECREF ở tầng C an toàn - đó là lý do quản lý bộ nhớ của CPython không bị tranh chấp.
  • Nhưng GIL không làm code Python của bạn miễn nhiễm race condition: += gồm nhiều bytecode, vẫn bị cắt ngang. Hãy dùng threading.Lock cho vùng critical section.

Nguồn tham khảo

- Kai

Để biết rõ hơn về GIL, bạn hãy tham khảo bài viết sau nhé:

Thấy bài viết hữu ích?

0 lượt thích

Đăng nhận xét