在開發高效能網路服務與系統級應用時,非同步 I/O 模型是不可或缺的基石。然而,不同作業系統平台提供了截然不同的底層實現。本文從系統呼叫的層級出發,深入探討 Linux 的 epoll、BSD/macOS 的 kqueue,以及 Windows 的 IOCP。這三者不僅是 API 上的差異,更代表了「就緒基礎」與「完成基礎」兩種截然不同的事件通知哲學。理解這些機制的運作原理、資料流程與阻塞模式,對於設計一個能夠在多平台間無縫運作、兼顧效能與資源管理的抽象層至關重要。本文將引導讀者穿透高階語言的封裝,直面作業系統核心,剖析在建立統一 I/O 介面時所面臨的設計抉擇與技術權衡,為打造穩健的跨平台軟體奠定理論基礎。
深入理解作業系統支援的事件佇列、系統呼叫與跨平台抽象
- 透過另一個系統呼叫,我們註冊對此套接字上讀取事件的興趣。重要的是,我們還必須通知作業系統,當在步驟1中創建的事件佇列中事件準備就緒時,我們將期望收到通知。
- 接下來,我們呼叫
epoll_wait或kevent來等待事件。這將**阻塞(暫停)**呼叫它的執行緒。 - 當事件準備就緒時,我們的執行緒被解除阻塞(恢復),我們從等待呼叫返回,並帶有關於發生的事件的資料。
- 我們在步驟2中創建的套接字上呼叫
read。
此圖示:epoll和kqueue流程簡化圖
@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 AppThread
rectangle "epoll/kqueue 流程" {
AppThread --> OS : 1. 呼叫 epoll_create/kqueue()
OS --> AppThread : 返回事件佇列描述符
AppThread --> OS : 2. 呼叫 socket()
OS --> AppThread : 返回套接字描述符
AppThread --> OS : 3. 呼叫 epoll_ctl/kevent() (註冊讀取事件興趣)
note left of OS : 通知 OS 期望事件就緒通知
AppThread --> OS : 4. 呼叫 epoll_wait/kevent() (阻塞)
OS -[hidden]-> OS : (OS 執行其他任務)
OS --> AppThread : 5. 事件就緒,執行緒解除阻塞,返回事件資料
AppThread --> OS : 6. 呼叫 read() (從套接字讀取資料)
OS --> AppThread : 返回讀取資料
}
end note
@enduml看圖說話:
此圖示簡化地展示了epoll和kqueue這兩種基於就緒的事件佇列的工作流程。首先,應用程式執行緒會向作業系統請求創建一個事件佇列和一個網路套接字。接著,執行緒會向作業系統註冊對該套接字上特定事件(例如讀取事件)的興趣,並明確表示期望在事件就緒時收到通知。完成註冊後,執行緒會呼叫epoll_wait或kevent進入阻塞狀態,將控制權交給作業系統。當註冊的事件(例如套接字可讀)真正發生時,作業系統會喚醒該執行緒,並返回事件相關資料。最後,執行緒會呼叫read()從套接字中讀取資料。這個流程的核心在於「就緒」通知,即作業系統僅在可以執行I/O操作時才通知應用程式。
基於完成的事件佇列
IOCP代表輸入/輸出完成埠(input/output completion port)。這是一種基於完成的事件佇列。這種類型的佇列會在事件完成時通知你。一個範例是資料已讀入緩衝區。
以下是這種類型事件佇列中發生的基本分解:
- 我們透過呼叫
CreateIoCompletionPort來創建一個事件佇列。 - 我們創建一個緩衝區並要求作業系統給我們一個套接字的句柄。
- 我們透過另一個系統呼叫註冊對此套接字上讀取事件的興趣,但這次我們也傳入在(步驟2)中創建的緩衝區,資料將讀入該緩衝區。
- 接下來,我們呼叫
GetQueuedCompletionStatusEx,它將阻塞直到事件完成。 - 我們的執行緒被解除阻塞,我們的緩衝區現在已填充我們感興趣的資料。
此圖示:IOCP流程簡化圖
@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 AppThread
rectangle "IOCP 流程" {
AppThread --> OS : 1. 呼叫 CreateIoCompletionPort()
OS --> AppThread : 返回完成埠句柄
AppThread --> AppThread : 2. 創建緩衝區
AppThread --> OS : 呼叫 socket()
OS --> AppThread : 返回套接字句柄
AppThread --> OS : 3. 呼叫 CreateIoCompletionPort() (註冊讀取事件興趣,並傳入緩衝區)
note left of OS : OS 將資料直接讀入緩衝區
AppThread --> OS : 4. 呼叫 GetQueuedCompletionStatusEx() (阻塞)
OS -[hidden]-> OS : (OS 執行 I/O 並填充緩衝區)
OS --> AppThread : 5. 事件完成,執行緒解除阻塞,緩衝區已填充資料
}
end note
@enduml看圖說話:
此圖示簡化地展示了IOCP(輸入/輸出完成埠)這種基於完成的事件佇列的工作流程。首先,應用程式執行緒會向作業系統請求創建一個完成埠,並創建一個用於接收資料的緩衝區。接著,執行緒會向作業系統註冊對套接字上特定事件(例如讀取事件)的興趣,並將預先準備好的緩衝區一併傳遞給作業系統。作業系統會直接將讀取的資料寫入這個緩衝區。完成註冊後,執行緒會呼叫GetQueuedCompletionStatusEx進入阻塞狀態。當I/O操作完成且資料已寫入緩衝區時,作業系統會喚醒該執行緒,此時緩衝區中已包含所需的資料。IOCP的核心在於「完成」通知,即作業系統在I/O操作完全結束並將結果放入緩衝區後才通知應用程式。
epoll、kqueue和IOCP
epoll是Linux實現事件佇列的方式。在功能方面,它與kqueue有很多共同點。在Linux上使用epoll優於其他類似方法(如select或poll)的優勢在於,epoll被設計為可以非常高效地處理大量事件。
kqueue是macOS實現事件佇列的方式(它起源於BSD),在FreeBSD和OpenBSD等作業系統中也有使用。在高層次功能方面,它在概念上與epoll相似,但在實際使用上有所不同。
IOCP是Windows處理這種類型事件佇列的方式。在Windows中,完成埠會在事件完成時通知你。現在,這聽起來可能只是一個微小的差異,但事實並非如此。當你想要編寫一個函式庫時,這一點尤其明顯,因為對兩者進行抽象意味著你必須將IOCP建模為基於就緒的,或者將epoll/kqueue建模為基於完成的。
將緩衝區借給作業系統也會帶來一些挑戰,因為在等待操作返回時,此緩衝區保持不變非常重要。
| 平台 | 事件佇列 | 類型 |
|---|---|---|
| Windows | IOCP | 基於完成 |
| Linux | epoll | 基於就緒 |
| macOS | kqueue | 基於就緒 |
跨平台事件佇列
在創建跨平台事件佇列時,你必須處理這樣一個事實:你必須創建一個統一的API,無論是在Windows(IOCP)、macOS(kqueue)還是Linux(epoll)上使用,它都是相同的。最明顯的區別是IOCP是基於完成的,而kqueue和epoll是基於就緒的。這個根本區別意味著你必須做出選擇:
- 你可以創建一個將kqueue和epoll視為基於完成的佇列的抽象,或者
- 你可以創建一個將IOCP視為基於就緒的佇列的抽象
根據玄貓的個人經驗,創建一個模仿基於完成的佇列的抽象,並在幕後處理kqueue和epoll是基於就緒的事實,比反過來要容易得多。正如玄貓之前提到的,使用wepoll是在Windows上創建基於就緒的佇列的一種方式。它將大大簡化創建此類API的過程,但我們暫時不討論它,因為它不太為人所知,也不是官方文件中的方法。值得注意的是,IOCP需要一個緩衝區來讀取資料,因為它在資料讀入該緩衝區時返回。另一方面,kqueue和epoll則不需要。它們只會在你可以將資料讀入緩衝區而不會阻塞時返回。
玄貓認為,在跨平台開發中,選擇將基於就緒的API模擬為基於完成的,或反之,是一個關鍵的設計決策。這不僅影響到API的複雜度,更深遠地影響了記憶體管理和效能。深入理解底層系統呼叫和FFI,是實現高效能、精確控制的跨平台抽象的基石。
深入理解作業系統支援的事件佇列、系統呼叫與跨平台抽象
玄貓認為,我們應讓使用者控制如何管理他們的記憶體。使用者定義緩衝區的大小、重用方式,並控制在使用IOCP時傳遞給作業系統的所有記憶體方面。
在epoll和kqueue的情況下,在這樣的API中,你可以簡單地為使用者呼叫read並填充相同的緩衝區,使其對使用者來說,API是基於完成的。
如果玄貓想要呈現一個基於就緒的API,你必須在Windows上進行I/O時,創造出有兩個獨立操作的錯覺。首先,請求在套接字上資料準備好讀取時發出通知,然後實際讀取資料。雖然這有可能做到,但你很可能會發現自己必須創建一個非常複雜的API,或者由於需要中間緩衝區來維持基於就緒的API的錯覺,而在Windows平台上接受一些效率低下的情況。
我們將把事件佇列的話題留到我們創建一個簡單的範例來精確展示它們如何運作時。在此之前,我們需要真正熟悉FFI和系統呼叫,我們將透過實作一個基本的系統呼叫來做到這一點。玄貓也將利用這個機會討論抽象層次以及我們如何創建一個在三個不同平台上運作的統一API。
系統呼叫、FFI和跨平台抽象
我們將為三種架構:BSD/macOS、Linux和Windows實作一個非常基本的系統呼叫。我們還將看到這如何在三個抽象層次中實作。
我們將實作的系統呼叫是當我們向**標準輸出(stdout)**寫入內容時使用的系統呼叫,因為這是一個非常常見的操作,並且了解它實際如何運作是很有趣的。
我們將從實作一個「原始(raw)」系統呼叫開始。原始系統呼叫是指繞過作業系統提供的用於進行系統呼叫的函式庫,而是依賴於作業系統具有**穩定系統呼叫ABI(Application Binary Interface)**的系統呼叫。穩定的系統呼叫ABI意味著它保證如果你將正確的資料放入某些暫存器並呼叫一個將控制權傳遞給作業系統的特定CPU指令,它將始終執行相同的操作。
為了進行原始系統呼叫,我們需要編寫一些內聯組合語言(inline assembly),但請不要擔心。儘管我們在這裡突然引入它,但我們將逐行解釋它,並且在第五章中,我們將更詳細地介紹內聯組合語言,以便你熟悉它。
在這個抽象層次上,我們需要為BSD/macOS、Linux和Windows編寫不同的程式碼。如果作業系統在不同的CPU架構上運行,我們還需要編寫不同的程式碼。
Linux上的原始系統呼叫
在Linux和macOS上,我們要調用的系統呼叫稱為write。這兩個系統都基於**文件描述符(file descriptors)**的概念運行,並且當你啟動一個進程時,stdout已經存在。
如果你沒有運行Linux的機器,你有幾個選項可以運行這個範例。你可以將程式碼複製並貼上到Rust Playground中,或者你可以使用Windows中的WSL運行它。
正如引言中所述,玄貓將在每個範例的開頭列出你需要前往的範例,你可以在那裡透過運行main.rs來運行範例。
我們做的第一件事是引入標準函式庫模組,該模組使我們能夠訪問asm!宏。
程式碼庫參考:ch03/a-raw-syscall
use std::arch::asm;
下一步是編寫我們的系統呼叫函數:
#[inline(never)]
fn syscall(message: String) {
let msg_ptr = message.as_ptr();
let len = message.len();
unsafe {
asm!(
"mov rax, 1",
"mov rdi, 1",
"syscall",
in("rsi") msg_ptr,
in("rdx") len,
out("rax") _,
out("rdi") _,
lateout("rsi") _,
lateout("rdx") _
);
}
}
我們將逐行解釋這第一個程式碼,所以我們只需要詳細解釋這一次。
首先,我們有一個名為#[inline(never)]的屬性,它告訴編譯器我們永遠不希望。
此圖示將展示系統呼叫、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 "高階程式語言 (Rust)" as RustApp
component "標準函式庫 (std::io)" as StdLib
}
package "抽象層" {
component "跨平台 I/O 抽象" as CrossPlatformIO
component "外部函數介面 (FFI)" as FFI
}
package "作業系統特定層" {
component "作業系統提供的函式庫" as OSLib
component "原始系統呼叫 (Raw Syscall)" as RawSyscall
}
package "硬體層" {
component "CPU 指令集" as CPU
component "暫存器" as Registers
}
RustApp --> StdLib : 使用高階 I/O 函數
StdLib --> CrossPlatformIO : 透過抽象層調用
CrossPlatformIO --> FFI : 處理跨語言/平台調用
FFI --> OSLib : 調用 OS 提供的函式庫
OSLib --> RawSyscall : 最終執行系統呼叫
RustApp --> RawSyscall : (直接原始系統呼叫)
RawSyscall --> Registers : 設置暫存器參數
Registers --> CPU : 執行系統呼叫指令 (如 `syscall`)
CPU --> OSLib : 控制權轉移給 OS 核心
note right of RawSyscall : 繞過 OS 函式庫,直接與核心交互
note left of FFI : 處理不同語言的呼叫約定
note right of CrossPlatformIO : 統一不同 OS 的 I/O 介面
end note
end note
end note
@enduml看圖說話:
此圖示闡明了系統呼叫、FFI和跨平台抽象在軟體堆疊中的層次結構。從頂層的高階程式語言(如Rust),應用程式可以透過標準函式庫或跨平台I/O抽象來執行I/O操作。這些抽象層會利用外部函數介面(FFI)來橋接不同語言或平台之間的呼叫約定,最終調用作業系統提供的函式庫。而這些函式庫最終會執行底層的原始系統呼叫。圖中特別強調了原始系統呼叫,它繞過了作業系統提供的函式庫,直接透過設置CPU暫存器並執行特定的CPU指令(如syscall)與作業系統核心交互。這種直接操作雖然複雜,但能提供最大的效能和控制權,對於理解跨平台抽象的底層機制至關重要。
玄貓認為,直接進行原始系統呼叫雖然能提供底層控制與潛在效能優勢,但其嚴重的跨平台相容性問題,以及對作業系統內部實現細節的依賴,使得它在實際應用中極具挑戰性,尤其是在需要長期維護和跨平台部署的專案中。
深入剖析epoll、kqueue與IOCP這三大事件佇列的核心機制後,我們不僅是在理解系統底層的運作差異,更是在權衡軟體架構中的一項關鍵取捨:在「就緒」與「完成」兩種模型間的抽象策略。將基於就緒的epoll/kqueue模擬為完成模型,誠然在工程上較為直觀,但這項決策本身就隱含了對效能、複雜度與記憶體管理權責的深刻洞察。
進一步探討原始系統呼叫,則將此一權衡推向極致。它揭示了抽象層的價值——以可控的效能損耗換取開發效率與跨平台的可維護性;同時也標示出技術領導者必須面對的風險邊界,即過度深入底層可能導致的脆弱性與平台鎖定。未來的趨勢並非單純追求極致底層或高階抽象,而是兩者的精準融合。能夠根據應用場景、團隊能力與長期維護成本,動態選擇最適抽象層次的團隊,將構建起難以超越的技術護城河。
玄貓認為,真正的技術領導力,不僅展現在對單一系統的精通,更體現在設計跨平台抽象時的權衡智慧與架構遠見。精準掌握從原始系統呼叫到高階API之間的光譜,正是資深開發者晉升為架構師的關鍵修養。