|
|
| Race condition là gì? Cách Python ngăn chặn race condition |
+= ở 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.
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:
- 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ỳ vọng: 8000
Thực tế: 37Con 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_count và self.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".
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ì 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
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)
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)
Thực tế: 8000Khố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.
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ùngthreading.Lockcho vùng critical section.
Nguồn tham khảo
- Python Glossary - Global Interpreter Lock (GIL)
- Python C API - Reference Counting (
Py_INCREF/Py_DECREF) - Python
threading- Lock Objects - PEP 703 - Making the Global Interpreter Lock Optional in CPython
- Kai