本文 AI 產出,尚未審核

Python 並行與非同步(Concurrency & Async)

主題:GIL(Global Interpreter Lock)


簡介

在學習 Python 的併發(concurrency)與非同步(async)時,GIL(全域直譯器鎖) 是最常被提及,也最讓人困惑的概念。它不僅影響 CPU 密集型程式的效能,也直接決定了我們在選擇執行緒(thread)或多行程(process)時的設計取捨。了解 GIL 的運作機制、限制與繞過方式,對於想在 Python 中寫出高效能、可擴充的程式碼的開發者來說,至關重要。

本文將從 GIL 的起源與原理 切入,說明它與 CPU 核心記憶體管理 的關係,並提供 實作範例常見陷阱最佳實踐,最後列舉適合或不適合使用執行緒的 實際應用場景,幫助讀者在面對併發需求時,能夠作出正確的技術決策。


核心概念

1. 什麼是 GIL?

  • Global Interpreter Lock(全域直譯器鎖)是 CPython(最常見的 Python 實作)所採用的機制,用來保護 Python 物件的記憶體管理(尤其是引用計數)不被多執行緒同時修改。
  • 任何一個執行緒 要執行 Python bytecode 時,必須先取得 GIL。取得後,該執行緒才能執行;其他執行緒則被阻塞,直到 GIL 被釋放。
  • GIL 的存在讓 單執行緒的 Python 程式 在大多數情況下仍能保持記憶體安全,但同時也限制了 多執行緒的 CPU 並行 能力。

2. 為什麼會有 GIL?

  1. 簡化實作:CPython 的記憶體管理(reference counting)在多執行緒環境下若不加鎖,會導致競爭條件(race condition)與記憶體洩漏。GIL 讓開發者不必在每個物件操作上加上細粒度的鎖。
  2. 提升單執行緒效能:在沒有 GIL 的情況下,需要大量的細部鎖定,會產生額外的上下文切換與同步開銷。對於大量 I/O 密集型的腳本,GIL 的成本相對較低。
  3. 歷史因素:在 Python 早期,硬體多核心尚未普及,設計者選擇了「先做好單執行緒的正確性」而非「追求多核心併發」的路線。

3. GIL 的行為細節

時間點 行為描述
執行緒啟動 每個新執行緒在開始執行 Python bytecode 前,都會嘗試 acquire GIL。
執行 執行緒持有 GIL 時,會連續執行 一定的指令數(預設 5ms)遇到 I/O 阻塞,之後主動 release GIL,讓其他執行緒有機會取得。
I/O 操作 大多數阻塞的 I/O(如 socket.recv()file.read())會自動釋放 GIL,讓其他執行緒在等待期間繼續跑。
CPU 密集型 若程式長時間執行純 Python 計算,GIL 會造成 CPU 核心無法被充分利用,只能在單核心上跑。

4. GIL 與執行緒、行程的選擇

類型 何時使用 受 GIL 影響程度
threading(執行緒) I/O 密集、網路服務、UI 互動 :I/O 會釋放 GIL
multiprocessing(多行程) CPU 密集、計算密集型演算法 :每個行程都有自己的 Python 解釋器與 GIL
asyncio(協程) 大量輕量級 I/O、事件驅動服務 無直接影響:協程在單執行緒內切換,不涉及 GIL 競爭

程式碼範例

以下範例示範 GIL 在不同情境下的行為。每段程式碼均附上說明,方便讀者直接執行觀察。

範例 1️⃣:純 CPU 密集型的多執行緒(受 GIL 限制)

import threading
import time

def cpu_bound_task(n):
    """計算 n 次的費波那契,純 Python 計算,會持有 GIL"""
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

def worker():
    start = time.time()
    cpu_bound_task(10_000_00)   # 1 百萬次迭代
    print(f"Thread {threading.current_thread().name} 完成,耗時 {time.time() - start:.2f}s")

# 建立兩個執行緒
t1 = threading.Thread(target=worker, name="T1")
t2 = threading.Thread(target=worker, name="T2")

t1.start()
t2.start()
t1.join()
t2.join()

