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