Python 非同步 20 年演進史

與 Go 或 Erlang 這種自誕生起便內建排程器與輕量執行緒的現代語言不同,Python 誕生於 1990 年代初。它的語言哲學、記憶體模型、全域直譯器鎖(GIL)以及廣大生態,全數根植於單執行緒、同步阻塞與 C 語言擴充套件相容性。

這項歷史前提決定了 Python 發展非同步時無法推倒重來,只能借用直譯器既有的機制(例如產生器)、疊加語法糖與標準函式庫設計,不斷尋求妥協。

這是一段長達二十餘年的「漸進式突圍」實錄:從早期以 select 為核心的 asyncore,到藉由產生器雙向通訊的「借道轉型」,再到社群框架的顯式與隱式路線分裂;最終由 Python 之父 Guido van Rossum 親自主導 Tulip 專案,確立官方 asyncio 標準。

然而,語法的標準化遠非故事終點。現代非同步架構帶來了函式著色的生態撕裂、threading.local 失效引發的跨請求狀態污染,以及缺乏約束的非結構化並行。

直到 Trio 發起結構化並行革命,Python 才在 3.11 迎來 TaskGroup。隨著 Python 3.13 推進可選的 Free-threading 建置(PEP 703 允許停用 GIL),並於 3.14 依 PEP 779 轉為正式支援,非同步協程與多執行緒的定位更面臨全新的重估。


一、史前時代與協程演進:從 asyncore 到產生器借道轉型

1. asyncore 的極限與狀態機泥淖

在 Python 2.x 早期,標準函式庫中處理非同步網路通訊的模組,是由 Sam Rushing 在 1990 年代編寫的 asyncore 與 asynchat(已於 Python 3.10 標記棄用,3.12 正式移除)。

asyncore 的核心邏輯非常原始:它將 Socket 物件封裝成 dispatcher 類別,底層維護全域 Socket 字典,並在主迴圈中呼叫作業系統的 select() 或 poll()。

在沒有協程概念的年代,這種設計帶來了沉重的工程包袱:開發者必須在類別實例上手動維護狀態機,通訊協定的交握、資料交換與逾時重試被硬生生割裂成離散的回呼片段;加上底層深度綁定傳統的 select(),受限於 FD_SETSIZE 的 1,024 檔案描述符硬上限與 O(N) 輪詢開銷,完全無法應對現代高並行需求。

2. 產生器的三次質變:協程的借道突圍

在不破壞直譯器既有排程架構的前提下,Python 核心團隊找到了一條巧妙的借道方案:將原本用於惰性計算的產生器(Generator),逐步擴充為具備上下文暫停與恢復能力的協程。這場變革經歷了三個關鍵 PEP:

3. 從產生器模擬到原生語法對照

PEP 380 讓產生器模擬非同步呼叫鏈的架構完全成熟,為 asyncio 的誕生鋪平了最後一哩路。但基於產生器的協程本質上仍是產生器,直譯器無法在型別層面防範誤用。

以下對照 Python 3.4 基於裝飾器與 yield from 的舊式非同步程式碼,與 Python 3.5+ PEP 492 原生語法的差異:

# 方案 A:Python 3.4 時代(PEP 380 + PEP 3156,基於產生器)
import asyncio

@asyncio.coroutine
def fetch_data_legacy(url: str):
    print(f"[Legacy] 開始擷取: {url}")
    yield from asyncio.sleep(1)
    return f"Data from {url}"

@asyncio.coroutine
def main_legacy():
    data = yield from fetch_data_legacy("https://example.com")
    print(f"[Legacy] 取得結果: {data}")

# 方案 B:Python 3.5+ 時代(PEP 492,原生協程)
async def fetch_data_native(url: str) -> str:
    print(f"[Native] 開始擷取: {url}")
    await asyncio.sleep(1)
    return f"Data from {url}"

async def main_native():
    data = await fetch_data_native("https://example.com")
    print(f"[Native] 取得結果: {data}")

在方案 A 中,fetch_data_legacy 回傳的是普通的 generator 物件。若開發者誤將其寫入 for item in fetch_data_legacy(),直譯器無法發出警告,導致邏輯無聲崩潰。

方案 B 引進的 async def 則宣告了專屬的 coroutine 型別。直譯器嚴格禁止將其當作迭代器(Iterator),且若未加上 await 便會觸發 RuntimeWarning: coroutine was never awaited,在型別與語意上,協程從此與產生器徹底分家。