說明

  • 兩個執行緒同時執行 cpu_bound_task,但實際上 只有一個執行緒在同一時間持有 GIL,因此總耗時接近兩倍於單執行緒的結果。
  • 若改用 multiprocessing.Process,則會看到效能接近 2 核心的加速。

範例 2️⃣:I/O 密集型的多執行緒(GIL 釋放)

import threading
import time
import urllib.request

def fetch(url):
    """下載網址內容,I/O 操作會釋放 GIL"""
    start = time.time()
    with urllib.request.urlopen(url) as resp:
        _ = resp.read()  # 讀取全部資料
    print(f"{threading.current_thread().name} 下載完成,耗時 {time.time() - start:.2f}s")

urls = [
    "https://www.python.org",
    "https://docs.python.org/3/",
    "https://pypi.org",
    "https://realpython.com"
]

threads = [threading.Thread(target=fetch, args=(url,), name=f"T{i}") for i, url in enumerate(urls, 1)]

t0 = time.time()
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"全部下載完成,總耗時 {time.time() - t0:.2f}s")

說明

  • urllib.request.urlopen 在等待網路回應時會 釋放 GIL,讓其他執行緒得以執行。
  • 效能提升明顯,總耗時遠低於逐一串行下載的時間。

範例 3️⃣:使用 multiprocessing 繞過 GIL(CPU 密集型)

import multiprocessing
import time

def cpu_intensive(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

def worker(num):
    start = time.time()
    cpu_intensive(10_000_00)
    print(f"Process {num} 完成,耗時 {time.time() - start:.2f}s")

if __name__ == "__main__":
    start_total = time.time()
    processes = [multiprocessing.Process(target=worker, args=(i,)) for i in range(2)]

    for p in processes:
        p.start()
    for p in processes:
        p.join()

    print(f"兩個 Process 總耗時 {time.time() - start_total:.2f}s")

說明

  • 每個 Process 擁有自己的 CPython 解釋器與 獨立的 GIL,因此兩個 CPU 密集型任務可以同時佔用兩顆核心,效能提升接近理想的 2 倍。

範例 4️⃣:asyncio 與非阻塞 I/O(不涉及 GIL)

import asyncio
import aiohttp
import time

async def fetch(session, url):
    async with session.get(url) as resp:
        await resp.text()
    print(f"下載完成 {url}")

async def main():
    urls = [
        "https://www.python.org",
        "https://docs.python.org/3/",
        "https://pypi.org",
        "https://realpython.com"
    ]
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, u) for u in urls]
        await asyncio.gather(*tasks)

t0 = time.time()
asyncio.run(main())
print(f"全部 async 下載完成,總耗時 {time.time() - t0:.2f}s")

說明

  • asyncio 透過單執行緒的事件迴圈管理大量非阻塞 I/O,不會因 GIL 競爭而降低效能
  • 適合「大量同時連線」的情境,如爬蟲、即時聊天伺服器等。

範例 5️⃣:C 擴充模組釋放 GIL(計算密集型)

/* demo_gil.c - 使用 Cython 或 CPython C API 釋放 GIL 的範例 */
#include <Python.h>

static PyObject* heavy_compute(PyObject* self, PyObject* args) {
    long n;
    if (!PyArg_ParseTuple(args, "l", &n))
        return NULL;

    /* 釋放 GIL,允許其他 Python 執行緒同時執行 */
    Py_BEGIN_ALLOW_THREADS
    long a = 0, b = 1, tmp;
    for (long i = 0; i < n; ++i) {
        tmp = a + b;
        a = b;
        b = tmp;
    }
    Py_END_ALLOW_THREADS

    return PyLong_FromLong(a);
}

/* 模組定義略 */

說明

  • 若使用 C/C++ 編寫計算密集型擴充套件,只要在適當的區段使用 Py_BEGIN_ALLOW_THREADS / Py_END_ALLOW_THREADS,即可 手動釋放 GIL,讓多執行緒同時運行 C 程式碼。
  • 這是高效能數值計算(如 NumPy)能在多執行緒下仍保持效能的關鍵技巧。

常見陷阱與最佳實踐

