Python 並發 FAQ

綜合 更新於 Oct 9, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — Python 實際上怎麼跑並發:GIL、執行緒與行程與 asyncio 的取捨、同步原語,以及該怎麼選。 另見:faq_python.md — 語言本身; cs_basic.md — 作業系統層面的行程、執行緒與排程。


1) GIL 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

**全域直譯器鎖(Global Interpreter Lock)**是 CPython 裡的一把互斥鎖,它讓 同一時間只有一條執行緒能執行 Python bytecode,就算你有 16 核也一樣。

它為什麼存在:CPython 的記憶體管理是參考計數,而計數的更新不是原子的。一把粗粒度 的鎖讓直譯器(以及所有依它撰寫的 C 擴充)既正確、單執行緒又快,代價是沒辦法平行執行 bytecode。

它擋住什麼、不擋什麼:

工作 開多執行緒有用嗎? 為什麼
CPU 密集的純 Python(迴圈、解析、數學) ❌ 沒用 同時只有一條執行緒握著 GIL
阻塞 I/O(網路、磁碟、subprocess) ✅ 有用 系統呼叫期間 GIL 會被釋放
time.sleep ✅ 有用 會釋放 GIL
NumPy / pandas / hashlib / 壓縮之類的重運算 ✅ 通常有用 C 擴充在長時間運算期間會釋放 GIL

所以真正該記住的規則是:等待用執行緒,運算用行程。

python
# python
# 4 threads on a CPU-bound loop  -> about as slow as 1 thread (sometimes slower,
#                                   from GIL hand-off contention)
# 4 threads on 4 HTTP requests   -> about 4x faster

直譯器大約每 5 ms(sys.setswitchinterval)或在執行緒阻塞時切換一次。Python 3.13 出了 一個實驗性的 free-threaded 版本,沒有 GIL;在它成為預設之前,就照上面那張表理解。


2) 三種模型 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

threading multiprocessing asyncio
單位 OS 執行緒 OS 行程 協程(同一條執行緒)
CPU 平行 ❌(GIL) ✅ 真正的平行 ❌
並發 I/O ✅ ✅ ✅✅(幾千個 socket)
記憶體 共享 各自獨立 —— 資料要pickle 過去 共享
每單位成本 約 8 MB stack,啟動微秒級 數 MB,啟動毫秒級 約幾 KB,切換奈秒級
切換方式 搶佔式(任何 bytecode 邊界) OS 排程器 協作式 —— 只在 await 處
失敗模式 資料競態 序列化成本、沒有共享狀態 一個阻塞呼叫凍住全部
適合 阻塞 I/O、舊的函式庫 CPU 密集工作 大量並發網路呼叫

一句話選擇:

  • 大量網路/磁碟等待,而函式庫是阻塞式的 → 執行緒(或執行緒池)。
  • 大量網路等待,而函式庫是非同步的 → asyncio。
  • 純 Python 的大量計算 → 行程(或用 NumPy,或把迴圈搬進 C)。
  • 兩者都有 → I/O 用 asyncio,CPU 的部分交給 ProcessPoolExecutor。

3) 執行緒 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

該用的 API 是 concurrent.futures

python
# python
from concurrent.futures import ThreadPoolExecutor, as_completed

urls = [...]
with ThreadPoolExecutor(max_workers=16) as pool:          # shutdown on exit
    futures = {pool.submit(fetch, u): u for u in urls}
    for fut in as_completed(futures):
        url = futures[fut]
        try:
            handle(fut.result())     # result() re-raises the worker's exception
        except TimeoutError:
            log.warning("timed out: %s", url)
  • pool.map(fn, items) 會保持輸入順序,想要全部結果時很適合。
  • as_completed 誰先完成就先給誰 —— 顯示進度或提早結束時比較好。
  • **future 的例外只有在你呼叫 .result() 時才會被丟出來。**忘了呼叫就會把失敗 靜靜吞掉;這是用 executor 最常見的 bug。
  • 池的大小要照等待量抓,不是照核心數:做 I/O 時 max_workers 幾十個很正常。