二、早期路線大分裂:Twisted、Tornado 與 Gevent 的顯隱之爭

在官方 asyncio 標準確立之前,Python 社群在 2000 至 2012 年間展開了激烈的路線探索。這場爭論圍繞著「顯式(Explicit)還是隱式(Implicit)」以及「回呼(Callback)還是協程(Coroutine)」展開,形成了三個代表性框架。

1. Twisted 與回呼地獄:Reactor 模式的先驅

創始人 Glyph Lefkowitz 於 2001 年發起 Twisted,比 Node.js 早了約八年。Twisted 奠定了事件驅動架構的經典設計:以單一全域事件迴圈(Reactor 模式)監聽 Socket 事件,並提出 Deferred 物件處理非同步完成通知。

然而,當業務邏輯包含條件分支、重試或巢狀請求時,Deferred 鏈會演變成嚴重的回呼地獄(Callback Hell)。

更致命的是堆疊遺失。所有 Callback 都在事件迴圈頂層被呼叫,一旦拋出例外,原始呼叫點的呼叫堆疊早已消失,除錯日誌只剩下排程器的雜亂回溯。

2. Tornado 的折衷:IOLoop 與早期產生器協程

2009 年 FriendFeed 開源的 Tornado 帶來了重大衝擊。Tornado 原生具備非同步 HTTP 伺服器與簡潔的 IOLoop,並在 PEP 342 增強產生器就位後,於 2.1 版(2011 年)推出產生器式協程模組 tornado.gen,隨後在 3.0 版(2013 年)加入 @gen.coroutine 裝飾器。

開發者得以用 yield 替代回呼,在單一函式內享受線性程式設計的流暢感。

但在 PEP 380 誕生之前,Tornado 的產生器無法跨層穿透,任何跨函式呼叫都必須層層包裹裝飾器,限制了大型架構的模組化發展。

3. Gevent 的黑魔法:Greenlet 與猴子補丁

與 Twisted、Tornado 堅持顯式控制不同,Gevent 選擇了誘人但爭議巨大的路線——隱式並行(Implicit Concurrency)。

Gevent 基於 C 實作的微執行緒 greenlet 與 libev/libuv,核心武器是著名的猴子補丁(Monkey Patching)。只需一行 monkey.patch_all(),就能在執行期將標準函式庫的 socket、ssl 與 time 全數替換為協程切換實作。

看似優雅的黑魔法,卻在實際專案中付出了沉重代價:

4. 顯式與隱式的哲學十字路口

這場對決塑造了 Python 非同步的根本走向:

評估面向顯式陣營(Twisted / Tornado / 官方 asyncio)隱式陣營(Gevent)
程式碼可讀性透過 yield 或 await 明確標記 I/O 切換點表面如同步程式碼,切換點不可見
排程心智模型合作式排程(Cooperative),切換點可審查偽搶佔式體驗,切換點隱藏在底層系統呼叫中
舊有程式碼改造成本高:必須全鏈條改寫呼叫介面成本低:通常只需在入口處加上 patch_all()
C 擴充相容性優秀:可由開發者在專屬執行緒池明確隔離 C 呼叫極差:無法攔截底層 C 語言發起的阻塞呼叫
Python 之禪相容完全符合:顯式優於隱式、面對歧義拒絕猜測違背:依賴黑魔法偷換直譯器狀態

儘管 Gevent 在早期讓許多舊系統迅速享受到並行紅利,但 Guido van Rossum 與核心團隊最終斷然拒絕了隱式排程。Python 選擇堅持「顯式(Explicit)」原則,由語言與生態共同承擔語法重構的代價。


三、官方標準化之路:Tulip 啟航、Web 轉型與生態中空期

1. Guido 親征:Tulip 專案與 PEP 3156

2012 年前後,Python 3 的推廣遭遇陣痛。為了替 Python 3 打造無可替代的關鍵功能,Python 之父 Guido van Rossum 親自主導了代號為 Tulip(鬱金香) 的專案。

該專案最終收斂為 PEP 3156,並在 Python 3.4 以 asyncio 模組(初期為暫定 API)正式併入標準函式庫。

PEP 3156 確立了三大核心抽象:

2. PEP 492 原生語法:進入現代非同步時代

Python 3.4 雖然確立了標準函式庫,但 @asyncio.coroutine 與 yield from 依然充斥著妥協感。

2015 年,由 Yury Selivanov 主導的 PEP 492 在 Python 3.5 正式引進原生語法:

