Bài 13 · Nâng cao · 28 phút· Cập nhật 11/06/2026
Đa luồng & đa tiến trình
Biên soạn bởi Nguyễn Anh Tuấn
Đa luồng & đa tiến trình trong Python: threading vs multiprocessing, I/O-bound vs CPU-bound, race condition & Lock, concurrent.futures.
Nhờ hiểu GIL ở bài trước, lựa chọn trở nên rõ ràng:
| Loại việc | Dùng | Vì sao |
|---|---|---|
| I/O-bound | threading / async | GIL nhả khi chờ → chồng lấn. |
| CPU-bound | multiprocessing | Mỗi tiến trình GIL riêng → song song thật. |
- ▸I/O-bound (chờ mạng/đĩa/CSDL) → đa luồng hoặc async.
- ▸CPU-bound (tính toán) → đa tiến trình (hoặc free-threading, bài sau).
- ▸Chọn sai mô hình = không nhanh hơn (vd dùng threads cho CPU-bound - xem bài GIL).
Đa luồng chia sẻ bộ nhớ - tiện nhưng nguy hiểm. Khi nhiều luồng cùng đọc-sửa-ghi một biến chung mà không đồng bộ, ta gặp race condition. Bấm qua kịch bản "Không khoá" để thấy một lần mất update:
race.py - bug LATENT (tiềm ẩn)
import threading
counter = 0
def inc(n):
global counter
for _ in range(n):
counter += 1 # doc -> cong -> ghi: KHONG nguyen tu
ts = [threading.Thread(target=inc, args=(200_000,)) for _ in range(4)]
[t.start() for t in ts]; [t.join() for t in ts]
print(counter) # ky vong 800000 - nhung co the it hon Kết quả khi chạy
800000 # ... hoac 799_xxx - THAT THUONG
Vì sao đôi khi vẫn ĐÚNG - và đó mới là chỗ nguy hiểm
- ▸threading.Thread(target=..., args=...).start()/.join() để chạy luồng.
- ▸counter += 1 gồm nhiều bytecode → GIL có thể chen giữa → lost update.
- ▸Race là LATENT & thất thường: lúc đúng lúc sai → phải khoá đúng, đừng dựa vào may.
Bọc đoạn đọc-sửa-ghi dữ liệu chung bằng with lock: để nguyên tử hoá:
lock.py
import threading
counter = 0
lock = threading.Lock()
def tang(n):
global counter
for _ in range(n):
with lock: # vung gang: moi luc chi 1 luong vao
counter += 1
ts = [threading.Thread(target=tang, args=(200_000,)) for _ in range(4)]
[t.start() for t in ts]; [t.join() for t in ts]
print(counter) # LUON 800000 (on dinh) Kết quả khi chạy
800000
Bẫy deadlock
- ▸with lock: biến "đọc→sửa→ghi" thành nguyên tử - sửa lost update, kết quả ổn định.
- ▸Giữ vùng găng NGẮN; đừng gọi mạng/đợi lâu khi đang giữ khoá.
- ▸Nhiều khoá → lấy theo cùng thứ tự để khỏi deadlock.
Để CPU-bound chạy song song THẬT, dùng nhiều tiến trình - mỗi cái có interpreter + GIL riêng. Đây là số đo thật trên máy 4+ lõi:
mp.py - đo tăng tốc CPU-bound
import time
from concurrent.futures import ProcessPoolExecutor
def nang(n):
s = 0
for i in range(n): s += i * i
return s
if __name__ == "__main__": # bat buoc tren Windows/macOS
N = 20_000_000
t = time.perf_counter(); [nang(N) for _ in range(4)]; seq = time.perf_counter() - t
t = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as ex:
list(ex.map(nang, [N] * 4))
par = time.perf_counter() - t
print(f"seq={seq:.2f}s 4 tien trinh={par:.2f}s -> {seq/par:.1f}x") Kết quả khi chạy
seq=3.38s 4 tien trinh=1.06s -> 3.2x
- ▸Đa tiến trình cho CPU-bound: ≈3,2× với 4 tiến trình (so với threading ≈1×).
- ▸Mỗi tiến trình bộ nhớ RIÊNG → không chia sẻ biến (đỡ race, nhưng phải truyền dữ liệu).
- ▸Dữ liệu qua lại phải PICKLE → tránh truyền vật to; bọc trong if __name__ == "__main__".
Cách an toàn nhất với đồng thời là đừng chia sẻ trạng thái - hãy TRUYỀN dữ liệu qua một queue.Queue (đã an toàn-luồng sẵn):
queue_demo.py - producer/consumer
import threading, queue
viec = queue.Queue()
ket_qua = queue.Queue()
def tho(): # nhieu tho cung lay viec
while True:
x = viec.get()
if x is None: break # tin hieu dung
ket_qua.put(x * x)
viec.task_done()
thos = [threading.Thread(target=tho) for _ in range(3)]
[t.start() for t in thos]
for i in range(10): viec.put(i)
viec.join() # cho lam het
for _ in thos: viec.put(None) # bao dung
[t.join() for t in thos]
print(sorted(ket_qua.queue)) Kết quả khi chạy
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
- ▸Queue an toàn-luồng sẵn → khỏi tự quản Lock cho dữ liệu vào/ra.
- ▸Mẫu producer/consumer: bỏ việc vào queue, nhiều "thợ" lấy ra xử lý - mỗi việc đúng một lần.
- ▸Không có trạng thái chung = không có race: dễ suy luận & kiểm thử hơn dùng Lock.
concurrent.futures cho cùng một API cho luồng & tiến trình - đổi mô hình chỉ là đổi tên executor:
futures.py
from concurrent.futures import ThreadPoolExecutor # I/O-bound
# from concurrent.futures import ProcessPoolExecutor # CPU-bound: doi DUNG dong nay
def tai(url):
... # goi mang (I/O-bound)
return url
urls = ["a", "b", "c", "d"]
with ThreadPoolExecutor(max_workers=4) as ex:
ket_qua = list(ex.map(tai, urls)) # giu thu tu; hoac ex.submit -> future - ▸ThreadPoolExecutor (I/O) vs ProcessPoolExecutor (CPU) - cùng API submit/map.
- ▸ex.map giữ thứ tự kết quả; ex.submit trả future để lấy kết quả/biệt lệ riêng lẻ.
- ▸Bắt đầu bằng concurrent.futures (gọn) trước khi cần threading/multiprocessing thô.
Tiếp theo
Câu hỏi thường gặp
Theo loại tác vụ (do GIL): I/O-BOUND (chờ mạng/đĩa/CSDL) → THREADING hoặc async - luồng nhả GIL khi chờ nên chồng lấn tốt. CPU-BOUND (tính toán nặng) → MULTIPROCESSING - mỗi tiến trình có interpreter + GIL riêng nên chạy song song THẬT trên nhiều lõi (đo được ≈3,2× với 4 tiến trình).
Là lỗi khi nhiều luồng cùng đọc-sửa-ghi một dữ liệu DÙNG CHUNG mà không đồng bộ, dẫn tới kết quả sai tuỳ thứ tự chạy. Ví dụ kinh điển "lost update": hai luồng cùng đọc counter=0, cùng ghi 1 → mất một lần tăng.
GIL chỉ đảm bảo MỘT bytecode chạy một lúc, không đảm bảo cả KHỐI "đọc→ghi" của bạn là nguyên tử. counter += 1 gồm nhiều bytecode; GIL có thể chuyển luồng GIỮA chúng. Trên build có GIL, nhiều khi bạn vẫn thấy KẾT QUẢ ĐÚNG vì lượt chuyển hiếm khi rơi đúng khe - nhưng đó là GIL CHE lỗi, không phải hết lỗi. Trên bản free-threaded (không GIL) thì lỗi LỘ rõ. Đừng dựa vào may rủi: cứ khoá đúng.
Bọc vùng găng (đoạn đọc-sửa-ghi dữ liệu chung) trong with lock: để mỗi lúc chỉ một luồng vào. DEADLOCK xảy ra khi hai luồng giữ một khoá rồi chờ khoá của nhau → kẹt vĩnh viễn. Tránh: giữ vùng găng NGẮN; nếu cần nhiều khoá, luôn lấy theo CÙNG MỘT THỨ TỰ; tốt nhất là tránh chia sẻ (dùng Queue).
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Cho việc CPU-bound nặng cần song song thật, nên chọn gì?
- 1
Chọn mô hình
Cho: (a) tải 50 trang web; (b) tính số nguyên tố tới 10 triệu chia 4 phần. Mỗi việc nên
threadinghaymultiprocessing? Vì sao?Hoàn thành khi: (a)
threading(I/O-bound); (b)multiprocessing(CPU-bound, cần song song thật). - 2
Race “thất thường”
Viết 4 luồng mỗi luồng tăng
counterdùng chung 200.000 lần KHÔNG khoá. Chạy vài lần. Kết quả có luôn = 800.000 không?Hoàn thành khi: Có thể đúng 800.000 (GIL che) hoặc < (mất update) - THẤT THƯỜNG. Bạn hiểu vì sao không được dựa vào nó.
- 3
Sửa bằng Lock
Thêm
threading.Lock+with lockquanh phép tăng. Chạy lại nhiều lần.Hoàn thành khi: Luôn = 800.000, ổn định. Bạn giải thích
with locklàm "đọc→ghi" nguyên tử. - 4
Đo song song thật
Dùng
ProcessPoolExecutorchia một việc CPU-bound thành 4 phần; so thời gian với chạy tuần tự bằngtime.perf_counter.Hoàn thành khi: Trên máy ≥4 lõi, đa tiến trình nhanh hơn rõ (vd ≈3×);
threadingthì không - bạn giải thích được. - 5
Tránh chia sẻ bằng Queue
Viết producer-consumer: một luồng bỏ việc vào
queue.Queue, hai luồng lấy ra xử lý. Không dùng biến chung nào khác.Hoàn thành khi: Không có
Lockthủ công;Queuetự an toàn-luồng; mỗi việc xử lý đúng một lần.