競態還是會發生

GIL 不會讓你的程式碼變成執行緒安全 —— 它只是把 bytecode 的執行序列化,而 counter += 1 是一個讀-改-寫,會被編成好幾個 bytecode(幾個要看版本), 所以切換可能正好落在中間。

python
# python
import threading

lock = threading.Lock()
with lock:                 # always `with`, never manual acquire/release
    counter += 1
原語 用途
Lock 互斥
RLock 可重入 —— 同一條執行緒可以再取得一次
Semaphore(n) 限制並發數(例如同時 5 個 API 呼叫)
Event 一次性的廣播旗標(set() / wait())
Condition 等某個條件成立,然後被通知
Barrier(n) 讓 n 條執行緒一起放行
queue.Queue 執行緒安全的交棒 —— 優先用它,不要共享 list 再加鎖
threading.local() 每條執行緒各自的狀態(request id、DB 連線)

用 queue.Queue 寫的生產者/消費者完全不需要顯式加鎖 —— 對於大多數「執行緒之間怎麼 共享資料」的問題,它就是那個標準答案。

Daemon 執行緒與關閉

daemon 執行緒不會讓行程活著,而且會在結束時被粗暴地砍掉 —— 千萬不要把你在意的 finally 放在裡面。要乾淨地關掉 worker,請用佇列上的哨兵值或一個 Event。


4) 行程 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

python
# python
from concurrent.futures import ProcessPoolExecutor

if __name__ == "__main__":                 # REQUIRED on macOS/Windows (spawn)
    with ProcessPoolExecutor() as pool:    # defaults to the usable CPU count
                                          # (os.process_cpu_count() on 3.13+,
                                          #  os.cpu_count() before that)
        for result in pool.map(crunch, chunks, chunksize=8):
            ...

會咬人的地方:

  • **任何跨越邊界的東西都會被 pickle。**lambda、閉包、開著的 socket 與資料庫 handle 都過不去。送純資料,不要送包著資源的物件。
  • 啟動方式:fork(Linux 在 3.14 之前的預設 —— 快,但和執行緒併用不安全)、 spawn(macOS/Windows 預設 —— 全新的直譯器,所以模組層級的程式碼會重跑一次)、 forkserver。多數「在我的 Linux 上就好好的」都出在這裡的行為差異。
  • **把工作分塊。**每個項目的 IPC 開銷可能遠大於運算本身;chunksize 是常見解法。
  • 共享狀態:訊息用 multiprocessing.Queue/Pipe,大筆資料用 Value/Array 或 shared_memory,Manager 方便但慢(它會透過一個伺服器行程做代理)。

5) asyncio 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

一條執行緒、一個事件迴圈、很多協程。協程會一直跑到遇上 await,然後把控制權交回 迴圈,讓迴圈去跑其他準備好的工作。

python
# python
import asyncio, aiohttp

async def fetch(session, url):
    async with session.get(url, timeout=5) as resp:
        return await resp.json()

async def main(urls):
    async with aiohttp.ClientSession() as session:
        async with asyncio.TaskGroup() as tg:              # 3.11+
            tasks = [tg.create_task(fetch(session, u)) for u in urls]
    return [t.result() for t in tasks]

asyncio.run(main(urls))

規則

  • **await 是任務唯一能被暫停的地方。**協作式排程的意思是:一個慢的同步呼叫會擋住 其他所有任務。
  • 絕對不要在協程裡呼叫阻塞的程式碼 —— time.sleep、requests.get、吃重的 CPU 迴圈。改用非同步版本(asyncio.sleep、httpx/aiohttp),或把它推出迴圈: await asyncio.to_thread(blocking_fn, arg)。
  • 呼叫一個協程函式在被 await 或被排程之前什麼都不會發生 —— 少寫 await 的 fetch(url) 就是那個經典的「怎麼什麼事都沒發生?」。
  • 並發來自任務,不是來自 await 本身:在迴圈裡一個一個 await 是循序的; TaskGroup / asyncio.gather 才是並發。