PEP 492 徹底確立了現代 Python 非同步的心智模型。隨後 Python 3.6 的 PEP 525(非同步產生器)與 PEP 530(非同步推導式),更進一步完備了語法拼圖。

3. Web 典範轉移:從 WSGI 到 ASGI

Web 是 Python 最廣泛的戰場。自 2003 年確立的 WSGI 規範(PEP 333,2010 年為 Python 3 更新為 PEP 3333)本質上是同步阻塞的:每個請求必須佔用一個實體行程或執行緒。當 WebSocket 與長連線串流爆發時,WSGI 顯得捉襟見肘。

2016 年,Django 核心開發者 Andrew Godwin 為了解決 Channels 的即時通訊需求,主導設計了 ASGI(Asynchronous Server Gateway Interface)。

ASGI 3.0 確立以單一非同步 Callable 為核心:接收 scope、receive 與 send 三個參數。這個簡潔的非同步介面,為 Uvicorn、Starlette 與 FastAPI 等現代框架奠定了標準基礎。

4. uvloop 的突破:Cython 與 libuv 逼近原生 I/O 極限

長期以來,社群對 Python 非同步的質疑在於:「直譯器效能有限又受制於 GIL,換成非同步真能變快嗎?」

2016 年,MagicStack 團隊的 Yury Selivanov 開源了 uvloop。它使用 Cython 將 Node.js 底層歷經考驗的 libuv C 函式庫,封裝為 asyncio.AbstractEventLoop 的直接實作。

uvloop 完全繞過了 Python 官方用純 Python 與部分 C 撰寫的預設迴圈。基準測試顯示,在更換為 uvloop 後,HTTP 與 TCP 處理效能普遍提升了 2 至 4 倍,在簡單 I/O 壓測中逼近 Node.js 與 Go,為 Python 高效能服務注入了強大信心。

5. 滯後的生態鏈:ORM 與資料庫驅動的雙軌陣痛

然而,當 Web 框架在高並行跑分榜上名列前茅時,一線開發者卻面臨一個殘酷的現實:Web 伺服器是非同步的,但後端資料庫全都是同步的。

在主流業務中,絕大部分時間都在與關聯式資料庫通訊。舊有的驅動(如 psycopg2、MySQLdb)底層全為阻塞式 C 呼叫;同時,ORM 的核心機制深植於同步語意:

在 2018 至 2021 年間,開發者被迫在兩種痛苦間二選一:要麼放棄 SQLAlchemy 成熟的模型關聯與遷移生態,改用輕量非同步查詢庫;要麼在非同步 View 內透過 run_in_executor 將同步 ORM 丟進執行緒池,承受高昂的切換成本。

雖然 MagicStack 於 2016 年開源的 asyncpg 帶來了數倍於傳統驅動的吞吐量,但高階 ORM 的翻新難度遠超預期。直到 SQLAlchemy 1.4 / 2.0(2021–2023 年),Mike Bayer 帶領團隊歷經數年重構底層,透過 AsyncSession 與 greenlet 技術完成內部轉化,官方生態才補齊這塊關鍵缺口。

這段五年的生態空窗期表明:語言層面的語法標準化往往只是第一步,周邊驅動與 ORM 生態的重塑才是工程落實最耗時的環節。


四、深水區的語言困境:函式著色、狀態污染與取消陷阱

當非同步進入生產環境深水區後,Python 開發者遇到的是語言層與執行期的結構性挑戰。

1. 函式著色問題(What Color is Your Function?)

2015 年,Bob Nystrom 在經典文章《What Color is Your Function?》中指出:顯式協程語言必然面臨生態被切分為「紅色函式(非同步)」與「藍色函式(同步)」的宿命。

在 Python 中,這項矛盾尤為尖銳:

2. 跨請求狀態污染:threading.local 的瓦解與 PEP 567

在同步多執行緒時代,全域上下文傳遞的唯一標準是 threading.local()。開發者將 Request ID、登入使用者或租戶資訊存入其中,即可在呼叫鏈深處隨時讀取。

但在單執行緒協程環境下,threading.local() 會引發災難性的跨請求資料污染:

CAUTION

協程環境不可用 threading.local() 存放請求級狀態:登入身分、租戶或 Request ID 可能被後續請求覆寫,在權限驗證與金流場景構成跨請求資料外洩。請改用 contextvars.ContextVar(PEP 567)隔離。

