← Lập trình Python nâng cao

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ệcDùngVì sao
I/O-boundthreading / asyncGIL nhả khi chờ → chồng lấn.
CPU-boundmultiprocessingMỗ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:

Luồng A · tmp
counter (chung)
0
Luồng B · tmp
counter = 0
bước 1/9
Biến dùng chung = 0. Mỗi luồng sẽ tăng 2 lần (lẽ ra ra 4).

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

Trên build có GIL, bạn có thể chạy ra đúng 800000 nhiều lần. ĐỪNG mừng vội: đó là GIL CHE lỗi (lượt chuyển luồng hiếm khi rơi đúng khe đọc→ghi). Bug vẫn ở đó - nó sẽ lộ khi tải cao, đổi máy, hoặc chạy trên bản free-threaded. "Chạy được ở máy em" là cái bẫy kinh điển.
  • 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

Cần nhiều khoá thì coi chừng deadlock: luồng A giữ lock1 chờ lock2, luồng B giữ lock2 chờ lock1 → kẹt vĩnh viễn. Cách tránh: luôn lấy khoá theo CÙNG MỘT THỨ TỰ, giữ vùng găng ngắn, và tốt nhất là tránh chia sẻ (Bước 5).
  • 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

Đa tiến trình cho song song CPU thật nhưng phải tách bộ nhớ & pickle. Bỏ luôn GIL để LUỒNG cũng song song CPU (mà vẫn chung bộ nhớ) được không? Bài cuối phần này: free-threading - Python không-GIL.

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).

Mỗi tiến trình có BỘ NHỚ RIÊNG → không chia sẻ biến trực tiếp; dữ liệu truyền qua lại phải PICKLE (đóng gói) → tốn chi phí, và có thứ không pickle được (vd lambda, file handle). Khởi tạo tiến trình cũng nặng hơn luồng. Hợp việc CPU nặng, chia thành mảnh ĐỘC LẬP, dữ liệu vào/ra gọn.

API cấp cao gọn cho cả hai: ThreadPoolExecutor (đa luồng) và ProcessPoolExecutor (đa tiến trình) dùng GIỐNG NHAU - submit/map để giao việc, future để lấy kết quả. Đổi giữa luồng và tiến trình thường chỉ là đổi tên lớp executor.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Cho việc CPU-bound nặng cần song song thật, nên chọn gì?

  1. 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 threading hay multiprocessing? Vì sao?

    Hoàn thành khi: (a) threading (I/O-bound); (b) multiprocessing (CPU-bound, cần song song thật).

  2. 2

    Race “thất thường”

    Viết 4 luồng mỗi luồng tăng counter dù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. 3

    Sửa bằng Lock

    Thêm threading.Lock + with lock quanh 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 lock làm "đọc→ghi" nguyên tử.

  4. 4

    Đo song song thật

    Dùng ProcessPoolExecutor chia một việc CPU-bound thành 4 phần; so thời gian với chạy tuần tự bằng time.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×); threading thì không - bạn giải thích được.

  5. 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ó Lock thủ công; Queue tự an toàn-luồng; mỗi việc xử lý đúng một lần.