陷阱 說明 解決方式
誤以為 threading 能提升 CPU 效能 在 CPU 密集型任務中,執行緒只能在單核心上交替執行,反而會增加上下文切換開銷。 改用 multiprocessing、C 擴充模組或利用 NumbaCython 釋放 GIL。
忘記在 I/O 之外釋放 GIL 只要程式裡有長時間的純 Python 計算,即使有 I/O,也會被 GIL 卡住。 使用 concurrent.futures.ThreadPoolExecutor 處理 I/O,將 CPU 任務交給 ProcessPoolExecutor
過度使用全域變數 多執行緒同時讀寫全域變數會產生競爭條件,即使 GIL 在保護物件引用,也無法保證業務邏輯的正確性。 使用 queue.Queuethreading.Lockmultiprocessing.Manager 來同步資料。
asyncio 內混用阻塞呼叫 阻塞的 I/O(如 requests.get) 會卡住事件迴圈,導致所有協程停頓。 改用 aiohttpaiomysql 等非阻塞套件,或將阻塞工作搬到執行緒池 (loop.run_in_executor)。
忘記在子行程中使用 if __name__ == "__main__" 在 Windows 平台上,multiprocessing 預設使用 spawn,若未加保護,會導致遞迴建立子行程。 必須在入口檔案加上 if __name__ == "__main__": 包裹程式碼。

最佳實踐彙總

  1. 先分析工作負載
    • I/O 密集threadingasyncio
    • CPU 密集multiprocessing、C 擴充或釋放 GIL 的 Cython/Numba 程式碼。
  2. 盡量使用標準庫的高階抽象concurrent.futures.ThreadPoolExecutorProcessPoolExecutor 能自動管理執行緒/行程池。
  3. 避免在多執行緒環境中共享可變資料,使用 QueueLock 來保護。
  4. 測試與基準:使用 timeitcProfilepy-spy 等工具,確認是否真的因 GIL 受限而需要改變實作。
  5. 適度使用 C 擴充:對於關鍵的計算密集段,考慮寫成 Cython 或使用已有的釋放 GIL 的第三方套件(如 NumPy、Pandas 的底層 C 函式)。

實際應用場景

場景 推薦的併發模型 為何適合
Web Scraper(大量 HTTP 請求) asyncio + aiohttpthreading + requests(配合 ThreadPoolExecutor 網路 I/O 會自動釋放 GIL,協程或執行緒都能同時發出多筆請求,效能佳。
即時聊天伺服器 asyncio(單執行緒事件迴圈) 高併發、低延遲,且不需要 CPU 密集計算。
圖像處理或機器學習前處理 multiprocessingNumba/Cython 釋放 GIL 的函式 每張圖片的處理是 CPU 密集型,使用多行程能充分利用多核心。
資料庫批次寫入 ThreadPoolExecutor(若使用支援非阻塞的資料庫驅動)或 asyncio + aiopg/aiomysql 大量 I/O,執行緒或協程皆可,依據資料庫驅動的支援度選擇。
CPU 密集型演算法(遺傳演算法、蒙地卡羅模擬) multiprocessing + ProcessPoolExecutor 每個子任務獨立且計算量大,行程間無共享狀態,適合多核心併行。
自訂 C 擴充套件 使用 Cython/CPython C API 並在計算區段釋放 GIL 需要在 Python 中直接呼叫高速 C 函式,且仍想保持多執行緒的併發能力。

總結

  • GIL 是 CPython 為了保護記憶體管理而設計的全域鎖,會限制多執行緒在 CPU 密集型任務上的併行,但對 I/O 密集型工作影響不大,因為 I/O 操作會自動釋放 GIL。
  • 選擇正確的併發模型:根據工作負載(CPU vs I/O)決定是使用 threadingmultiprocessingasyncio,或是結合 C 擴充釋放 GIL。
  • 避免常見陷阱:不要把 CPU 密集型任務交給純執行緒、不要在協程內呼叫阻塞函式、注意全域變數的競爭條件。
  • 最佳實踐:先分析需求、使用高階抽象(concurrent.futuresasyncio),必要時採用 Cython/Numba 釋放 GIL,並透過基準測試驗證效能。

掌握 GIL 的本質與運作方式,能讓你在 Python 中更靈活地設計併發程式,寫出既安全高效的應用。祝你在未來的專案中,能根據需求恰當選擇執行緒、行程或協程,充分發揮硬體資源的潛力!