為了解決這個危機,Yury Selivanov 於 Python 3.7 提出了 PEP 567,引入 contextvars.ContextVar。它讓直譯器支援「隨協程樹複製與繼承」的上下文快照機制。

以下程式碼對照了兩者的核心行為差異:

import asyncio
import threading
import contextvars

# 錯誤示範:協程環境下使用 threading.local (引發跨協程狀態篡改)
thread_local = threading.local()

async def handler_with_thread_local(req_id: str):
    thread_local.req_id = req_id
    await asyncio.sleep(0.1)  # 讓出 CPU,其他協程極可能覆寫此值
    print(f"預期: {req_id}, 實際取到: {thread_local.req_id}")

# 正確做法:使用 PEP 567 contextvars.ContextVar (協程樹安全隔離)
request_context: contextvars.ContextVar[str] = contextvars.ContextVar("request_context")

async def handler_with_context_var(req_id: str):
    token = request_context.set(req_id)  # 當前協程 Task 獨立 Context 快照
    try:
        await asyncio.sleep(0.1)
        print(f"預期: {req_id}, 實際取到: {request_context.get()}")
    finally:
        request_context.reset(token)

在 threading.local 模型下,後抵達的請求會在協程讓出 CPU 時覆寫全域狀態,導致先前請求甦醒時讀取到錯誤資料。改用 contextvars 後,直譯器在衍生任務時會對上下文進行獨立快照,各協程修改互不干擾,徹底化解了微服務分散式追蹤與請求上下文傳遞的難題。

contextvars 的隔離邊界本質是 Task。事件迴圈在透過 create_task() 生成新任務時會自動對當前 Context 進行快照複製;因此,只要 Web 框架為每個 HTTP 請求指派獨立 Task,就能自然達成請求間的狀態隔離。

3. 例外與取消(Cancellation)的語意陷阱

非同步程式設計中最棘手的部分,往往落在連線中斷或逾時觸發時的取消(Cancellation)語意。在 asyncio 中,取消一個 Task 的底層機制是在協程暫停點注入 asyncio.CancelledError。

這個機制伴隨著兩個惡名昭彰的陷阱:

WARNING

asyncio.shield() 不會保護當前任務:它只讓被包裹的清理協程轉入背景續跑,當前任務仍會立刻捕獲 CancelledError;關鍵收尾應改用專屬逾時保護或獨立作用域,否則清理仍可能被中斷而遺留資源洩漏。


五、結構化並行革命:告別並行世界的 goto

1. 傳統並行的「Goto 危機」:Nathaniel J. Smith 與 Trio 的反思

2018 年,Trio 框架作者 Nathaniel J. Smith(njs)發表了開源社群名篇《Notes on structured concurrency, or: Go statement considered harmful》。

文章回顧了 1968 年 Edsger Dijkstra 的名篇《Go To Statement Considered Harmful》。在結構化程式設計誕生前,程式碼充斥著 goto 跳轉,導致變數生命週期混亂、呼叫堆疊支離破碎。現代語言透過迴圈、條件分支與函式呼叫徹底取代了 goto,確立了「控制流程具備明確入口與單一出口」的鐵律。

Nathaniel J. Smith 尖銳地指出:現代非同步語言中的 asyncio.create_task() 或 Go 語言的 go func(),本質上就是並行世界裡的 goto!

非結構化並行帶來了三大宿疾:

2. 結構化並行理念與 Nursery

結構化並行(Structured Concurrency)的核心原則是:並行任務必須擁有清晰的巢狀作用域邊界。父任務衍生出的所有子任務,必須在離開該作用域前全數終結(正常結束或被取消);父作用域對子任務的成敗與生命週期負有絕對責任。

這催生了「育兒室(Nursery)」模型:任何並行任務只能在明確的作用域內生成。一旦其中任一子任務引發未捕獲例外,作用域會自動向其餘所有同級任務傳送取消訊號,在收集所有例外後,將錯誤統一向外層拋出。

3. 從 asyncio.gather 到 Python 3.11 asyncio.TaskGroup

Python 社群深刻吸納了 Trio 的哲學。在引入 PEP 654(Exception Groups and except)* 後,Python 3.11 正式在標準函式庫推出了 asyncio.TaskGroup。

以下對照傳統 asyncio.gather 的失控風險,與現代 TaskGroup 的嚴格結構化約束:

import asyncio

async def worker_ok(name: str):
    await asyncio.sleep(2)
    print(f"[{name}] 順利完成!")

