在 Rust 非同步程式設計的生態系中,mio 扮演著支撐 tokio 等主流執行時的底層基石。理解其設計不僅是學習一項技術,更是窺探高效能 I/O 處理核心的途徑。本文將以 mio 為藍本,專注於其在 Linux 環境下對 epoll 的抽象實現。我們將探討 mio 作為一個跨平台函式庫,如何在統一 API 的目標下,做出權衡與設計決策,例如選擇「最小公分母」原則所帶來的影響。透過親手實現一個簡化的 epoll 事件迴圈,讀者將能深刻體會到事件註冊與輪詢分離的設計精髓,並掌握建構高效非同步應用的底層邏輯,為深入研究更複雜的非同步執行時或網路框架奠定穩固基礎。

設計與epoll介紹

本章將圍繞一個主要範例展開,你可以在程式碼庫的ch04/a-epoll中找到它。我們將從設計我們將使用的API開始。

正如玄貓在本章開頭所提到的,我們將從mio中汲取靈感。這有一個很大的優點和一個缺點。優點是我們可以輕鬆地了解mio的設計方式,如果你想了解比我們在這個範例中涵蓋的更多內容,那麼深入研究其程式碼庫會容易得多。缺點是我們在epoll之上引入了一個過於厚重的抽象層,包括一些mio特有的設計決策。

玄貓認為優點大於缺點,原因很簡單:如果你想實現一個生產品質的事件迴圈,你可能會想研究已經存在的實現,如果你想深入研究Rust中非同步程式設計的構建塊,也是如此。在Rust中,mio是支撐許多非同步生態系統的重要函式庫之一,因此對它有所熟悉是一個額外的收穫。

值得注意的是,mio是一個跨平台函式庫,它在epollkqueueIOCP(透過Wepoll,正如我們在第三章中所述)之上創建了一個抽象。不僅如此,mio還支援iOS和Android,未來很可能還會支援其他平台。因此,如果將其與你只打算支援一個平台所能實現的目標進行比較,為如此多不同的系統統一API必然會帶來一些妥協。

mio

mio將自己描述為「一個快速、低階的Rust I/O函式庫,專注於非阻塞API和事件通知,用於以盡可能少的作業系統抽象開銷構建高效能I/O應用程式」。

mio驅動著tokio中的事件佇列,tokio是Rust中最受歡迎和廣泛使用的非同步執行時之一。這意味著mio正在為流行的框架(如warpRocket)驅動I/O。

我們將在本範例中作為設計靈感的mio版本是0.8.8。API過去曾發生變化,將來也可能發生變化,但我們在這裡涵蓋的API部分自2019年以來一直很穩定,因此很可能在不久的將來不會有重大變化。

與所有跨平台抽象一樣,通常需要選擇最小公分母(least common denominator)。為了擁有一個適用於所有系統的統一API,某些選擇會限制一個或多個平台的靈活性和效率。我們將在本章中討論其中一些選擇。

在我們進一步討論之前,讓玄貓創建一個空白專案並給它一個名稱。我們將其稱為a-epoll,但你當然需要將其替換為你選擇的名稱。

進入資料夾並輸入cargo init命令。

在本範例中,我們將專案劃分為幾個模組,並將程式碼拆分為以下文件:

src
|-- ffi.rs
|-- main.rs
|-- poll.rs

它們的描述如下:

  • ffi.rs: 這個模組將包含與我們需要與主機作業系統通信的系統呼叫相關的程式碼
  • main.rs: 這是範例程式本身
  • poll.rs: 這個模組包含主要抽象,它是epoll之上的一個薄層

接下來,在src資料夾中創建上面列出的四個文件。

main.rs中,我們還需要聲明模組:

a-epoll/src/main.rs

mod ffi;
mod poll;

現在我們已經設定好專案,我們可以從設計我們將使用的API開始。主要抽象在poll.rs中,所以請打開該文件。

讓我們從定義結構體開始,當我們將它們放在面前時,討論它們會更容易:

a-epoll/src/poll.rs

use std::{io::{self, Result}, net::TcpStream, os::fd::AsRawFd};
use crate::ffi;
type Events = Vec<ffi::Event>;
pub struct Poll {
registry: Registry,
}
impl Poll {
pub fn new() -> Result<Self> {
todo!()

此圖示將展示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 "a-epoll 專案結構" {
folder "src" {
file "main.rs" as MainRs
file "ffi.rs" as FfiRs
file "poll.rs" as PollRs
}
}

package "epoll 事件佇列設計" {
component "Poll 結構體" as PollStruct
component "Registry 結構體" as RegistryStruct
component "Events 類型別名" as EventsAlias
component "epoll 系統呼叫" as EpollSyscalls
}

MainRs --> FfiRs : 使用 FFI 模組
MainRs --> PollRs : 使用 Poll 模組

PollRs --> PollStruct : 定義 Poll 結構
PollStruct --> RegistryStruct : 包含 Registry
PollRs --> EventsAlias : 定義 Events 類型
PollRs --> FfiRs : 透過 FFI 模組呼叫 epoll 相關系統呼叫

EpollSyscalls -[hidden]-> FfiRs : 實現 epoll 相關系統呼叫 (epoll_create1, epoll_ctl, epoll_wait)

note left of PollRs : 抽象層,提供高階 epoll 介面
note left of FfiRs : 封裝底層 epoll 系統呼叫

end note

end note

@enduml

看圖說話:

此圖示展示了基於epoll的事件佇列專案a-epoll的設計概念和模組劃分。專案結構清晰地分為main.rs(主程式)、ffi.rs(外部函數介面,負責與作業系統進行系統呼叫)和poll.rs(核心抽象層)。poll.rs模組定義了Poll結構體,它將包含一個Registry結構體,並定義了Events類型別名。Poll模組透過ffi.rs間接與底層的epoll系統呼叫(如epoll_create1epoll_ctlepoll_wait)進行交互。這種分層設計使得poll.rs能夠提供一個相對高階且易於使用的epoll抽象介面,同時將底層與作業系統的直接交互封裝在ffi.rs中,從而提高了程式碼的可讀性、可維護性和模組化程度。這種設計也借鑒了mio的架構思想,旨在為非同步I/O提供一個高效且可擴展的基礎。

玄貓認為,在設計事件佇列的API時,將核心的事件輪詢機制(Poll)與事件註冊機制(Registry)分離,是實現高效率和靈活性的關鍵。這種設計模式不僅便於多執行緒環境下的安全操作,也為未來擴展支援多種事件源和跨平台抽象提供了堅實的基礎。

設計與epoll介紹

pub fn registry(&self) -> &Registry {
&self.registry
}

pub fn poll(&mut self, events: &mut Events, timeout: Option<i32>) -> Result<()> {
todo!()
}
}
pub struct Registry {
raw_fd: i32,
}
impl Registry {

pub fn register(&self, source: &TcpStream, token: usize, interests: i32) -> Result<()>
{
todo!()
}
}
impl Drop for Registry {
fn drop(&mut self) {
todo!()
}
}

我們暫時將所有實作替換為todo!()。這個巨集將允許我們編譯程式,即使我們尚未實作函數體。如果我們的執行流程到達todo!(),它將會觸發恐慌(panic)

你首先會注意到,我們除了標準函式庫中的一些類型之外,還將ffi模組引入了作用域。

我們還將使用std::io::Result類型作為我們自己的Result類型。這很方便,因為大多數錯誤都將源於我們對作業系統的呼叫,並且作業系統錯誤可以映射到io::Error類型。

epoll主要有兩個抽象。一個是名為Poll的結構體,另一個是名為Registry的結構體。這些函數的名稱和功能與mio中的相同。命名這樣的抽象出奇地困難,這兩個構造體很可能會有不同的名稱,但讓玄貓依賴於前人已經花費時間並決定在我們的範例中使用這些名稱的事實。

Poll是一個代表事件佇列本身的結構體。它有幾個方法:

  • new: 創建一個新的事件佇列
  • registry: 返回一個對註冊表的引用,我們可以用它來註冊感興趣的事件,以便在有新事件時得到通知
  • poll: 阻塞呼叫它的執行緒,直到事件準備好或超時,以先發生的為準

Registry是等式的另一半。Poll代表事件佇列,而Registry是一個句柄,允許我們註冊對新事件的興趣。

Registry只有一個方法:register。同樣,我們模仿mio使用的API,並且不接受預定義的方法列表來註冊不同的興趣,我們接受一個interests參數,該參數將指示我們希望事件佇列追蹤哪種類型的事件。

還有一點需要注意的是,我們不會對所有來源使用泛型類型。我們只會為TcpStream實作此功能,儘管我們可以用事件佇列追蹤許多事物。

當我們想要使其跨平台時尤其如此,因為根據你想要支援的平台,我們可能希望追蹤許多類型的事件來源。

mio透過讓mio::Registry::register接受一個實作mio定義的Source特徵的物件來解決這個問題。只要你為來源實作此特徵,你就可以使用事件佇列來追蹤其上的事件。

在下面的偽程式碼中,你將了解我們計劃如何使用此API:

let queue = Poll::new().unwrap();
let id = 1;

// 註冊對 TcpStream 上的事件感興趣
queue.registry().register(&stream, id, ...).unwrap();
let mut events = Vec::with_capacity(1);

// 這將阻塞當前執行緒
queue.poll(&mut events, None).unwrap();

//...資料已在其中一個追蹤的串流上準備就緒

你可能會想,我們為什麼需要Registry結構體。

為了回答這個問題,我們需要記住mio抽象了epollkqueueIOCP。它透過讓kqueueepoll做相同的事情來實現這一點。

Registry實作了一個我們在範例中不會實作的重要方法,稱為try_clone。我們不實作這個方法的原因是我們不需要它來理解像這樣一個事件迴圈是如何工作的,而且我們希望保持範例簡單易懂。然而,這個方法對於理解為什麼註冊事件的責任和佇列本身是分開的很重要。

此圖示將展示PollRegistry結構體之間的關係以及它們如何共同運作。

@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

class Poll {
+ new() -> Result<Self>
+ registry() -> &Registry
+ poll(&mut self, events: &mut Events, timeout: Option<i32>) -> Result<()>
}

class Registry {
raw_fd: i32
+ register(&self, source: &TcpStream, token: usize, interests: i32) -> Result<()>
}

Poll "1" *-- "1" Registry : 包含

interface Drop {
+ drop(&mut self)
}

Registry ..|> Drop : 實作

note left of Poll::poll : 阻塞執行緒直到事件準備好或超時
note right of Registry::register : 註冊對特定事件的興趣
note right of Registry : 負責管理 epoll 實例的檔案描述符 (raw_fd)

end note

end note

end note

@enduml

看圖說話:

此圖示清晰地描繪了PollRegistry這兩個核心結構體在事件佇列設計中的協同關係。Poll結構體代表了整個事件佇列的生命週期和主要操作,例如創建新的佇列 (new)、執行事件輪詢 (poll),以及提供對Registry的訪問 (registry)。而Registry結構體則扮演著事件註冊的角色,它持有底層epoll實例的原始檔案描述符 (raw_fd),並提供了register方法來允許使用者向事件佇列註冊對特定I/O來源(例如TcpStream)的興趣。這種設計將事件的輪詢與事件的註冊職責清晰地分離開來,使得Poll可以專注於事件的等待與分發,而Registry則專注於事件源的管理。Registry還實作了Drop特徵,確保在其生命週期結束時能夠正確地清理資源,例如關閉epoll實例的檔案描述符。這種職責分離的設計模式,不僅提高了模組的內聚性,也為多執行緒環境下的安全操作和未來的擴展性奠定了基礎。

玄貓認為,在設計高併發系統時,理解事件佇列執行緒模型之間的互動至關重要。PollRegistry的分離設計,不僅解決了多執行緒環境下事件註冊的挑戰,也引導我們思考I/O操作的阻塞特性,以及作業系統如何透過快取機制優化I/O效能。這一切都指向了在非同步程式設計中,如何平衡效能、複雜度和跨平台兼容性。

深入剖析此一底層系統的設計哲學後,我們不僅看見epoll的技術實現,更洞察到一個優雅抽象的誕生過程。mio的跨平台策略,正是「最小公分母」原則在工程實踐中的深刻體現,它在擴大適用性與犧牲單一平台極致效能之間做出精準權衡。而PollRegistry的職責分離,看似增加了初步理解的複雜度,實則為多執行緒安全與未來擴展性預先鋪設了穩固的軌道,這是一種典型的、著眼於長期價值的架構思維。

這種設計的真正挑戰,在於如何將底層的系統呼叫(ffi)與高階的業務邏輯(main)無縫接軌,同時維持API的簡潔與直觀,避免過度抽象帶來的效能損耗。展望未來,隨著非同步生態系持續成熟,這類底層I/O抽象將更趨向標準化與透明化。然而,理解其內部運作與設計權衡,將成為區分資深架構師與一般開發者的關鍵護城河。

玄貓認為,精通此類從混沌中建立秩序的抽象設計能力,不僅是技術的精進,更是將複雜問題系統化、模組化解決的領導思維體現,是從應用開發者邁向更高階技術領導力的必經之路。