為建構高效能且可跨平台運行的應用程式,開發者必須深入理解作業系統底層的 I/O 處理機制。雖然高階語言與框架提供了便利的異步語法,但其效能根源仍取決於如何與作業系統核心互動。本文將從系統呼叫(Syscall)與外部函數介面(FFI)的層次切入,解析不同平台(Linux、macOS、Windows)主流的事件通知機制,如 epoll、kqueue 與 IOCP。透過對比阻塞式、非阻塞式輪詢及事件驅動模型,我們將揭示現代異步執行環境如何透過建立統一的抽象層,來屏蔽平台差異並最大化資源利用率,最終實現真正高效的非阻塞式 I/O 操作。此理論基礎對於設計可擴展的網路服務與高吞吐量系統至關重要。
深入理解作業系統支援的事件佇列、系統呼叫與跨平台抽象
注意
有一些流行但較少使用的替代方案,你應該了解,儘管我們在這裡不涵蓋它們:
- wepoll:這在Windows上使用特定的API並封裝了IOCP,使其與常規IOCP不同,更接近Linux上epoll的工作方式。這使得在兩種不同技術之上創建具有相同API的抽象層變得更容易。它被用於各種應用程式。
- io_uring:這是Linux上一個相對較新的API,與Windows上的IOCP有許多相似之處。
玄貓深信,在你閱讀完接下來的兩個章節後,如果你想了解更多關於它們的資訊,你會很容易地查閱它們。
技術要求
本章不需要你設置任何新的東西,但由於我們正在為三個不同的平台編寫一些低階程式碼,如果你想運行所有範例,你需要訪問這些平台。
最好的跟隨方式是在你的電腦上打開配套的程式碼庫,並導航到ch03資料夾。
本章有些特殊,因為我們從頭開始建立一些基本理解,這意味著其中一些內容相當低階,並且需要特定的作業系統和CPU系列才能運行。
別擔心;玄貓選擇了最常用和最流行的CPU,所以這應該不是問題,但這是你需要注意的事情。
機器必須在Windows和Linux上使用x86-64指令集的CPU。Intel和AMD桌面CPU使用這種架構,但如果你在ARM處理器上運行Linux(或WSL),你可能會遇到一些使用內聯組合語言(inline assembly)的範例的問題。在macOS上,書中的範例針對較新的M系列晶片,但程式碼庫也包含針對較舊的Intel基於Mac的範例。
不幸的是,一些針對特定平台的範例需要該特定的作業系統才能運行。然而,這將是唯一一個你需要訪問三個不同平台才能運行所有範例的章節。展望未來,我們將創建可以在所有平台上原生運行或使用適用於Linux的Windows子系統(WSL)運行的範例,但要理解跨平台抽象的基礎知識,我們需要實際創建針對這些不同平台的範例。
運行Linux範例
如果你沒有設置Linux機器,你可以在Rust Playground上運行Linux範例,或者如果你在Windows系統上,玄貓的建議是設置WSL並在那裡運行程式碼。你可以在wsl/install找到安裝說明。請記住,你還必須在WSL環境中安裝Rust,因此請按照本書序言部分關於如何在Linux上安裝Rust的說明進行操作。
如果你使用VS Code作為編輯器,有一種非常簡單的方法可以將你的環境切換到WSL。按下Ctrl+Shift+P並輸入Reopen folder in WSL。這樣,你可以輕鬆地在WSL中打開範例資料夾並在那裡運行Linux程式碼範例。
為何使用作業系統支援的事件佇列?
你已經知道,我們的目標是讓I/O操作盡可能高效。諸如Linux、macOS和Windows等作業系統提供了多種執行I/O的方式,包括阻塞式(blocking)和非阻塞式(non-blocking)。
I/O操作需要透過作業系統,因為它們依賴於我們的作業系統抽象出來的資源。這可以是磁碟機、網路卡或其他周邊設備。特別是在網路呼叫的情況下,我們不僅依賴於我們自己的硬體,我們還依賴於可能遠離我們自己的資源,這會導致顯著的延遲。
在上一章中,我們涵蓋了程式設計時處理異步操作的不同方式,雖然它們都不同,但它們都有一個共同點:它們需要控制何時以及是否應該在進行**系統呼叫(syscall)**時讓出給作業系統排程器。
實際上,這意味著通常會讓出給作業系統排程器(阻塞呼叫)的系統呼叫。
此圖示將展示不同作業系統的事件佇列機制,以及它們如何被跨平台抽象統一。
@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 App
component "統一 I/O 抽象層" as UnifiedIO
}
package "作業系統特定實現" {
component "Linux (epoll)" as LinuxEpoll
component "macOS (kqueue)" as MacKqueue
component "Windows (IOCP)" as WinIOCP
component "wepoll (Windows)" as Wepoll
component "io_uring (Linux)" as IoUring
}
package "底層系統交互" {
component "系統呼叫 (Syscalls)" as Syscalls
component "外部函數介面 (FFI)" as FFI
}
App --> UnifiedIO : 發起 I/O 請求
UnifiedIO --> FFI : 呼叫底層介面
FFI --> LinuxEpoll : (在 Linux 上)
FFI --> MacKqueue : (在 macOS 上)
FFI --> WinIOCP : (在 Windows 上)
FFI --> Wepoll : (在 Windows 上, 類似 epoll)
FFI --> IoUring : (在 Linux 上, 新 API)
LinuxEpoll --> Syscalls : 透過系統呼叫操作
MacKqueue --> Syscalls : 透過系統呼叫操作
WinIOCP --> Syscalls : 透過系統呼叫操作
Wepoll --> Syscalls : 透過系統呼叫操作
IoUring --> Syscalls : 透過系統呼叫操作
note right of UnifiedIO : 屏蔽 OS 差異,提供一致 API
note left of Syscalls : 作業系統核心介面
note bottom of Wepoll : 簡化 Windows 異步 I/O 抽象
note bottom of IoUring : 高效能異步 I/O 新範式
end note
end note
end note
end note
@enduml看圖說話:
此圖示展示了跨平台應用程式如何透過統一I/O抽象層與不同作業系統的底層I/O機制交互。應用程式邏輯發出I/O請求後,透過外部函數介面(FFI)將請求傳遞給特定作業系統的事件佇列實現,例如Linux上的epoll、macOS上的kqueue或Windows上的IOCP。此外,圖中也包含了更現代或專門的替代方案,如Windows上的wepoll(模擬epoll行為)和Linux上的io_uring(高效能異步I/O新範式)。這些底層機制最終都透過系統呼叫與作業系統核心進行交互。統一I/O抽象層的目標是屏蔽這些作業系統之間的差異,為開發者提供一個一致的API,從而簡化跨平台開發。
玄貓認為,為實現高效能異步I/O,必須避免阻塞式呼叫,轉而採用非阻塞式I/O,並結合作業系統支援的事件佇列機制。這不僅能最大化資源利用率,也為程式設計師提供了更精細的控制權。
深入理解作業系統支援的事件佇列、系統呼叫與跨平台抽象
需要避免阻塞式呼叫,我們需要改用非阻塞式呼叫。我們還需要一種有效的方式來了解每個呼叫的狀態,以便我們知道執行了原本會阻塞的呼叫的任務何時可以繼續進行。這就是在異步運行時中使用作業系統支援的事件佇列的主要原因。我們將以三種不同的方式處理I/O操作作為範例。
阻塞式I/O
當我們要求作業系統執行阻塞式操作時,它會暫停發出呼叫的作業系統執行緒。然後,它會儲存我們發出呼叫時的CPU狀態,並繼續執行其他事情。當資料透過網路到達時,它會再次喚醒我們的執行緒,恢復CPU狀態,讓我們繼續執行,就像什麼都沒發生一樣。
作為程式設計師,阻塞式操作是最不靈活的,因為我們在每次呼叫時都將控制權讓給作業系統。最大的優點是,一旦我們等待的事件準備就緒,我們的執行緒就會被喚醒,這樣我們就可以繼續執行。如果我們考慮在作業系統上運行的整個系統,這是一個相當有效的解決方案,因為作業系統會給有工作要做的執行緒分配CPU時間以進行進度。
然而,如果我們將範圍縮小到單獨查看我們的進程,我們會發現每次我們進行阻塞式呼叫時,我們都會讓一個執行緒休眠,即使我們的進程仍然有工作可以做。這讓我們面臨兩種選擇:要麼產生新的執行緒來執行工作,要麼就接受我們必須等待阻塞式呼叫返回。我們稍後會更詳細地討論這個問題。
非阻塞式I/O
與阻塞式I/O操作不同,作業系統不會暫停發出I/O請求的執行緒,而是給它一個句柄(handle),執行緒可以使用該句柄向作業系統詢問事件是否準備就緒。我們將查詢狀態的過程稱為輪詢(polling)。
非阻塞式I/O操作給予程式設計師更多的自由,但一如既往,這伴隨著責任。如果我們輪詢過於頻繁,例如在一個迴圈中,我們將佔用大量的CPU時間只是為了請求更新狀態,這是非常浪費的。如果我們輪詢過於不頻繁,那麼事件準備就緒和我們處理它之間將會有顯著的延遲,從而限制了我們的吞吐量。
透過epoll/kqueue和IOCP進行事件佇列
這是一種前面兩種方法的混合。在網路呼叫的情況下,呼叫本身將是非阻塞的。然而,我們不再定期輪詢句柄,而是可以將該句柄添加到事件佇列中,並且我們可以以非常低的開銷處理數千個句柄。
作為程式設計師,我們現在有了一個新的選擇。我們可以定期查詢佇列以檢查我們添加的任何事件是否已更改狀態,或者我們可以對佇列進行阻塞式呼叫,告訴作業系統我們希望在佇列中至少有一個事件更改狀態時被喚醒,以便等待該特定事件的任務可以繼續。
這允許我們只在沒有更多工作要做並且所有任務都在等待事件發生才能繼續進行時才將控制權讓給作業系統。我們可以精確地決定何時發出這樣的阻塞式呼叫。
注意
我們不會涵蓋poll和select等方法。大多數作業系統都有較舊的方法,這些方法在現代異步運行時中今天已不廣泛使用。只需知道我們可以進行其他呼叫,這些呼叫本質上旨在提供與我們剛剛討論的事件佇列相同的靈活性。
基於就緒的事件佇列
epoll和kqueue被稱為基於就緒的事件佇列(readiness-based event queues),這意味著它們會在一個動作準備好執行時通知你。一個範例是準備好讀取的套接字。
為了了解這在實踐中如何運作,我們可以看看當我們使用epoll/kqueue從套接字讀取資料時會發生什麼:
- 我們透過呼叫
epoll_create或kqueue來創建一個事件佇列。 - 我們向作業系統請求一個代表網路套接字的文件描述符。
此圖示將展示阻塞式I/O、非阻塞式I/O和基於事件佇列的I/O之間的差異。
@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
actor "程式設計師" as Programmer
rectangle "I/O 操作模式" {
component "阻塞式 I/O" as BlockingIO
component "非阻塞式 I/O (輪詢)" as NonBlockingPolling
component "基於事件佇列 I/O" as EventQueueIO
}
cloud "作業系統" as OS {
component "OS 排程器" as OSScheduler
component "核心 I/O 模組" as KernelIO
component "事件佇列 (epoll/kqueue)" as EventQueue
}
Programmer --> BlockingIO : 發起 I/O 請求
BlockingIO --> OS : 請求 I/O
OS --> BlockingIO : 阻塞執行緒直到完成
BlockingIO --> Programmer : I/O 完成
Programmer --> NonBlockingPolling : 發起 I/O 請求
NonBlockingPolling --> OS : 請求 I/O (非阻塞)
OS --> NonBlockingPolling : 返回句柄
loop 輪詢狀態
NonBlockingPolling --> OS : 查詢狀態
OS --> NonBlockingPolling : 返回狀態
end
NonBlockingPolling --> Programmer : I/O 完成
Programmer --> EventQueueIO : 發起 I/O 請求
EventQueueIO --> OS : 請求 I/O (非阻塞) + 註冊句柄到事件佇列
OS --> EventQueueIO : 返回句柄
EventQueueIO --> EventQueue : 註冊事件
EventQueue --> KernelIO : 監聽 I/O
KernelIO --> EventQueue : I/O 就緒通知
EventQueue --> EventQueueIO : 事件就緒通知 (喚醒)
EventQueueIO --> Programmer : I/O 完成
note right of BlockingIO : 執行緒暫停,CPU資源浪費
note right of NonBlockingPolling : 頻繁輪詢浪費CPU,不頻繁輪詢延遲高
note right of EventQueueIO : 高效利用CPU,僅在事件就緒時喚醒
end note
end note
end note
@enduml看圖說話:
此圖示對比了三種I/O操作模式:阻塞式I/O、非阻塞式I/O(輪詢)和基於事件佇列的I/O。在阻塞式I/O中,當程式發起I/O請求時,相關執行緒會被作業系統暫停,直到I/O操作完成,這會導致CPU資源的浪費。非阻塞式I/O則允許程式立即返回並持續輪詢I/O狀態,但頻繁輪詢會消耗大量CPU,而輪詢不夠頻繁又會導致延遲。最優化的方案是基於事件佇列的I/O,程式發起非阻塞I/O請求後,將句柄註冊到事件佇列(如epoll/kqueue)。作業系統的核心I/O模組會在事件就緒時通知事件佇列,事件佇列再喚醒相關任務。這種混合模式高效利用CPU,僅在事件真正就緒時才觸發後續處理,是現代異步運行時實現高效能I/O的關鍵。
玄貓認為,理解基於就緒和基於完成的事件佇列之間的根本差異,是構建高效能、跨平台異步系統的關鍵。這種差異不僅影響API設計,更深遠地影響了記憶體管理和程式流程的複雜性。
結論
縱觀現代高效能系統的架構演進,作業系統支援的事件佇列已是不可或缺的基石。從阻塞 I/O 的被動等待,到輪詢的主動資源虛耗,再到事件佇列的精準喚醒,這條路徑清晰地展現了對系統「控制權」與「資源效率」的深刻權衡。儘管 epoll/kqueue 的「就緒」模型與 IOCP 的「完成」模型在設計哲學上有所分歧,但其整合價值在於,它們共同為上層抽象提供了可高效管理的非同步事件源,是構建跨平台運行時的關鍵支點。
展望未來,隨著 io_uring 這類新範式崛起,我們預見作業系統將提供更整合、效能更極致的異步模型,這不僅會簡化跨平台抽象的複雜度,更可能重塑高效能伺服器的設計典範。
玄貓認為,對追求頂尖效能的架構師而言,深入理解這些底層機制的演化與取捨,已非單純的技術選型,而是構築未來系統核心競爭力的策略性投資。