async def worker_fail(name: str):
    await asyncio.sleep(0.5)
    print(f"[{name}] 發生致命崩潰!")
    raise ValueError(f"{name} 失敗")

# 傳統作法:asyncio.gather (非結構化,存在背景任務逃逸)
async def test_gather():
    print("--- 測試 legacy gather ---")
    try:
        await asyncio.gather(worker_ok("Task-1"), worker_fail("Task-2"))
    except Exception as e:
        print(f"[gather 捕捉到例外]: {e}")
    # 注意:此時 Task-1 依然在背景悄悄運行,生命週期已脫離控制!
    await asyncio.sleep(2.0)

# 現代標準:Python 3.11+ asyncio.TaskGroup (結構化並行)
async def test_taskgroup():
    print("\n--- 測試現代 TaskGroup ---")
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(worker_ok("TG-1"))
            tg.create_task(worker_fail("TG-2"))
    except* ValueError as eg:
        print(f"[TaskGroup 捕捉到例外群組]: {eg.exceptions}")
        print("核心保證:任一任務崩潰,同 Scope 內其餘任務立即連鎖取消,避免殘留未受控的孤立任務。")

async def main():
    await test_gather()
    await test_taskgroup()

if __name__ == "__main__":
    asyncio.run(main())

在 test_gather 中,worker_fail 崩潰後,worker_ok 依然在背景持續執行直到第 2 秒。而在 TaskGroup 中,TG-2 失敗的瞬間,作用域自動向 TG-1 發出取消訊號,並將錯誤透過 PEP 654 ExceptionGroup 完整向上聚合,杜絕了一切幽靈任務。

此外,Alex Grönholm 開發的 AnyIO 相容層,讓開發者只需撰寫一套符合結構化並行規範的程式碼,即可直接在 asyncio 或 trio 上運作。目前 Starlette 與 HTTPX 均直接採用 AnyIO 作為非同步相容層。


六、終局思辨:No-GIL 時代的非同步定位與分層架構

隨著 Python 3.13 引入實驗性的 Free-threaded CPython(PEP 703,允許停用 GIL 的建置選項),並在 3.14 依 PEP 779 轉為正式支援,社群開始重新審視多執行緒的競爭力:當執行緒能實現真正的多核平行運算時,開發者是否還需要承擔 async/await 的心智負擔?

事實上,非同步協程與多執行緒所處理的效能瓶頸截然不同,兩者在架構上並非取代,而是分工互補。

1. 原生執行緒與非同步協程的本質差異

從系統資源與排程模型檢視,兩者存在根本分歧:

架構指標作業系統原生執行緒(OS Threads / No-GIL)非同步協程(Async Coroutines / asyncio)
排程方式搶佔式(Preemptive):由作業系統核心時鐘中斷強制切換合作式(Cooperative):由程式碼在 await 點主動讓出
記憶體佔用每個執行緒預設佔用 1MB 至 8MB 堆疊空間每個協程本質只是 Python Frame 物件,僅佔 數百 Bytes 至數 KB
並行規模極限單機建立 2,000 至 5,000 執行緒即達極限(記憶體吃緊、核心排程崩潰)單機可輕鬆維持 50,000 至 200,000 個活躍連線狀態
切換開銷核心態上下文切換(暫存器保存、快取失效、TLB 更新),成本高昂使用者態函式指標跳轉,速度接近一般函式呼叫
狀態同步安全風險極高:任何指令間隙均可能被中斷,需大量 Mutex/Lock 防範競爭低底層競爭、仍具邏輯競態:兩個 await 間具備單執行緒原子性,無記憶體 Data Race;但跨 await 的複合操作仍需以 asyncio.Lock 防範業務狀態衝突
核心解決痛點突破 GIL,利用多核 CPU 平行處理計算密集任務解決 C10K/C100K 高連線、高延遲的 I/O 多工等待問題

2. 未來架構終局:Async 外層多工+No-GIL 內層平行池

在 No-GIL 時代成熟後,Python 的最佳架構實踐將走向分層協同(Hierarchical Hybrid):

Free-threading 並非否定非同步的價值,反而補齊了非同步長久以來「一旦遇到 CPU 密集運算就卡死事件迴圈」的短板。

NOTE

延伸閱讀:Python GIL 30 年演進史

3. 架構思辨:回歸 Boring Technology

回顧這段長達二十年的演進,它給現代開發者留下了清晰的指引: