Python 多執行緒 (Threading) 與 GIL
多執行緒 (Multithreading) 是一種讓程式同時執行多個任務的技術。在 Python 中,我們可以使用標準庫 threading 來建立執行緒。
然而,Python 的多執行緒有一個著名的特性(或限制),稱為 GIL (Global Interpreter Lock),這使得它在某些場景下的行為與其他語言(如 Java, C++)不同。
什麼是執行緒 (Thread)?
執行緒是作業系統能夠進行運算排程的最小單位。
- 輕量級:建立和切換的成本比 Process (行程) 低。
- 共享記憶體:同一個 Process 內的所有 Thread 共享同一塊記憶體空間。這意味著變數可以很容易共享,但也要小心「競態條件 (Race Condition)」。
建立執行緒
使用 threading.Thread 類別來建立執行緒:
import threading
import time
def worker(name, delay):
print(f"{name} 開始工作")
time.sleep(delay)
print(f"{name} 完成工作")
# 建立執行緒
t1 = threading.Thread(target=worker, args=("Worker-1", 1))
t2 = threading.Thread(target=worker, args=("Worker-2", 2))
# 啟動執行緒
t1.start()
t2.start()
print("主程式繼續執行...")
# 等待執行緒結束 (Join)
t1.join()
t2.join()
print("所有工作完成")
什麼是 GIL (Global Interpreter Lock)?
這是 Python 面試必考題,也是效能調優的關鍵。
GIL (全域解譯器鎖) 是 CPython 解譯器(官方標準 Python)的一種機制。它確保在同一時間,只有一個執行緒在執行 Python Bytecode。
為什麼要有 GIL?
為了簡化 CPython 內部的記憶體管理(特別是 Reference Counting 機制)。如果沒有 GIL,多個執行緒同時對同一個物件進行 Reference Count 加減,極易導致 Memory Leak 或 Crash。加上 GIL 是最簡單粗暴的解決方法。
GIL 的影響
這意味著,即使你的電腦有 8 核心 CPU,Python 的多執行緒程式也只能用到 1 個 CPU 核心。多個執行緒實際上是在一顆核心上快速切換 (Context Switch) 執行的,並無法實現真正的平行運算 (Parallelism)。
那麼,Python 多執行緒還有用嗎?
有用!但要看場景:
- CPU 密集型 (CPU-bound) 任務:如影像處理、大規模數學運算。
- 不適合使用 Threading。因為 GIL 會導致無法利用多核,甚至因為切換開銷而變慢。
- 解法:請使用
multiprocessing(多行程)。
- I/O 密集型 (I/O-bound) 任務:如爬蟲、讀寫檔案、資料庫連線。
- 非常適合使用 Threading。因為當執行緒在等待 I/O (如等待網頁回應) 時,GIL 會被釋放,讓其他執行緒有機會執行。
競態條件與鎖 (Lock)
由於執行緒共享記憶體,當多個執行緒同時修改同一個變數時,會發生 Race Condition,導致資料錯誤。
錯誤範例
import threading
counter = 0
def increment():
global counter
for _ in range(1000000):
# 這行看起來是一行,但在 Bytecode 層面是多個步驟 (讀取 -> 加一 -> 寫回)
# 中間可能隨時被切換,導致數據不一致
counter += 1
t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start(); t2.start()
t1.join(); t2.join()
print(f"最終結果: {counter}")
# 預期是 2000000,但在沒有 Lock 的情況下,結果通常會小於這個數字
使用 Lock 解決
lock = threading.Lock()
counter = 0
def increment():
global counter
for _ in range(1000000):
# 獲取鎖 (如果鎖被別人拿走,會在這裡等待)
lock.acquire()
try:
counter += 1
finally:
# 釋放鎖
lock.release()
# 也可以使用 Context Manager 寫法 (推薦)
# with lock:
# counter += 1
Daemon Thread (守護執行緒)
預設情況下,主程式會等待所有執行緒結束才關閉。如果你希望某個執行緒在主程式結束時自動被殺死(例如背景監控程式),可以將其設為 Daemon。
t = threading.Thread(target=background_task)
t.daemon = True # 必須在 start() 之前設定
t.start()
# 主程式結束時,t 也會直接結束,不會等待它跑完
Thread Local Data (執行緒區域資料)
有時候我們希望變數是「每個執行緒獨立擁有的」,而不是共享的。例如在 Web Server 中,每個執行緒處理不同的 Request,我們不希望 Request A 的資料被 Request B 的執行緒讀到。
threading.local() 可以幫我們達成這件事:
import threading
# 建立一個 Thread Local 物件
local_data = threading.local()
def process_data(data):
# 對 local_data 的修改只會影響當前執行緒
local_data.value = data
print(f"Thread {threading.current_thread().name}: {local_data.value}")
t1 = threading.Thread(target=process_data, args=("Data-1",), name="T1")
t2 = threading.Thread(target=process_data, args=("Data-2",), name="T2")
t1.start(); t2.start()
t1.join(); t2.join()
# Thread T1: Data-1
# Thread T2: Data-2
使用 ThreadPoolExecutor (執行緒池)
手動建立 threading.Thread 並管理它們(例如限制同時執行的數量)是比較麻煩的。
現代 Python 開發(Python 3.2+)推薦使用 concurrent.futures.ThreadPoolExecutor 來管理執行緒池。它的介面更高級、更易用。
from concurrent.futures import ThreadPoolExecutor
import time
def worker(n):
print(f"Worker {n} start")
time.sleep(1)
return f"Worker {n} result"
# 使用 Context Manager 自動開關 Pool
# max_workers=2 表示同時最多只有 2 個執行緒在跑
with ThreadPoolExecutor(max_workers=2) as executor:
# 提交任務
future1 = executor.submit(worker, 1)
future2 = executor.submit(worker, 2)
future3 = executor.submit(worker, 3)
# 獲取結果 (會等待任務完成)
print(future1.result())
print(future2.result())
print(future3.result())
print("All done")