在建構高併發網路應用時,理解底層 I/O 模型的運作至關重要。mio 函式庫的設計精髓在於其對 epoll 機制的巧妙封裝,透過將事件輪詢(Poll)與事件註冊(Registry)的職責分離,有效解決了多執行緒環境下的資源競爭問題。此架構允許單一執行緒專注於阻塞式的事件等待,而其他工作執行緒能非同步地提交新的監聽需求。然而,非同步 I/O 的複雜性不僅止於此。作業系統的快取策略使得檔案或網路 I/O 的阻塞行為變得難以預測,這也促使開發者必須考慮將潛在的長時間操作卸載至獨立的執行緒池,以避免主事件迴圈被拖累。為了徹底掌握這些高階抽象背後的原理,我們必須深入底層,檢視如何透過外部函數介面(FFI)直接與 epoll 系統呼叫進行互動,從而揭示其事件驅動模型的核心機制。
設計與epoll介紹
重要說明
玄貓指出,使用者可以透過呼叫Registry::try_clone來獲取一個擁有的Registry實例。這個實例可以傳遞給其他執行緒,或者透過Arc<Registry>與其他執行緒共享,允許多個執行緒向同一個Poll實例註冊興趣,即使Poll正在阻塞另一個執行緒,等待Poll::poll中發生新事件。
Poll::poll需要獨佔訪問權,因為它接受一個&mut self。因此,當我們在Poll::poll中等待事件時,如果我們依賴使用Poll來註冊興趣,則無法從不同的執行緒同時註冊興趣,因為這將被Rust的類型系統阻止。
這也使得多個執行緒在同一個實例上以任何有意義的方式等待Poll::poll中的事件實際上是不可能的,因為這將需要同步,而這本質上會使每次呼叫都變成順序執行。
這種設計允許使用者透過Registry從潛在的多個執行緒與佇列互動,而一個執行緒則進行阻塞呼叫並處理來自作業系統的通知。
注意
mio不允許你在同一個Poll::poll呼叫上阻塞多個執行緒,這並不是epoll、kqueue或IOCP的限制。它們都允許許多執行緒在同一個實例上呼叫Poll::poll並接收佇列中事件的通知。epoll甚至允許特定的旗標來指示作業系統應該喚醒一個還是所有等待通知的執行緒(特別是EPOLLEXCLUSIVE旗標)。
問題部分在於當許多執行緒在同一個佇列上等待事件時,不同平台如何決定喚醒哪些執行緒,部分在於對該功能似乎沒有太大的興趣。例如,epoll預設會喚醒所有阻塞在Poll上的執行緒,而Windows預設只會喚醒一個執行緒。
你可以在一定程度上修改這種行為,並且未來也有在Poll上實作try_clone方法的想法。目前,設計如我們所概述,我們在範例中也將堅持這一點。
這引導我們在開始實作範例之前應該涵蓋的另一個主題。
所有I/O都是阻塞的嗎?
最後,一個容易回答的問題。答案是一個響亮的大聲的…也許。問題是並非所有I/O操作都會阻塞,即作業系統會暫停呼叫執行緒,並且切換到另一個任務會更有效率。這樣做的原因是作業系統很聰明,會在記憶體中快取大量資訊。如果資訊在快取中,請求該資訊的系統呼叫將立即返回資料,因此強制上下文切換或任何重新排程當前任務可能不如同步處理資料有效率。
問題在於無法確定I/O是否會阻塞,這取決於你正在做什麼。
玄貓給你兩個範例。
DNS查詢
當創建TCP連接時,首先發生的事情之一是你需要將域名轉換為IP位址。作業系統維護著本地位址和它以前在快取中查詢過的位址的映射,並且能夠幾乎立即解析它們。然而,第一次查詢未知位址時,它可能需要呼叫DNS伺服器,這需要很長時間,如果沒有以非阻塞方式處理,作業系統將在等待回應時暫停呼叫執行緒。
文件I/O
本地文件系統上的文件是作業系統執行相當多快取的另一個領域。頻繁讀取的小文件通常會快取在記憶體中,因此請求該文件可能根本不會阻塞。如果你有一個提供靜態文件的Web伺服器,那麼你很可能會提供一組相當有限的小文件。這些文件很可能已經快取在記憶體中。然而,
此圖示將展示Poll和Registry在多執行緒環境下的互動模式,以及try_clone的潛在作用。
@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 "執行緒 A (主 I/O 執行緒)" as ThreadA
actor "執行緒 B (工作執行緒)" as ThreadB
actor "執行緒 C (工作執行緒)" as ThreadC
component "Poll 實例" as PollInstance
component "Registry 實例" as RegistryInstance
ThreadA --> PollInstance : 呼叫 poll(&mut self, ...) (阻塞)
PollInstance --> RegistryInstance : 內部引用
ThreadB --> RegistryInstance : 呼叫 register(...)
ThreadC --> RegistryInstance : 呼叫 register(...)
RegistryInstance <-- ThreadB : 透過 try_clone() 取得共享句柄 (Arc<Registry>)
RegistryInstance <-- ThreadC : 透過 try_clone() 取得共享句柄 (Arc<Registry>)
note left of PollInstance : poll() 需要 &mut self,一次只能一個執行緒阻塞
note right of RegistryInstance : 註冊操作可由多個執行緒同時進行
note bottom of RegistryInstance : 原始 mio 透過 try_clone 實現多執行緒註冊
end note
end note
end note
@enduml看圖說話:
此圖示展示了在多執行緒環境下,Poll與Registry實例之間的互動模式,特別是Registry::try_clone方法在實現多執行緒事件註冊中的作用。執行緒A作為主I/O執行緒,負責呼叫Poll實例的poll方法,這個操作是阻塞的,並且由於poll方法需要&mut self,確保了在任何給定時間只有一個執行緒可以執行此操作。與此同時,執行緒B和執行緒C作為工作執行緒,可以透過Registry::try_clone()獲取Registry實例的共享句柄(通常透過Arc<Registry>實現),然後安全地呼叫Registry實例的register方法來註冊對新事件的興趣。這種設計將事件的輪詢(由Poll負責)與事件的註冊(由Registry負責)職責分離,使得多個執行緒可以同時向事件佇列添加監聽器,而不會干擾主I/O執行緒的阻塞輪詢操作,從而實現了高效且安全的併發模型。
玄貓認為,即使在非同步I/O的語境下,理解I/O操作的阻塞特性仍然至關重要。作業系統的快取機制會使某些I/O操作表現為非阻塞,但其行為的不確定性,以及面對大量數據或記憶體壓力時的效能退化,促使我們在設計高併發系統時,考慮將潛在的阻塞I/O操作卸載到執行緒池中,以避免阻塞主事件迴圈。這也突顯了FFI模組在與作業系統底層機制交互中的關鍵作用。
設計與epoll介紹
如果作業系統記憶體不足,它可能需要將記憶體頁面映射到硬碟,這會使通常非常快的記憶體查找變得極其緩慢。如果存在大量隨機訪問的小文件,或者你提供非常大的文件,情況也是如此,因為作業系統只會快取有限的資訊。如果你在同一個作業系統上運行許多不相關的進程,你也將遇到這種不可預測性,因為它可能不會快取對你重要的資訊。
處理這些情況的一種流行方法是忘記非阻塞I/O,而是實際進行阻塞呼叫。你不想在運行Poll實例的同一個執行緒中進行這些呼叫(因為每個小的延遲都會阻塞所有任務),但你可能會將該任務委託給執行緒池。在執行緒池中,你有數量有限的執行緒,它們負責進行常規的阻塞呼叫,用於DNS查找或文件I/O等。
libuv是Node.js所基於的非同步I/O函式庫。雖然其範圍比mio(只關心非阻塞I/O)更大,但libuv對Node.js在JavaScript中的作用,就像mio對tokio在Rust中的作用一樣。
注意
在執行緒池中進行文件I/O的原因是,歷史上非阻塞文件I/O的跨平台API一直很差。雖然許多執行時選擇將此任務委託給執行緒池,進行對作業系統的阻塞呼叫,但隨著作業系統API的發展,未來可能不再如此。
創建一個執行緒池來處理這些情況超出了本範例的範圍(即使mio也認為這超出了其範圍,只是為了澄清)。我們將專注於展示epoll如何運作,並在文本中提及這些主題,儘管我們不會在本範例中實際實作它們的解決方案。
現在我們已經涵蓋了許多關於epoll、mio和我們的範例設計的基本資訊,是時候編寫一些程式碼,親自看看這一切在實踐中是如何運作的了。
ffi模組
讓玄貓從不依賴於任何其他模組的模組開始,然後從那裡開始。ffi模組包含我們需要與作業系統通信的系統呼叫和資料結構的映射。一旦我們介紹了系統呼叫,我們還將詳細解釋epoll如何運作。
它只有幾行程式碼,所以我將第一部分放在這裡,以便更容易追蹤我們在文件中的位置,因為有很多東西需要解釋。打開ffi.rs文件並編寫以下程式碼:
ch04/a-epoll/src/ffi.rs
pub const EPOLL_CTL_ADD: i32 = 1;
pub const EPOLLIN: i32 = 0x1;
pub const EPOLLET: i32 = 1 << 31;
#[link(name = "c")]
extern "C" {
pub fn epoll_create(size: i32) -> i32;
pub fn close(fd: i32) -> i32;
pub fn epoll_ctl(epfd: i32, op: i32, fd: i32, event: *mut Event) -> i32;
pub fn epoll_wait(epfd: i32, events: *mut Event, maxevents: i32, timeout: i32) -> i32;
}
你首先會注意到我們聲明了幾個常數,名為EPOLL_CTL_ADD、EPOLLIN和EPOLLET。
玄貓稍後會解釋這些常數是什麼。讓玄貓首先看看我們需要進行的系統呼叫。幸運的是,我們已經詳細介紹了系統呼叫,所以你已經了解了ffi的基礎知識以及為什麼我們在上面的程式碼中連結到C:
epoll_create是我們用來創建epoll佇列的系統呼叫。這個方法接受一個名為size的參數,但size僅僅是出於歷史原因。該參數將被忽略,但必須大於0。close是我們在創建epoll實例時需要關閉檔案描述符的系統呼叫,以便我們正確釋放資源。epoll_ctl是我們用於對epoll實例執行操作的控制介面。這是我們用於註冊對來源事件感興趣的呼叫。它支援三個主要操作:
此圖示將展示ffi模組中epoll相關系統呼叫的結構與其在事件處理中的角色。
@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 "FFI 模組 (ffi.rs)" {
rectangle "epoll 常數" as EpollConsts {
+ EPOLL_CTL_ADD: i32
+ EPOLLIN: i32
+ EPOLLET: i32
}
rectangle "外部 C 函數聲明" as CFunctions {
+ epoll_create(size: i32) -> i32
+ close(fd: i32) -> i32
+ epoll_ctl(epfd: i32, op: i32, fd: i32, event: *mut Event) -> i32
+ epoll_wait(epfd: i32, events: *mut Event, maxevents: i32, timeout: i32) -> i32
}
rectangle "Event 結構體 (待定義)" as EventStruct
}
CFunctions --> EpollConsts : 使用常數作為參數
CFunctions --> EventStruct : 使用 Event 結構體指標
note left of CFunctions : 透過 FFI 橋接 Rust 與 C 語言的 epoll 介面
note right of EpollConsts : 定義 epoll 操作類型與事件類型
end note
end note
@enduml看圖說話:
此圖示展示了ffi模組的核心組成部分,它作為Rust程式與底層作業系統epoll機制交互的橋樑。模組內部定義了幾個關鍵的epoll常數,例如EPOLL_CTL_ADD用於添加監聽事件、EPOLLIN表示輸入事件以及EPOLLET表示邊緣觸發模式。這些常數在呼叫epoll相關的C函數時作為參數使用。接著,透過#[link(name = "c")] extern "C"區塊,聲明了四個主要的C語言epoll系統呼叫:epoll_create用於創建epoll實例、close用於關閉檔案描述符、epoll_ctl用於控制epoll實例(如添加、修改或刪除監聽事件),以及epoll_wait用於等待事件發生。這些外部函數的聲明,結合Event結構體(儘管在此圖中標示為待定義),共同構成了ffi模組,使得Rust程式能夠直接、高效地利用Linux核心提供的epoll機制來實現非阻塞I/O和事件驅動的程式設計。
玄貓認為,epoll機制的核心在於其高效的事件通知模型,而epoll_ctl和epoll_wait這兩個系統呼叫則是其運作的基石。特別是Event結構體,它巧妙地結合了位元遮罩來指示事件類型和行為模式,並透過epoll_data欄位實現了事件與自定義數據的綁定,這對於在多個事件源中精確識別和處理特定事件至關重要。理解這些底層細節,是構建高效非同步I/O系統的關鍵。
ffi模組
**新增、修改或刪除。**第一個參數epfd是我們想要執行操作的epoll檔案描述符。第二個參數op是我們指定是執行新增、修改還是刪除操作的參數。
在我們的案例中,我們只對新增事件感興趣,因此我們只傳入
EPOLL_CTL_ADD,這是指示我們想要執行新增操作的值。epoll_event稍微複雜一些,所以我們將更詳細地討論它。它為我們做了兩件重要的事情:首先,events欄位指示我們想要被通知的事件類型,它還可以修改我們被通知的方式和時間的行為。其次,data欄位向核心傳遞一塊資料,當事件發生時,核心會將其返回給我們。後者很重要,因為我們需要這些資料來精確識別發生了什麼事件,因為這是我們收到的唯一可以識別我們從哪個來源收到通知的資訊。epoll_wait是將阻塞當前執行緒並等待直到兩件事之一發生:我們收到事件已發生的通知,或者它超時。epfd是epoll檔案描述符,用於識別我們用epoll_create創建的佇列。events是與我們在epoll_ctl中使用的相同Event結構體的陣列。不同之處在於,events欄位現在向我們提供了關於發生了什麼事件的資訊,重要的是data欄位包含我們在註冊興趣時傳入的相同資料。例如,
data欄位讓我們可以識別哪個檔案描述符有準備好讀取的資料。maxevents參數告訴核心我們在陣列中為多少個事件預留了空間。最後,timeout參數告訴核心我們將等待事件多長時間,然後它會再次喚醒我們,這樣我們就不會無限期地阻塞。
ffi模組中的Event結構體
此文件中的程式碼的最後一部分是Event結構體:
ch04/a-epoll/src/ffi.rs
#[derive(Debug)]
#[repr(C, packed)]
pub struct Event {
pub(crate) events: u32,
// Token to identify event
pub(crate) epoll_data: usize,
}
impl Event {
pub fn token(&self) -> usize {
self.epoll_data
}
}
這個結構體在epoll_ctl中用於與作業系統通信,作業系統在epoll_wait中也使用相同的結構體與我們通信。
events被定義為u32,但它不僅僅是一個數字。這個欄位就是我們所說的位元遮罩(bitmask)。玄貓將在稍後的章節中花時間解釋位元遮罩,因為它在大多數系統呼叫中很常見,並非每個人都曾遇到過。簡單來說,它是一種利用位元表示作為一組是/否旗標的方式,以指示是否選擇了某個選項。
不同的選項在玄貓為epoll_ctl系統呼叫提供的連結中進行了描述。玄貓不會在這裡詳細解釋所有選項,只涵蓋我們將使用的選項:
EPOLLIN代表一個位元旗標,表示我們對檔案句柄上的讀取操作感興趣。EPOLLET代表一個位元旗標,表示我們對epoll設定為**邊緣觸發模式(edge-triggered mode)**的事件通知感興趣。
玄貓稍後會回到解釋位元旗標、位元遮罩以及邊緣觸發模式的真正含義,但讓玄貓先完成程式碼。
Event結構體上的最後一個欄位是epoll_data。這個欄位在文件中被定義為一個聯合體(union)。聯合體很像列舉,但與Rust的列舉不同,它不帶有關於它是什麼類型的信息,因此我們需要確保我們知道它持有什麼類型的資料。
我們使用這個欄位簡單地保存一個usize,這樣我們就可以在透過epoll_ctl註冊興趣時傳入一個識別每個事件的整數。傳入一個指標也是完全可以的——只要我們確保當它在epoll_wait中返回給我們時,該指標仍然有效。
我們可以將這個欄位視為一個令牌(token),這正是mio所做的,為了使API盡可能相似,我們複製mio並在結構體上提供一個token方法來獲取這個值。
此圖示將展示Event結構體的內部構成及其在epoll系統呼叫中的數據傳輸角色。
@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
struct Event {
+ events: u32 <<bitmask>>
+ epoll_data: usize <<token>>
--
+ token() -> usize
}
package "epoll 系統呼叫" {
component "epoll_ctl" as EpollCtl
component "epoll_wait" as EpollWait
}
Event --> EpollCtl : 作為輸入參數 (註冊事件)
Event <-- EpollWait : 作為輸出參數 (接收事件)
note right of Event::events : 位元遮罩,指示事件類型 (e.g., EPOLLIN, EPOLLET)
note right of Event::epoll_data : 用於識別事件源的用戶自定義數據 (令牌)
EpollCtl -[hidden]-> EpollWait : 核心內部處理
end note
end note
@enduml看圖說話:
此圖示詳細描繪了Event結構體的內部設計及其在epoll機制中的關鍵作用。Event結構體包含兩個主要欄位:events(一個u32型別的位元遮罩)和epoll_data(一個usize型別的令牌)。events欄位透過設定不同的位元旗標(如EPOLLIN表示讀取事件,EPOLLET表示邊緣觸發模式)來指示程式對哪種類型的事件感興趣,以及事件通知的行為模式。而epoll_data欄位則是一個用戶自定義的數據,通常用作一個令牌,以便在接收到事件通知時,能夠唯一地識別是哪個I/O來源觸發了該事件。Event結構體作為epoll_ctl系統呼叫的輸入參數,用於向核心註冊事件興趣;同時,它也是epoll_wait系統呼叫的輸出參數,核心透過它將發生的事件及其相關的令牌傳遞回應用程式。這種設計使得epoll能夠高效地處理大量併發I/O事件,並允許應用程式靈活地管理和識別事件來源。
玄貓認為,深入理解底層系統呼叫的細節,如#[repr(packed)]的作用,以及位元遮罩和邊緣觸發模式的原理,是構建高效能、精確控制的非同步I/O系統不可或缺的知識。這些概念不僅揭示了作業系統如何優化數據存取和事件通知,也為我們在面對複雜併發場景時,提供了更精準的工具和思維模式。
縱觀現代管理者的多元挑戰,深入理解如 epoll 這類底層技術的運作原理,其價值遠超過單純的程式設計。這不僅是技術能力的精進,更是一種從第一性原理出發,洞察複雜系統設計權衡的修養。本文從 mio 的 Poll 與 Registry 分離設計,到 ffi 模組的實作細節,揭示了高效能非同步 I/O 的核心架構。
分析此設計可以發現,mio 的抽象化巧妙地將 epoll 的原始介面轉化為符合 Rust 安全並行模型的 API,解決了多執行緒註冊的難題。然而,文章也務實地指出,即便在非同步框架下,作業系統快取機制仍會導致 I/O 行為的不可預測性,這也解釋了為何將潛在阻塞操作卸載至執行緒池,至今仍是維持系統韌性的關鍵策略。Event 結構體中「位元遮罩」與「令牌」的應用,正是將抽象概念落地為高效實踐的精髓,體現了工程師在資源限制下追求極致效能的智慧。
展望未來,隨著 io_uring 等新興作業系統 API 的成熟,非同步 I/O 的實踐典範勢必持續演進。屆時,僅僅熟悉上層框架的開發者將面臨知識折舊的風險,而真正掌握底層機制的人,才能快速適應並引領下一波架構變革。
玄貓認為,這種深入底層、從第一性原理出發的系統構建思維,不僅是技術卓越的基石,更是高階管理者在評估技術債、制定架構策略與領導創新時,不可或缺的決策能力。它代表了一種超越工具本身、直指問題本質的專業洞察力。