threading và multiprocessing — Thread, process và GIL

Phân biệt thread vs. process, lý do GIL không cho CPU-bound chạy song song, ThreadPoolExecutormultiprocessing.Pool, subprocess cho lệnh ngoài, và lưu đồ lựa chọn.

Bộ công cụ đồng thời và song song của Python — threading (thread), multiprocessing (process)subprocess (lệnh ngoài) — được đặt cạnh nhau. Sandbox Python chạy trên trình duyệt không thể tạo thread thật hay process thật, nên bài viết này dùng sơ đồ và các khối code chỉ để đọc để chốt các khái niệm.

Code ở đây không thực sự chạy

Các khối code trong bài viết này là ví dụ dành cho môi trường Python thật. Các lệnh gọi như threading.Thread.start() hay multiprocessing.Pool.map() sẽ không chạy trong sandbox trình duyệt vì runtime không thể tạo thread OS hay process OS. Thay vào đó, có một bài quiz kiểm tra khái niệm ở cuối bài.

threading / multiprocessing / subprocess phù hợp ở đâu
threadingI/O-boundFile / DB / APImultiprocessingCPU-boundXử lý ảnh / số họcsubprocessLệnh ngoàigit / ffmpeg / shellasyncio (bài trước)I/O đồng thời nhiều100 lệnh gọi Web API
threading cho công việc I/O-bound, multiprocessing cho tính toán nặng CPU, subprocess để gọi các lệnh bên ngoài Python. Đặt cạnh asyncio (đã trình bày ở bài trước cho I/O đồng thời số lượng lớn), bốn vai trò trở nên rõ ràng.

Process vs. thread

Một processđơn vị thực thi mà OS cấp phát — nó có không gian bộ nhớ riêng, và các process không can thiệp lẫn nhau. Một threadđơn vị thực thi nhẹ bên trong một process, và chia sẻ bộ nhớ với các thread anh em. Việc chia sẻ làm cho truyền dữ liệu nhanh, nhưng bạn phải để ý đến race condition (nhiều thread cùng ghi vào một biến cùng lúc và làm hỏng kết quả).

Mô hình tinh thần đơn giản: "process = nặng nhưng độc lập, thread = nhẹ nhưng chia sẻ".

Process, thread và coroutine lồng nhau như thế này
Process (multiprocessing tạo cái này)
  • Đơn vị thực thi độc lập theo cách OS nhìn
  • Bộ nhớ độc lập · Không bị ràng buộc GIL
  • Chi phí khởi tạo cao; song song thật hoạt động
Thread (threading tạo cái này)
  • Đơn vị thực sự chạy code bên trong process
  • Bộ nhớ chia sẻ · Bị ràng buộc bởi GIL
  • Hiệu quả cho công việc I/O-bound
Coroutine (asyncio chạy cái này)
  • Chạy bằng cách chuyển lượt bên trong một thread duy nhất
  • Còn nhẹ hơn nữa; tốt cho I/O đồng thời số lượng lớn
multiprocessing sinh ra các process ngoài cùng, threading sinh ra các thread ở giữa, và asyncio chạy các coroutine trong cùng. Ba lớp khác nhau — bạn muốn song song hóa cái gì sẽ quyết định chọn cái nào.
Khác biệt giữa process và thread
Process(multiprocessing)Bộ nhớ độc lậpKhởi tạo tốn kémKhông can thiệpSong song thậtThread(threading)Bộ nhớ chia sẻKhởi tạo nhẹCẩn thận raceRàng buộc GIL
Processbộ nhớ độc lập, chi phí khởi tạo cao đổi lấy không can thiệp lẫn nhau. Threadbộ nhớ chia sẻ, nhẹ, nhưng đồng bộ hóa (Lock và bạn bè) là bắt buộc. GIL của Python giới hạn tính song song của thread đối với công việc CPU-bound.

threading và GIL — ràng buộc đối với thread của Python

Python (CPython) có một cơ chế gọi là GIL (Global Interpreter Lock) áp đặt ràng buộc: "chỉ một thread có thể thực thi bytecode Python tại một thời điểm". Đối với công việc nặng CPU, chạy nó trên nhiều thread đồng thời về cơ bản không nhanh hơn một thread duy nhất.

