在處理大量 I/O 密集型任務時,傳統的多執行緒模型因其上下文切換與資源消耗的成本,逐漸顯露出效能瓶頸。為此,軟體架構領域發展出多種使用者層級的併發方案,試圖在單一執行緒上實現更高的吞吐量。本文將從較早期的纖程(Fiber)與回呼函數(Callback)模型出發,剖析其在實作與維護上的複雜性。隨後,我們將探討協程(Coroutine)如何透過期貨(Future)與 async/await 語法,將複雜的非同步流程轉化為更符合直覺的狀態機與循序程式碼,並分析這種無堆疊(Stackless)協程的優勢與其在搶佔機制上的限制。最終,論述將回歸底層,闡明現代異步執行時期(Async Runtime)如何依賴作業系統提供的事件佇列(如 epoll、IOCP)來打造真正高效能的非阻塞 I/O 基礎設施。

纖程與綠色執行緒的挑戰

缺點

  • 堆疊增長機制複雜: 當堆疊空間不足時,需要額外的工作和複雜性來實現堆疊的增長,這涉及複雜的記憶體管理和指標更新。
  • 上下文切換開銷: 每次上下文切換仍然需要儲存和恢復CPU狀態,儘管比作業系統執行緒輕量,但仍有開銷。
  • 跨平台實作困難: 如果要支援多個平台和/或CPU架構,正確實現纖程/綠色執行緒會非常複雜。
  • FFI開銷與複雜性: 與外部函數介面(FFI)交互時,可能產生大量的開銷並增加意想不到的複雜性,因為需要協調運行時排程器與作業系統執行緒。

基於回呼函數的併發方法

注意!

這也是M:N執行緒的另一個範例。許多任務可以在一個作業系統執行緒上併發運行。每個任務由一連串的回呼函數組成。

你可能已經從JavaScript中了解了我們接下來要討論的內容,玄貓假設大多數人都熟悉。

回呼函數方法的整個想法是儲存一個指向我們希望稍後運行的一組指令的指標,以及所需的任何狀態。在Rust中,這將是一個閉包(closure)

在大多數語言中,實現回呼函數相對容易。它們不需要任何上下文切換,也不需要為每個任務預先分配記憶體。

然而,使用回呼函數表示併發操作要求你從一開始就以一種截然不同的方式編寫程式。將使用正常循序程式流程的程式重寫為使用回呼函數的程式,代表著一次實質性的重寫,反之亦然。

基於回呼函數的併發可能難以理解,並且會變得非常複雜。術語「回呼地獄(callback hell)」是大多數JavaScript開發人員都熟悉的,這並非巧合。

由於每個子任務都必須儲存以後所需的所有狀態,因此記憶體使用量將隨著任務中回呼函數的數量呈線性增長。

優勢

  • 在大多數語言中易於實現。
  • 沒有上下文切換。
  • 記憶體開銷相對較低(在大多數情況下)。

缺點

  • 記憶體使用量隨著回呼函數的數量呈線性增長。
  • 程式和程式碼可能難以理解。
  • 這是一種截然不同的程式編寫方式,它將影響程式的幾乎所有方面,因為所有讓出操作都需要一個回呼函數。
  • 所有權可能難以理解。結果是,在沒有垃圾收集器的情況下編寫基於回呼函數的程式會變得非常困難。
  • 由於所有權規則的複雜性,任務之間共享狀態很困難。
  • 調試回呼函數可能很困難。

協程:承諾與期貨

注意!

這也是M:N執行緒的另一個範例。許多任務可以在一個作業系統執行緒上併發運行。每個任務都表示為一個狀態機

JavaScript中的**承諾(Promises)和Rust中的期貨(Futures)**是基於相同思想的兩種不同實現。

不同實現之間存在差異,但我們在這裡不關注這些差異。值得稍微解釋一下承諾,因為它們因在JavaScript中的使用而廣為人知。承諾與Rust的期貨也有很多共同點。

首先,許多語言都有承諾的概念,但我將在以下範例中使用JavaScript中的承諾。

承諾是處理基於回呼函數方法所帶來的複雜性的一種方式。

取代了以下程式碼:

setTimer(200, () => {
setTimer(100, () => {
setTimer(50, () => {
console.log("我是最後一個");
});
});
});

我們可以這樣做:

function timer(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

timer(200)
.then(() => timer(100))
.then(() => timer(50))
.then(() => console.log("我是最後一個"));

後一種方法也稱為延續傳遞風格(continuation-passing style)。每個子任務在完成後呼叫一個新任務。

回呼函數和承諾之間的區別在底層更為實質性。你看,承諾返回一個狀態機,它可以處於三種狀態之一:待定(pending)已實現(fulfilled)已拒絕(rejected)

當我們在前面的範例中呼叫timer(200)時,我們得到一個處於待定狀態的承諾。

現在,延續傳遞風格確實解決了一些與回呼函數相關的問題,但它在複雜性和程式編寫方式方面仍然保留了許多問題。然而,它們使我們能夠利用編譯器來解決許多這些問題,我們將在下一段中討論。

此圖示將展示基於回呼函數的併發模型,以及承諾/期貨如何將其抽象化為狀態機。

@startuml
!define DISABLE_LINK
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam minClassWidth 100
skinparam defaultFontSize 16

package "基於回呼函數的併發" {
component "任務 A" as TaskA
component "回呼 A1" as CallbackA1
component "回呼 A2" as CallbackA2
component "回呼 A3" as CallbackA3
}

package "協程:承諾與期貨" {
component "承諾/期貨 (狀態機)" as PromiseFuture
state "待定 (Pending)" as Pending
state "已實現 (Fulfilled)" as Fulfilled
state "已拒絕 (Rejected)" as Rejected
}

TaskA --> CallbackA1 : 啟動
CallbackA1 --> CallbackA2 : 鏈接
CallbackA2 --> CallbackA3 : 鏈接
CallbackA3 --> TaskA : 完成

PromiseFuture -up-> Pending : 初始狀態
Pending --> Fulfilled : 成功完成
Pending --> Rejected : 發生錯誤

note left of CallbackA1 : 記憶體使用量隨鏈接長度線性增長
note right of PromiseFuture : 抽象化複雜的回呼鏈
note "回呼地獄" as CallbackHell
CallbackHell .up. CallbackA3

end note

end note

@enduml

看圖說話:

此圖示對比了基於回呼函數的併發協程(承諾/期貨)兩種模型。在基於回呼函數的併發中,一個任務由一系列回呼函數(如回呼A1、A2、A3)串聯而成,形成一個執行鏈。這種模式雖然簡單,但容易導致「回呼地獄」,程式碼難以閱讀和維護,且記憶體使用量會隨著回呼鏈的長度呈線性增長。相對地,協程中的承諾/期貨將異步操作抽象為一個狀態機,它有待定已實現已拒絕三種狀態。這種模型將複雜的回呼鏈封裝起來,提供了一種更結構化、更易於管理的異步程式設計方式,大大改善了程式碼的可讀性和可維護性,有效避免了回呼地獄的困境。

玄貓認為,協程與async/await模式的出現,極大地簡化了併發程式的編寫,使其在語法上更接近傳統的循序程式。然而,這種便利性背後,也隱藏著堆疊管理、搶佔限制以及調試複雜性等挑戰,這些都需要開發者深入理解其底層機制。

協程與 async/await

協程分為兩種:非對稱協程(asymmetric coroutines)對稱協程(symmetric coroutines)。非對稱協程會讓出給排程器,這也是我們將重點關注的類型。對稱協程則會讓出給一個特定的目的地,例如另一個協程。

雖然協程通常是一個相當廣泛的概念,但將協程作為物件引入程式語言,才真正使這種處理併發的方式能夠與作業系統執行緒和纖程/綠色執行緒所聞名的易用性相媲美。

你看,當你在Rust或JavaScript中編寫async時,編譯器會將看起來像普通函數呼叫的內容重寫為一個期貨(future)(在Rust的情況下)或一個承諾(promise)(在JavaScript的情況下)。另一方面,await會將控制權讓出給運行時排程器,任務會被暫停,直到你正在等待的期貨/承諾完成。

透過這種方式,我們可以以幾乎與編寫正常循序程式相同的方式來編寫處理併發操作的程式。

我們的JavaScript程式現在可以這樣編寫:

async function run() {
await timer(200);
await timer(100);
await timer(50);
console.log("我是最後一個");
}

你可以將run函數視為一個由幾個子任務組成的可暫停任務。在每個await點,它會將控制權讓出給排程器(在這種情況下,它是眾所周知的JavaScript事件循環)。

一旦其中一個子任務的狀態變為已實現(fulfilled)已拒絕(rejected),該任務就會被排程繼續執行下一步。

當使用Rust時,當你編寫類似這樣的內容時,你可以看到函數簽名發生了相同的轉換:

async fn run() -> () { /* ... */ }

該函數會包裝返回物件,並且不返回類型(),而是返回一個輸出類型為()Future

Fn run() -> impl Future<Output = ()>

在語法上,Rust的futures 0.1與我們剛剛展示的承諾範例非常相似,而我們今天使用的Rust期貨與JavaScript中async/await的工作方式有很多共同之處。

這種將看起來像普通函數和程式碼重寫為其他內容的方式有很多好處,但它並非沒有缺點。

與任何**無堆疊協程(stackless coroutine)**實現一樣,完全搶佔(full pre-emption)可能很難或不可能實現。這些函數必須在特定點讓出,與纖程/綠色執行緒不同,無法在堆疊框架中間暫停執行。某種程度的搶佔是可能的,例如,透過在編譯時插入搶佔點(pre-emption points),但這與能夠在執行期間的任何點搶佔任務不同。

搶佔點

搶佔點可以被認為是插入程式碼,呼叫排程器並詢問它是否希望搶佔任務。這些點可以由編譯器自動插入,但你需要編譯器支援才能充分利用它。具有元程式設計能力(例如宏)的語言可以模擬大部分相同的功能,但這仍然不會像編譯器意識到這些特殊異步任務那樣無縫。

調試是另一個在實現期貨/承諾時必須小心處理的領域。由於程式碼被重寫為狀態機(或生成器),你將不會擁有與普通函數相同的堆疊追蹤。通常,你可以假設函數的呼叫者是在堆疊和程式流程中都位於它之前。對於期貨和承諾,可能是運行時呼叫了推進狀態機的函數,因此可能沒有一個好的回溯可以讓你看到在呼叫失敗的函數之前發生了什麼。有一些方法可以解決這個問題,但大多數都會產生一些開銷。

優勢

  • 你可以像往常一樣編寫程式碼和模型程式。
  • 沒有上下文切換。
看圖說話:

此圖示闡明了async/await模式的核心機制及其帶來的挑戰。當開發者編寫帶有async/await關鍵字的原始循序程式碼時,編譯器會將其轉換為一個底層的狀態機(即期貨/承諾)。在每個await點,狀態機會將控制權讓出運行時排程器,等待異步操作完成後,排程器再恢復狀態機的執行。這種機制使得程式碼看起來像循序執行,但實際上是異步的。然而,這種無堆疊協程的實現方式也帶來了挑戰:搶佔只能發生在預定義的搶佔點(通常是await處),無法在任意位置中斷執行。此外,由於程式碼被轉換為狀態機,調試變得更加困難,特別是堆疊追蹤可能不直觀,因為實際呼叫函數的可能是運行時而非直接的邏輯呼叫者。

玄貓認為,async/await模式雖然在程式碼可讀性上帶來了巨大進步,但其底層的無堆疊協程特性,使其在搶佔和調試方面存在固有挑戰。這些限制促使我們更深入地探索作業系統層面的併發機制,尤其是在高效能I/O場景中,作業系統支援的事件佇列扮演著不可或缺的角色。

協程與 async/await

優勢

  • 易於在各種平台實作。

缺點

  • 搶佔難以實現: 由於任務無法在堆疊框架中間被停止,完全的搶佔很難,甚至不可能實現。
  • 需要編譯器支援: 為了充分發揮其優勢,它需要編譯器的深度支援。
  • 調試困難: 由於非循序的程式流程以及回溯資訊的限制,調試可能很困難。

深入理解作業系統支援的事件佇列、系統呼叫與跨平台抽象

玄貓認為,要實現高效能的異步I/O,必須深刻理解底層作業系統的機制。本章將聚焦於作業系統支援的事件佇列,這些是現代異步運行時的基石。

在本章中,我們將探討作業系統支援的事件佇列如何運作,以及三個不同的作業系統如何以不同的方式處理這項任務。之所以要深入探討這些,是因為玄貓所知的大多數異步運行時都使用這樣的作業系統支援的事件佇列作為實現高效能I/O的基礎。當你閱讀有關異步程式碼實際如何運作的資料時,你很可能會頻繁地聽到對這些技術的引用。

基於本章討論的技術的事件佇列被許多流行的函式庫使用,例如:

  • polling (https://github.com/smol-rs/polling),用於Smolasync-std的事件佇列。
  • JavaScript運行時和Julia程式語言。
  • C#用於其異步網路呼叫。
  • Boost.Asio,一個用於C++異步網路I/O的函式庫。

我們與主機作業系統的所有互動都是透過系統呼叫(syscalls)完成的。要使用Rust進行系統呼叫,我們需要了解如何使用Rust的外部函數介面(FFI)

除了了解如何使用FFI和進行系統呼叫之外,我們還需要涵蓋跨平台抽象。當創建一個事件佇列時,無論是你自己創建還是使用函式庫,你會注意到,如果你只對例如Windows上的IOCP如何運作有一個高層次的概述,那麼這些抽象可能看起來有點不直觀。原因在於這些抽象需要提供一個API,涵蓋不同作業系統以不同方式處理相同任務的事實。這個過程通常涉及識別平台之間的共同點,並在此基礎上構建一個新的抽象。

為了避免使用一個相當複雜和冗長的範例來解釋FFI、系統呼叫和跨平台抽象,我們將透過一個簡單的範例逐步引入這個主題。當我們稍後遇到這些概念時,我們將對這些主題有足夠的了解,這樣我們就能為後續章節中更有趣的範例做好充分準備。

在本章中,我們將討論以下主要主題:

  • 為何使用作業系統支援的事件佇列?
  • 基於就緒的事件佇列
  • 基於完成的事件佇列
  • epoll
  • kqueue
  • IOCP
  • 系統呼叫、FFI和跨平台抽象

此圖示將展示作業系統支援的事件佇列在異步運行時中的核心地位,以及其與系統呼叫和跨平台抽象的關係。

@startuml
!define DISABLE_LINK
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam minClassWidth 100
skinparam defaultFontSize 16

package "異步運行時" {
component "應用程式邏輯" as AppLogic
component "異步排程器" as AsyncScheduler
component "任務佇列" as TaskQueue
}

package "作業系統核心" {
component "系統呼叫介面" as SyscallInterface
component "事件佇列機制" as EventQueueMechanism
component "核心 I/O 處理" as KernelIO
}

package "跨平台抽象" {
component "統一 API" as UnifiedAPI
component "FFI 層" as FFI
}

AppLogic --> AsyncScheduler : 提交異步任務
AsyncScheduler --> TaskQueue : 管理任務
TaskQueue --> UnifiedAPI : 請求 I/O 操作

UnifiedAPI --> FFI : 透過 FFI 呼叫
FFI --> SyscallInterface : 執行系統呼叫
SyscallInterface --> EventQueueMechanism : 註冊 I/O 事件
EventQueueMechanism --> KernelIO : 處理實際 I/O

KernelIO --> EventQueueMechanism : I/O 完成/就緒
EventQueueMechanism --> SyscallInterface : 通知事件
SyscallInterface --> FFI : 返回結果
FFI --> UnifiedAPI : 傳遞結果
UnifiedAPI --> AsyncScheduler : 喚醒相關任務

note right of EventQueueMechanism : Epoll, Kqueue, IOCP
note left of UnifiedAPI : 屏蔽 OS 差異

end note

end note

@enduml

看圖說話:

此圖示描繪了作業系統支援的事件佇列在現代異步運行時中的核心角色。應用程式邏輯透過異步排程器將異步任務提交到任務佇列。當這些任務需要執行I/O操作時,它們會透過統一API(作為跨平台抽象的一部分)發出請求。這個統一API再透過FFI層作業系統核心系統呼叫介面交互,最終利用事件佇列機制(如Epoll、Kqueue、IOCP)向核心I/O處理註冊I/O事件。一旦核心I/O完成或就緒,事件佇列機制會通知系統呼叫介面,結果會經由FFI和統一API返回給異步排程器,進而喚醒相關任務繼續執行。這個流程展示了如何透過作業系統提供的底層機制,結合跨平台抽象,實現高效能、非阻塞的異步I/O。

玄貓認為,深入理解作業系統底層的I/O機制是構建高效能異步系統的基石。儘管跨平台抽象能簡化開發,但了解不同作業系統如何處理事件佇列和系統呼叫的細微差異,對於優化和調試至關重要。

權衡不同併發模型的演進軌跡,從回呼函數到async/await,體現了程式設計對簡化複雜性的不懈追求。然而,async/await的語法便利性,實則是將複雜度轉嫁給運行時,並衍生出搶佔困難與調試挑戰等內在限制。真正的效能瓶頸突破,並非來自語法抽象,而是取決於對底層作業系統事件佇列(epoll、kqueue等)的掌握深度。

未來,頂尖開發者的核心價值,將從應用抽象層,轉向具備穿透抽象、優化乃至構建運行時的能力。這代表著一種從「使用者」到「創造者」的思維躍遷。

玄貓認為,唯有向下扎根、理解系統核心,才能在享受抽象便利的同時,真正駕馭複雜性,成就卓越的技術領導力。表層的優雅固然誘人,但底層的堅實才是構建高效能、高韌性系統的唯一根基。