每一波技術都解決了上一波最嚴重的問題,卻又帶來新的挑戰。
作業系統執行緒有額外負擔:一個作業系統執行緒會為其堆疊保留虛擬位址空間,且在現代 Linux 上建立需要數十微秒的時間。(2023年4月27日編輯:原文提到「約一毫秒」及「一兆位元組堆疊空間」混淆了虛擬保留與實體記憶體承諾,感謝 HN 上的 ibraheemdev、jemfinch、eklitzke 指正)上下文切換發生在核心空間,消耗 CPU 週期,而 O(n) 的就緒輪詢(select、poll)在大規模時會累積成本。若一個伺服器處理數千個同時連線,且每個連線分配一個執行緒,則意味著數千個執行緒同時消耗記憶體並競爭排程。系統花費時間管理執行緒,這些時間本可用於執行有用的工作。
這就是 1999 年 Dan Kegel 所命名的 C10K 問題。如果你在建置網頁伺服器、聊天系統或任何需要大量同時連線的系統,你需要一種不以每個連線一個執行緒方式處理併發的方法。
解決方案分波出現,每一波解決前一波最嚴重的問題,同時引入新的問題。我們之前已探討過 Go 的 channel 與 Erlang 的 actor,現在來看如今無所不在的 async。
第一波很直接:不要阻塞執行緒。不是等待 I/O 操作完成,而是註冊一個函式,當操作完成時呼叫它,然後繼續做下一件事。事件迴圈(select、poll、epoll、kqueue)將數千連線多工到少數幾個執行緒,回呼函式成為程式設計師與此機制的介面。
Node.js 建立了整個生態系統,基於此模型在單一執行緒上處理數千個同時連線。Nginx 的事件驅動架構是它取代 Apache 處理高併發工作負載的主要原因。
這解決了效能問題,但代價是:回呼函式顛倒了控制流程。你不再寫「先做 A,再做 B,最後做 C」的三個連續敘述,而是寫「做 A,完成後呼叫這個函式做 B,B 完成後呼叫另一個函式做 C」。程式設計師的意圖散落在巢狀閉包中。JavaScript 開發者稱之為「回呼地獄」,甚至建立網站互相吐槽。
回呼函式的問題不僅是美觀,還包括錯誤處理分散。每個回呼都需要自己的錯誤路徑。錯誤無法自然向上傳播,因為沒有呼叫堆疊(回呼在與註冊處不同的上下文執行)。在回呼鏈中處理部分失敗,意味著錯誤狀態必須在每個函式中傳遞。
此外,回呼沒有取消機制。如果你啟動非同步操作後決定不需要結果,沒有通用方法停止它。回呼最終仍會被觸發,你的程式必須處理不再關心結果的情況。
回呼解決了資源問題(執行緒過多),卻產生了易用性問題(程式碼難寫、難讀、難維護)。
下一波起於一個好主意:非同步操作不再傳入回呼,而是立即回傳一個代表未來結果的物件。
這就是 Promise(JavaScript)或 Future(Java、Rust 等)。此概念可追溯至 1977 年 Baker 與 Hewitt,但直到 2010 年代 C10K 問題推動下才成為主流。JavaScript 在 ES2015 標準化原生 Promise,Java 8 引入 CompletableFuture。
Promise 比回呼更易用。首先,Promise 可組合:promise.then(f).then(g) 讀起來像管線而非巢狀金字塔。錯誤處理也統一:鏈末的 .catch() 處理任一步驟的失敗。Promise 是值,可以儲存、傳遞、從函式回傳。對未完成計算的值有第一級的操作權,讓討論從原始執行緒轉向資料依賴。表達「此值依賴尚未完成的計算」是有用的。
以下是 JavaScript 讀取使用者資料再抓取近期訂單,先用回呼,再用 Promise:
Promise 版本在小範例中提升不大,但複雜度增加時差異明顯:五層回呼幾乎不可讀,五個 .then() 連鎖至少是線性的。
但 Promise 也帶來新問題:
Promise 是一次性。Promise 只解析一次,不適合表示串流、事件、重複訊息或持續通訊。WebSocket 接收訊息串流無法映射為「未來會存在的值」。這造成分裂:Promise 用於請求-回應模式,其他則用事件發射器、觀察者或回呼。
組合笨重。上例暗示了這點:要同時取得使用者與訂單進入最後 .then(),需巢狀或 Promise.all 的尷尬操作。兩個獨立非同步操作簡單(Promise.all([a,b])),但更複雜的條件分支、迴圈、提前退出需更複雜的組合器模式。這些模式有效,但屬於函式式程式設計習慣,嫁接在命令式語言上不自然。
錯誤悄然消失。JavaScript 中未被 .catch() 捕捉的拒絕 Promise 會吞掉錯誤,導致錯誤無聲無息。Node.js 後來將未處理拒絕從警告改為程序崩潰,瀏覽器也新增 unhandledrejection 事件。原本為改善錯誤處理的功能,反而產生新的無聲失敗類型,這在回呼中不存在。
類型分裂。每個函式現在回傳值或值的 Promise,呼叫者必須知道取得哪種,函式庫也需決定提供哪種。加入資料庫呼叫後,同步函式變非同步,所有呼叫者都需處理 Promise。這是輕微的著色問題,下一波會更嚴重。
Promise 鏈仍與開發者寫的順序程式碼大相逕庭。async/await 由 C# 2012 年首創,JavaScript(ES2017)、Python(3.5)、Rust(1.39)、Kotlin、Swift、Dart 採用,正是解決此問題:
async/await 版本讀起來像順序程式碼。變數自然綁定。可用 try/catch 取代 .catch()。迴圈內可用 await。對線性非同步序列是易用性勝利。
業界快速採用,JavaScript 框架全力支持,Python asyncio 成為標準非同步 I/O 方法,Rust 穩定 async/await 作為高效能網路路徑。幾年內,async/await 成為多數主流語言撰寫併發 I/O 代碼的預設方式。
2015 年 async/await 正興起時,Bob Nystrom 發表「你的函式是什麼顏色?」的思考實驗,描述一種語言中每個函式是「紅」或「藍」。紅函式可呼叫藍函式,藍函式呼叫紅函式需特殊儀式。每個函式必須選色,藍函式呼叫紅函式會使藍函式變紅,病毒式擴散整個程式碼庫。
這是 async/await 的類比:async 函式是紅色,同步函式是藍色。async 函式可呼叫同步函式,但同步函式呼叫 async 函式需阻塞或重構。整個程式碼中每個函式必須選色,且此選擇會傳播。
Nystrom 的文章受歡迎,因為它為開發者經驗命名。函式著色改變整個程式碼庫與生態系統。
Rust async 生態系統因競爭執行環境(Tokio、async-std、smol)而分裂,這些執行環境提供不相容的基本類型實作,如 TCP 串流與計時器。為 Tokio 寫的函式庫難以與 async-std 兼容。熱門 HTTP 用戶端 reqwest 只需 Tokio,若專案用其他執行環境,則是你的問題。函式庫作者只能選擇 Tokio(排除其他選項)或嘗試執行環境無關抽象(增加複雜度與效能負擔)。
Tokio 的主導地位是生態系統層級的函式著色。此負擔也出現在其他層級:
函式層級:新增一個 I/O 呼叫會改變函式簽名、回傳型別與呼叫慣例。所有呼叫者與其呼叫者都必須更新。此變更波及整個呼叫圖,直到框架入口或主函式。一行資料庫查詢可能需修改數十個檔案。
函式庫層級:作者面臨寫同步函式庫排除非同步用戶,或寫非同步函式庫迫使同步用戶增加執行環境依賴(或維護兩者)。許多人選擇「兩者兼顧」,API 面積、測試矩陣與維護負擔加倍。Python 中 requests(同步)與 aiohttp(非同步)是不同作者的獨立專案。httpx 最終提供雙介面,僅因分裂而成改進。
生態系統層級:Rust 範例是常態。每個接觸 I/O 的函式庫必須選色,限制可搭配的其他函式庫。Rust async 書中指出「同步與非同步程式碼傾向不同設計模式,難以組合針對不同環境的程式碼」。
成本不僅是後勤問題:async/await 引入執行緒沒有的新型錯誤。O’Connor 記錄 Rust async 死結類型「futurelocks」:一個 future 持有鎖後停止被輪詢,另一個 future 嘗試取得同一鎖。執行緒持鎖會持續進展釋放(除非做危險操作如 SuspendThread),但 async Rust 的 select!、buffered streams、FuturesUnordered 會停止輪詢持有資源的 futures。Oxide 的原始 futurelock 需核心轉存與反組譯器診斷。
較不顯眼的成本是 async/await 最大優勢——讓非同步程式碼看似順序——也是認知陷阱。
以下程式碼依序取得訂單與推薦:getRecommendations 必須等 getOrders 完成。但兩者獨立,推薦不依賴訂單,理應可並行,卻未並行。程式碼看起來乾淨正確,卻犧牲效能。
並行版本需程式設計師明確跳出順序風格:
此模式在小範例外擴展性差。真實應用中有數十個非同步呼叫,判斷哪些獨立可並行需人工分析依賴並重構程式碼。順序語法反而遮蔽依賴結構,也就是告訴你哪些可並行的關鍵資訊。
async/await 是為簡化非同步程式碼而生,卻讓「哪些可併行」成為程式設計師必須手動判斷並用破壞順序流程的組合器模式表達的問題。
公平說,async 抽象確實改善了部分問題。
async/await 對線性序列的易用性優於回呼或 Promise 鏈。對本質上順序但含 I/O 的程式碼,async/await 去除語法噪音,比回呼更易讀與除錯。
部分語言設計師選擇不同路徑。例如 Go 故意選擇 goroutine,接受較重執行時代價,換取無函式著色。(2023年4月24日編輯:Go 實際透過 context.Context 引入某種著色,傳播取消訊號。4月27日編輯:先前語句暗示 Go 是因 async/await 反應而做決定)Java 的 Project Loom(Java 21 虛擬執行緒)也做不同賭注:輕量執行緒看起來像普通執行緒,無需改變程式碼顏色。Loom 團隊明確指出函式著色是他們想避免的問題。
Zig 更進一步:完全移除編譯器層級 async/await,改以 I/O 介面參數設計,I/O 操作接受此介面。執行時(執行緒、事件迴圈或用戶提供)實作介面。函式簽名不因排程方式改變,async/await 成為函式庫函式而非語言關鍵字。有人認為 I/O 參數本身也是一種著色。
研究其他生態系 async/await 經驗的語言設計師認為函式著色成本大於收益,選擇不同路徑。
每個解決方案都解決問題,但引入新成本。這些成本是結構性的,影響每個程式、函式庫與 API 的形態。
每波技術讓寫非同步程式的局部體驗更好,卻讓整體體驗更複雜。寫單一非同步函式的開發者從未如此輕鬆,但維護混合同步/非同步程式碼、管理跨執行環境相容性、尋找隱藏於順序 await 鏈後的並行機會的團隊,承擔了過去不存在的負擔。
這不是糟糕工程。設計回呼、Promise、async/await 的人都在解決真實問題,每一步都是合理回應前一步失敗。但十五年與數次迭代後,累積的代價不小,且模式明顯:每次修正都治標不治本。
從回呼到 Promise 再到 async/await 的演進,或許是本系列最清楚的例證:從「如何管理併發執行?」出發的方案,在每個抽象層級都持續產生新問題。你可以在單一生態系、單一十年內實時觀察這一過程。