工具 做什麼
asyncio.run(main()) 在整個程式的生命週期裡擁有那個事件迴圈
TaskGroup(3.11+) 結構化並發:等全部完成,出錯時取消兄弟任務,丟出 ExceptionGroup
gather(*aws) 比較舊的等價物;return_exceptions=True 可以收集錯誤而不是立刻失敗
wait_for(aw, timeout) / asyncio.timeout() 期限 —— 網路呼叫永遠要加一個
Semaphore 限制在途請求數,免得把對方打爆
Queue 非同步的生產者/消費者
to_thread / loop.run_in_executor 阻塞或 CPU 工作的逃生門

async for / async with 是非同步的迭代器與情境管理器協定 (__aiter__/__anext__、__aenter__/__aexit__)。

取消

取消一個任務,會在它下一個 await 處在它內部丟出 CancelledError。 讓它往外傳 —— 接住它然後「清理一下繼續跑」會讓關閉流程壞掉。清理放在 finally, 而 asyncio.shield 只留給那種非完成不可的少數操作。


6) 實例比較

同一件工作 —— 100 次 HTTP 呼叫,再對每個回應算 hash —— 三種寫法:

text
sequential          : 100 x (latency + hash)          slowest
ThreadPoolExecutor  : latency overlapped, hash under the GIL (one at a time)
asyncio             : latency overlapped, lowest overhead; hash still serialises
asyncio + ProcessPool: latency overlapped AND hashing parallel across cores  ← best

面試官想聽到的重點:**說清楚你在等什麼。**等待用執行緒或 asyncio 疊起來; 運算用行程平行化。


7) 面試常見問答

Q:為什麼多執行緒沒有讓我 CPU 密集的 Python 變快? GIL 把 bytecode 的執行序列化了。改用 multiprocessing、用會釋放 GIL 的 C 擴充 函式庫(NumPy),或換一個 runtime。

Q:既然有 GIL,為什麼還需要鎖? 因為 GIL 會在 bytecode 之間被釋放。x += 1、先檢查再動作,以及任何多步驟的 不變條件,都可能被交錯。

Q:一萬個並發連線,該用 asyncio 還是執行緒? asyncio。一萬條 OS 執行緒代表好幾 GB 的 stack 與沉重的 context switch; 一萬個協程每個只有幾 KB,切換就跟函式返回差不多。

Q:什麼是競態條件?在 Python 裡怎麼找出來? 兩條執行緒在共享的可變狀態上交錯。製造競爭來重現它(開更多執行緒、把 sys.setswitchinterval 調小),然後靠縮小共享狀態來修 —— 訊息傳遞優於鎖。

Q:什麼是死鎖?怎麼避免? 兩條執行緒各自握著對方要的鎖。用全域一致的順序取鎖、用 acquire(timeout=…)、把臨界區縮短。四個 Coffman 條件見 cs_basic.md。

Q:怎麼給工作設時限? 非同步程式用 asyncio.timeout() / wait_for;用 executor 時用 future.result(timeout=…)。注意 Python 裡沒辦法強制砍掉一條執行緒 —— 設計上要用 協作式的停止旗標,或改用可以 terminate 的行程。

Q:threading.local() 是做什麼的? 存放不能共享、每條執行緒各自的狀態:寫 log 用的 request id、資料庫 session。 asyncio 的對應物是 contextvars.ContextVar,而且它還能跨 await 存活。


8) 重點檢查表

text
[ ] Explain the GIL, and exactly which workloads it blocks
[ ] Threads for waiting, processes for computing, asyncio for many sockets
[ ] ThreadPoolExecutor + as_completed, and why .result() must be called
[ ] queue.Queue producer/consumer without explicit locks
[ ] Pickling boundary and spawn vs fork for multiprocessing
[ ] async/await: tasks give concurrency, await alone does not
[ ] Never block the event loop; asyncio.to_thread as the escape hatch
[ ] TaskGroup, timeouts, cancellation and CancelledError
[ ] contextvars vs threading.local

參考資料