GIL — chỉ một thread chạy tại một thời điểm
Thread AĐang tínhGILA giữThread BĐang đợiThread AChờ I/O→ giải phóng GILGILB lấy đượcThread BBắt đầu tínhchuyển giao
GIL là một khóa duy nhất kiểm soát quyền thực thi bytecode Python. Các thread xếp hàng để giành lấy nó và giải phóng ngay khi gặp điểm chờ I/O, cho phép một thread khác xen vào trong khoảng thời gian đó.

Mặt khác, trong công việc I/O-bound (nơi thời gian bị chi phối bởi việc chờ phản hồi từ bên ngoài — mạng, file, DB) Python giải phóng GIL, nên threading thực sự tăng tốc công việc I/O-bound. Tuy nhiên, nếu workload là I/O-bound, asyncio thường có overhead nhỏ hơn và dễ viết hơn, nên với code mới hãy ưu tiên asyncio.

GIL ảnh hưởng đến thread như thế nào
CPU-bound(tính toán)threadingXếp hàng tại GIL→ Không song songCần multiprocessingI/O-bound(API / DB / file)threadingGIL nhả khi chờ I/O→ Chạy đồng thời(asyncio còn nhẹ hơn)
Công việc CPU-bound vẫn tuần tự dưới GIL bất kể bạn tạo bao nhiêu thread. Công việc I/O-bound giải phóng GIL trong lúc chờ, nên threading thực sự có hiệu quả. Để có song song CPU-bound thật, hãy dùng multiprocessing.
# threading: API mức thấp
import threading

def worker(name):
    print(f"{name} started")
    # làm gì đó
    print(f"{name} done")

t1 = threading.Thread(target=worker, args=("A",))
t2 = threading.Thread(target=worker, args=("B",))
t1.start()
t2.start()
t1.join()  # chờ hoàn thành
t2.join()

# concurrent.futures: API mức cao (khuyến nghị)
from concurrent.futures import ThreadPoolExecutor

def fetch(url):
    # trong code thật: requests.get(url) hoặc công việc I/O-bound khác
    return f"fetched: {url}"

with ThreadPoolExecutor(max_workers=4) as executor:
    urls = ["a.com", "b.com", "c.com"]
    results = list(executor.map(fetch, urls))
    print(results)

Với code mới, hãy chọn ThreadPoolExecutor

Dùng threading.Thread trực tiếp khiến việc quản lý vòng đời rối rắm. concurrent.futures.ThreadPoolExecutor an toàn để quản lý với with và cho phép bạn xử lý cả danh sách bằng executor.map(func, iterable) như một API duy nhất. Giới hạn số thread (max_workers) cũng tiện để không làm ngập server bằng quá nhiều kết nối.

Khi nào threading là lựa chọn tốt

threading / ThreadPoolExecutor phát huy khi bạn muốn lấp thời gian chờ bằng việc khác:

- Xử lý đồng thời nhiều Web API / truy vấn DB / file I/O

- Song song hóa các thư viện đồng bộ có sẵn (không hỗ trợ async)

- Đọc đồng thời output từ nhiều process được tạo ra bởi subprocess

- Chạy công việc nền mà không chặn main loop của GUI

multiprocessing — song song thật

multiprocessing là module để tạo nhiều process Python và chạy chúng song song. Vì process không bị ràng buộc GIL, bạn có thể chạy công việc CPU-bound theo kiểu song song thật — trên CPU 4 lõi, xử lý ảnh hoặc số học nặng nhanh lên khoảng 4 lần.

multiprocessing.Pool — song song thật trên 4 lõi
Input[d1, d2, d3, d4]Process 1Core 1heavy(d1)Process 2Core 2heavy(d2)Process 3Core 3heavy(d3)Process 4Core 4heavy(d4)Kết quả[r1, r2, r3, r4]
Bốn process Python chạy trên bốn lõi CPU riêng biệt, nên không có ràng buộc GIL và bạn có thực thi song song thật. Công việc CPU-bound nhanh lên khoảng 4 lần.
from multiprocessing import Pool

