Django 非同步演進:六年改造、架構瓶頸與 No-GIL 翻盤
2020 年 Django 3.1 宣布支援 Async Views(非同步 View)時,許多開發者以為這座 Python 最成熟的 Web 巨石終於要全面擁抱非同步。
幾年下來,許多團隊在生產環境將 View 改為 async def 後,換來的往往不是吞吐量提升,而是頻繁拋出的 SynchronousOnlyOperation、倍增的執行緒切換成本,以及事件迴圈被阻塞引發的全站逾時。
這些問題並非出自設定疏失,而是源自框架本身的結構限制:二十年的向後相容包袱、Python 語言層的語意天花板,以及周邊生態的斷層,共同決定了這場非同步改造的複雜度。
一、從 Channels 到 DEP 0009:漸進式改造的漫長跋涉
2005 年 Django 問世時,Web 的主流模型是同步請求-回應。伺服器透過 2003 年標準化的 WSGI(PEP 333)介面,為每個請求分配一個作業系統行程或執行緒。
在「每個請求綁定單一執行緒」的模型下,若要承載 1,000 個連線,系統就必須維護 1,000 個執行緒。隨著長連線與即時通訊需求爆發,行程與執行緒的記憶體開銷與切換成本過於高昂,Python 急需事件驅動的 I/O 架構。
2015 年,Django 核心開發者 Andrew Godwin 啟動了實驗性專案 Django Channels。Channels 透過 Daphne 介面伺服器與 Redis 事件匯流排派發背景 Worker,為 Django 帶來了 WebSocket 與非同步訊息處理能力。
Channels 最大的貢獻是催生了 ASGI(Asynchronous Server Gateway Interface)。ASGI 3.0 確立以單一非同步 Callable 為核心的規範,為 Python 非同步生態奠定地基,隨後促成了 Uvicorn、Starlette 與 FastAPI 的崛起。
2019 年 5 月,Andrew Godwin 正式提交 DEP 0009(Django Enhancement Proposal 0009: Async support)。這份藍圖明確宣示了核心原則:
The goal is to enable async for the developers who use Django, not to make Django itself a perfect, async-only project.
龐大的既有系統、數以萬計的第三方套件,決定了 Django 只能走「漸進式支援」與「雙軌並行(Dual-mode Parity)」路線。
Django 社群自此展開三個關鍵階段的改造長征:
| 演進階段 | 代表版本與年份 | 核心突破 | 架構考驗與代表性 API |
|---|---|---|---|
| 奠定地基與雙軌試水 | Django 3.0 至 3.1(2019 至 2020 年) | 引入 asgi.py 入口,支援原生 async def View 與中介軟體 | 導入 asgiref 執行緒池橋接;首度引爆 SynchronousOnlyOperation 保護衝突 |
| 資料庫存取破冰 | Django 4.1 至 4.2 LTS(2022 至 2023 年) | QuerySet 正式推出非同步介面,完善串流回應與測試支援 | 推出 a 字首系列方法(aget()、afirst()、aiterator()),奠定非同步查詢標準 |
| 深水區攻堅與邊界收斂 | Django 5.0 至 6.0(2023 年 12 月至今) | 身分認證與 Signal 非同步化,引入 psycopg 3 連線池並探索 Async Forms | 確立架構邊界:推進連線池與表單驗證,不再強求模板引擎與 Admin 非同步化 |
二、底層架構瓶頸:函式著色與延遲載入的語意死結
Django 的非同步改造之所以步履維艱,本質上撞上了電腦科學中經典的語意矛盾,以及框架二十年累積的高階抽象。
1. 函式著色與雙軌制代價
Bob Nystrom 在 2015 年的名篇《What Color is Your Function?》中指出:一旦程式語言引入 async/await,函式就被硬生生拆為紅色(非同步)與藍色(同步)。紅色函式必須用 await 呼叫,且只能被其他紅色函式直接執行。
Django 為了確保既有專案「百分之百不被破壞」,被迫維護龐大的雙軌介面:
- 查詢物件:
get()對應aget() - 取首筆紀錄:
filter().first()對應filter().afirst() - 身分驗證:
authenticate()對應aauthenticate() - 訊號廣播:
send()對應asend()
這導致 Django 核心程式碼體積急遽膨脹。只要開發者在非同步鏈條中誤呼叫了藍色程式碼,就會引發跨執行緒派發與切換開銷,甚至導致潛在死結。
雙軌制架構(紅色與藍色世界的鴻溝):
[同步世界 (藍色函式)]
View --> Middleware --> QuerySet (get) --> Sync DB Driver
|
+-- asgiref.sync.async_to_sync
| 呼叫非同步程式碼(阻塞派發)
[非同步世界 (紅色函式)]
View --> Middleware --> a-prefixed ORM
|
+-- asgiref.sync.sync_to_async
| 同步 DB Driver(主要路徑:執行緒池排隊)
|
+-- Async DB Pipeline
| (psycopg 3 等;逐步推進中:原生驅動)
|
+-- asgiref.sync.sync_to_async
同步程式碼(ThreadPool 分發)
目前 Django 的 a-prefixed ORM 介面(如 aget()、afirst())本質上大多仍是透過 asgiref 的 sync_to_async 將查詢派發至背景執行緒池執行,並非從底層全面非同步化。
2. ORM 延遲載入的語法死結
Django ORM 最具吸引力的設計是延遲載入(Lazy Loading)與點語法屬性導航。開發者只要寫下 book.author.profile.city,底層就會在存取欄位時自動觸發外部索引鍵查詢。
然而,這項優雅機制在非同步世界成了無法逾越的死結:
- Python 語法規格不允許屬性存取使用
await:點運算子觸發的__getattr__與描述器__get__是純同步魔術方法,語言層面根本沒有非同步描述器。 - 事件迴圈內禁止隱式阻塞:若允許
book.author在目前執行緒默默發起阻塞性 Socket 讀取,單一 Worker 上的所有非同步連線將同時停擺。
3. SynchronousOnlyOperation 保護屏障
為了防止隱式阻塞凍結事件迴圈,Django 引入了嚴格的防禦機制:一旦在非同步環境存取未預先載入的關聯欄位,系統會立即拋出 SynchronousOnlyOperation 例外。
這徹底打破了過去直覺的開發體驗。開發者必須時刻保持高度戒備,強制改寫所有查詢:
# 1. 必須在查詢時將所有巢狀關聯一次取回 (Eager Loading)
book = await Book.objects.select_related('author__profile').aget(id=1)
print(book.author.profile.city)
# 2. 或將涉及延遲載入的邏輯包裝進 sync_to_async
@sync_to_async
def get_author_city(book_id):
book = Book.objects.get(id=book_id)
return book.author.profile.city
city = await get_author_city(1)
4. 資料庫連線、交易與 ContextVars
在傳統 WSGI 環境下,每個作業系統執行緒持有獨立的資料庫連線,隔離機制由 threading.local() 天然保障。交易區塊透過 transaction.atomic() 管理,請求結束時自動釋放連線。
到了 ASGI 環境,多個協程交錯執行於同一個作業系統執行緒內。Django 改以 asgiref 的 Local 機制綁定連線:同步執行緒中等同 threading.local(),事件迴圈執行緒內則改用 context 變數區隔。
當協程透過 asyncio.gather 分發子任務時,子協程雖有獨立上下文快照,卻仍共用可變(mutable)的連線物件。由於底層驅動連線是單一通道,未受控的並行(Concurrency)查詢便可能引發協定層級的錯亂(如驅動端的 commands out of sync 錯誤),甚至讓巢狀 atomic() 的 rollback 狀態互相污染。
NOTE
延伸閱讀:Python 非同步 20 年演進史
三、另一面鏡子:FastAPI 原生表象下的隱性工程代價
社群經常好奇:為何 FastAPI 能呈現輕巧的原生非同步設計,Django 卻總給人修修補補的沉重感?
兩者的起點本質不同:FastAPI 誕生於 2018 年,是建立在 Starlette 與 Pydantic 之上的綠地專案(Greenfield);Django 則是承載了二十年歷史資產的全功能巨石(Brownfield)。
在架構邊界上,FastAPI 的輕巧在很大程度上是一種「架構責任外包」。它不內建 ORM、資料庫遷移、管理後台與表單系統,而是將 Web 開發中最繁重的狀態管理任務全部切割至框架邊界之外。
| 架構維度 | Django(全功能巨石) | FastAPI(微核心膠水層) |
|---|---|---|
| 設計哲學 | Batteries-Included 全功能內建,約定優於設定 | Micro-framework 極簡微核心,元件自由拼裝 |
| 持久化與遷移 | 內建成熟 ORM 與自動遷移,零設定開箱即用 | 外包給 SQLAlchemy 等,需手動設定 Alembic 協程橋接 |
| 延遲載入處理 | 拋出 SynchronousOnlyOperation 防禦性中斷 | 依賴 greenlet 微執行緒排程,漏載拋出 MissingGreenlet |
| 同步阻塞防護 | 混用同步呼叫由 asgiref 隔離至執行緒池 | 協程內誤呼叫同步 I/O 無警告凍結整個 Event Loop |
這種外包策略在真實的大型專案與長期維運中,往往會轉化為團隊必須自行承受的隱性代價:
1. 拼裝車架構與約定缺失
十個團隊開發 Django 專案,目錄結構與模組職責有高達九成保持一致,這是「慣例優於設定(Convention over Configuration)」帶來的協同紅利。
反觀 FastAPI 專案,缺乏統一規範常導致十個團隊拼裝出十種完全相異的土炮架構。特別是在資料庫遷移環節,開發者必須在 Alembic 的 env.py 內自行撰寫非同步引擎初始化與協程橋接邏輯,設定門檻與維護成本極高。
2. SQLAlchemy 2.0 Async 的假原生與 Greenlet 代價
FastAPI 最常見的持久化搭檔是 SQLAlchemy 2.0 Async。然而 SQLAlchemy 同樣受限於 Python 無法 await 屬性存取的鐵律。
當查詢漏寫了關聯預載(selectinload() 或 joinedload())時,底層嘗試延遲載入便會立即拋出著名的 MissingGreenlet 例外。為了解決這個矛盾,SQLAlchemy 不得不引入 C 擴充套件 greenlet 在執行緒內切換微執行緒上下文。
這種「非同步環境下的偽同步排程」使呼叫堆疊大幅加深,除錯成本倍增。更棘手的是,當 FastAPI 嘗試將 ORM 物件序列化為 Pydantic 模型時,欄位走訪會自動觸碰屬性,只要任一關聯未預先載入,序列化管線就會當場崩潰。
3. 無警告的 Event Loop 凍結
FastAPI 允許在同一個應用中混用同步 def 與非同步 async def,宣稱能自動將同步 View 卸載至 anyio 執行緒池。這在實務團隊協作中埋下了嚴重的穩定性隱憂。
若團隊成員在 async def View 中誤呼叫了阻塞性的同步操作(例如傳統 requests.get()、未非同步化的第三方 SDK 或本地大檔案讀寫),FastAPI 與 Python 直譯器在預設情況下不會發出任何警告。
其結果是:單執行緒事件迴圈當場凍結,同一個 Worker 上的數百個連線全面失去回應。若團隊為了防雷把所有操作全寫成同步 def,FastAPI 就會退化為預設僅有 40 個執行緒的 ThreadPool 模型,整體吞吐量甚至落後於調校後的 Gunicorn + WSGI。
WARNING
在 async def View 內誤用阻塞性同步 I/O,預設不會收到任何警告:單執行緒事件迴圈會當場凍結,同一 Worker 上的數百個連線全面失去回應。
4. CPU 密集型序列化對排程的掠奪
FastAPI 在跑分基準中的亮眼表現,多半建立在極小 Payload 的簡單測試上。在真實業務情境中,單一請求往往需要透過 Pydantic 驗證並轉換數百筆巢狀資料。
這種純 CPU 密集的序列化計算會在單執行緒事件迴圈中獨占 CPU 數十毫秒,進而引發事件迴圈延遲(Event Loop Lag)飆高,導致下游服務產生劇烈的延遲抖動。
四、生態斷層:DRF 的停滯與 Tom Christie 的出埃及記
要理解 Django 非同步推進的另一大障礙,必須正視 Python Web 開發史上的關鍵事件:Django REST Framework(DRF)的停滯與 Tom Christie 的自立門戶。
長期以來,DRF 是 Python 建構 Web API 時最廣泛使用的框架。然而 DRF 的核心架構深度綁定同步語意:
Serializer的欄位解析高度依賴 ORM 的點語法延遲載入ViewSet與GenericAPIView的請求生命週期方法均為同步簽名- 權限檢查(
has_permission)與身分認證模組全部為同步呼叫
若要將 DRF 徹底改造成原生非同步,整套序列化與 View 管線必須全數推倒重構,這等同於宣告既有龐大生態系作廢。該提案 issue 已於 2024 年 3 月結案,官方路線維持同步核心,非同步需求改建議由第三方套件 adrf 支援。
面對這道難題,DRF 作者 Tom Christie 選擇走出 Django 舒適圈,早在 2016 年便成立 Encode 組織,並自 2017 年起從底層重新打造現代非同步工具鏈。
這場出走直接重塑了 Python Web 格局:
- Uvicorn 結合
uvloop,讓 Python 的 I/O 吞吐量顯著提升,足以與 Node.js 及 Go 競爭 - Starlette 確立了現代 ASGI 路由與 WebSocket 的輕量核心規範
- HTTPX 成為支援同步與非同步的新一代 HTTP 用戶端
- 隨後,FastAPI 正是站在 Starlette 的肩膀上誕生並迅速竄紅
DRF 原作者的重心轉移,客觀上導致 Django 官方生態中最核心的 API 支柱停留在同步時代。
面對空白,Django 社群並未坐以待斃。Vitaliy Kucheryaviy 打造了 django-ninja,全面整合 Pydantic 與原生 async def View。
django-ninja 透過 Pydantic Schema 徹底解耦資料驗證與 ORM 查詢:入口驗證純資料,出口進行純值轉換,完全跳過 DRF 序列化過程中反覆觸發屬性 getter 的繁重反射,成為 Django 現代 API 開發的重要支柱。
NOTE
五、No-GIL 時代的逆轉:Boring Technology 的後發韌性
當 Python 開發者耗費無數心力將程式碼改造為 async/await 之際,Python 直譯器底層正在迎來三十年來最劇烈的變革——Free-threaded CPython(PEP 703 移除全域直譯器鎖 GIL)。
這場由 Sam Gross 推動的變革在 Python 3.13 成為官方實驗特性,並自 Python 3.14 起獲得官方正式支援。
NOTE
延伸閱讀:Python GIL 30 年演進史
在 GIL 時代,純 Python 的 Web 應用程式碼要利用多核,主要手段是透過 Gunicorn 等伺服器啟動多個 Worker 行程,每個行程持有獨立的直譯器與記憶體空間。同一份應用程式碼、ORM 中繼資料與快取結構在每個行程中各複製一份,記憶體開銷隨核心數線性成長。
async/await 之所以吸引人,正是因為它讓單一執行緒在 I/O 等待期間切換處理其他任務——在單行程內以協程多工處理大量並行連線,不必付出多行程的記憶體代價。
Free-threaded Python 移除了 GIL 這道枷鎖,開啟第三條路:同一行程內的多個執行緒不再被迫輪流執行,可以真正分散到不同 CPU 核心同時運算,且完全不受函式著色與屬性存取的語法束縛。
若這條路成熟,既有的同步程式碼只需調高執行緒數就能獲得平行(Parallelism)能力,將整個專案改寫為 async/await 的必要性便大幅降低。
Dan McKinley 在經典名篇《Choose Boring Technology》中曾寫道:
Technology for its own sake is snake oil.
——為了技術本身而追逐技術,不過是江湖賣藥。
回頭審視 Django 二十年來的堅持:
- 堅守以執行緒或行程為核心的同步模型
- 避免為了追求潮流而顛覆既有 API
- 捍衛資料庫連線的確定性與交易邊界
在 No-GIL 時代,這份「保守」反而轉化為強大的後發韌性(Late-mover Resilience):
- 既有系統無需承擔重構風險:二十年前撰寫的同步 Django 專案,在 Free-threaded 環境下只需拉高執行緒設定,便能以極低的記憶體增量獲得平行能力,省去改寫為
aget()的成本。 - 終結函式著色的心智分裂:開發者可以繼續享受
book.author.profile.city這種直覺的鏈式屬性導航,不必時刻提防SynchronousOnlyOperation。 - 既有同步生態重獲新生:那些因無法非同步化而停滯的成熟套件,在原生多執行緒時代重獲競爭力。
當然,No-GIL 並非萬靈丹。Free-threaded Python 在 3.14 才剛獲得官方正式支援,從直譯器穩定、C 擴充套件全面支援到 Django 與主流套件完成執行緒安全驗證,整個生態系的成熟保守估計仍需三至五年。
缺乏 GIL 保護後,依賴全域狀態的同步套件必須重新面對執行緒安全(Thread Safety)考驗;且作業系統執行緒的記憶體開銷,依然無法取代協程在數萬閒置長連線下的高密度優勢。
六、未竟之業與架構選型決策
儘管面臨挑戰,Django 官方並未停下非同步演進的腳步。未來的重點攻堅方向包括:
- 深化 psycopg 3 原生管線:全面發揮 psycopg 3 的原生非同步連線與 Pipeline Mode(管線批次模式),降低中介派發開銷。
- 探索 Async Forms 驗證流程:設計非同步表單驗證器,支援在表單驗證期直接執行非同步 I/O 查詢。
- 確立架構邊界:社群已逐步達成共識,不再強求將 Template 模板引擎與 Admin 後台徹底改為非同步,讓 SSR 系統繼續享有同步模型的確定性。
NOTE
實戰技術決策樹
面對不同業務情境,工程團隊應建立清醒的選型依據:
專案架構選型路徑:
核心業務型態是什麼?
+-- 企業內部系統 / 內容管理 / 複雜資料模型 (CRM / ERP)
| +-- 選用 Django(純同步 WSGI 模式,
| 搭配 Gunicorn 多行程 + 執行緒)
| 特點:極度成熟、可預測性高、維運心智負擔最低
|
+-- 純 API 服務 / 微服務 / 即時通訊需求 (WebSockets / SSE)
+-- 是否強烈需要 Django Admin、內建 Auth
| 與成熟 Migrations 體系?
| +-- 是 --> 選用 Django + django-ninja
| | (局部採用 async View 與 aget,
| | 搭配 Granian / Uvicorn)
| +-- 否 --> 選用 FastAPI / Starlette
| | (搭配 SQLAlchemy 2.0 Async,
| | 自行封裝架構邊界)
+-- 核心瓶頸是否為外部 API 聚合或高並行 I/O 多工?
+-- 是 --> 採用非同步架構
(asyncio.gather / HTTPX)
+-- 否 (單純 DB CRUD) -->
回歸同步 View 與成熟連線池
關鍵工程紀律
- 不要為了「現代感」盲目將 View 改為
async def:常規資料庫 CRUD 寫成同步 View 效能往往更穩定。唯有當 View 內部需要同時並行請求多個外部微服務時,非同步協程才能發揮真實價值。 - 嚴禁在非同步 View 內存取未預載的 ORM 關聯欄位:若在非同步管線操作資料庫,務必強制執行
select_related與prefetch_related,或將關聯邏輯封裝於明確的sync_to_async函式內。 - 在 No-GIL 前夕清醒界定多執行緒與協程的邊界:若業務核心為常規資料庫 CRUD,應優先堅守同步模型與多執行緒部署(若必須採用 ASGI,可評估成熟的 Uvicorn 或以 Rust 打造的 Granian),避免為了追求非同步而讓系統陷入佇列頭端阻塞(Head-of-Line Blocking)與雙軌損耗。
Django 的非同步演進,呈現了一座成熟巨石在典範轉移下的典型取捨。它沒有新興框架輕裝上陣的俐落,但新興框架的俐落,很大程度來自於將狀態持久化與架構約定完全外包給開發者自行承擔。
在非同步狂熱逐漸退潮、No-GIL 重新平衡多執行緒價值的當下,Django 二十年來對同步模型、確定性交易邊界與向後相容的堅持,不再只是保守的包袱,而是為長期維運保留了珍貴的穩定性。