def heavy(n):
    return sum(i * i for i in range(n))   # công việc CPU-bound

if __name__ == "__main__":   # dạng bắt buộc cho multiprocessing
    with Pool(processes=4) as pool:
        results = pool.map(heavy, [10**6, 10**6, 10**6, 10**6])
        print("sum:", sum(results))

multiprocessing yêu cầu `if __name__ == '__main__':`

multiprocessing hoạt động bằng cách chạy lại script cha trong từng process con, nên gọi Pool(...).map(...) ở top level sẽ kích hoạt đệ quy vô tận và sập. Chế độ khởi động spawn của Windows / macOS đặc biệt nghiêm về điều này — luôn đặt code chính bên trong khối if __name__ == "__main__":.

subprocess — lệnh ngoài

subprocess là module để gọi lệnh ngoài (lệnh shell của OS) từ Python — chạy git status, chuyển đổi video bằng ffmpeg, gọi shell script và các trường hợp "khởi động một chương trình không phải Python" khác. Tên có vẻ giống multiprocessing, nhưng đó là một công cụ hoàn toàn khác.

subprocess.run — gọi lệnh ngoài từ Python
Pythonsubprocess.run([...])OStạo một processLệnh ngoàigit / ffmpeg v.v.Trả stdout /returncode về Python
Python yêu cầu OS chạy một lệnhmột process OS riêng biệt chạy lệnh ngoài → stdout và return code trở về trong một đối tượng CompletedProcess. Hữu ích để giao việc mà Python một mình không làm được.
import subprocess

result = subprocess.run(
    ["git", "status", "--short"],
    capture_output=True,
    text=True,
    check=True,    # sinh CalledProcessError nếu thất bại
)
print(result.stdout)
print("return code:", result.returncode)

Luồng quyết định — chọn cái nào?

Việc chọn giữa asyncio / threading / multiprocessing / subprocess quy về hai trục: "CPU-bound hay I/O-bound?""Bên trong Python hay là lệnh ngoài?". Sơ đồ luồng dưới đây loại bỏ phần lớn việc đoán mò.

Luồng quyết định cho đồng thời / song song
Gọi mộtlệnh ngoài?I/O-bound?(thời gian chờ chiếm)CPU-bound?(tính toán chiếm)→ subprocess→ asyncio(hoặc threading)→ multiprocessingYesYesYes
Lệnh ngoài → subprocess, I/O-bound → asyncio (hoặc threading), CPU-bound → multiprocessing. Ba trục để chọn đúng công cụ.
WorkloadChọnLý do
Gọi 100 Web API đồng thờiasyncioI/O-bound; nhẹ và dễ viết
Song song hóa client HTTP đồng bộ có sẵnthreading (ThreadPoolExecutor)Nếu thư viện không hỗ trợ async, dùng threading
Song song hóa xử lý ảnh trên 4 lõimultiprocessingCPU-bound cần process để tránh GIL
Chạy lệnh như git hoặc ffmpegsubprocessChuyên dụng để gọi chương trình ngoài Python
Hàng triệu phép toán đơn giảnNumPy / CythonVector hóa thắng song song hóa ở mức Python

CPU-bound thực sự hiếm hơn bạn nghĩ

Phần lớn code Python trông như đang kẹt ở tính toán thực ra nhanh lên 100 lần nhờ vector hóa với NumPy / Pandas / Cython. Trước khi đuổi theo gấp 4 lần với multiprocessing, kiểm tra xem nên làm gì trước: NumPy cho số học, Pandas cho dữ liệu, regex được tối ưu cho chuỗi.

QUIZ

Kiểm tra kiến thức

Hãy trả lời từng câu hỏi một.

Câu 1GIL (Global Interpreter Lock) của Python, tăng số thread không song song hóa được loại công việc nào?

Câu 2Bạn dùng cái nào để gọi các lệnh ngoài như git status từ Python?

Câu 3Cái nào phù hợp nhất để song song hóa thật sự tính toán nặng trên 4 lõi CPU?