在網際網路初期,伺服器通常採用同步阻塞 I/O(Blocking I/O) 結合「一個連線對應一個執行緒(Thread-per-Connection)」的模型(如早期 Apache HTTP Server)。
當連線數達到數千時,高達數千個作業系統執行緒帶來的執行緒堆疊記憶體消耗與劇烈的 CPU 核心上下文切換(Context Switch) 會直接讓伺服器瀕臨崩潰——這就是著名的 C10K 難題。
為了解決以單一或少量執行緒同時高效監聽數萬乃至數百萬個 Socket 連線的難題,Linux 核心演進出了 I/O 多路復用(I/O Multiplexing) 技術:從 select、poll 到顛覆性的 epoll。
本文將完整剖析這三代技術的底層資料結構、核心態與使用者態複製開銷、以及 Nginx 和 Netty 依賴的 Reactor 事件驅動模式。
1. 三代 I/O 多路復用架構全景對決
| 評估維度 | select (1983) | poll (1997) | epoll (2002) |
|---|---|---|---|
| 底層核心資料結構 | 固定大小位元遮罩 Array | 動態陣列 / 鏈表 | 紅黑樹 + 就緒雙向鏈表 |
| 最大監聽連線數限制 | 預設 1024 (FD_SETSIZE) | 無上限 (受記憶體限制) | 無上限 (受系統 FD 限制) |
| FD 從使用者態複製到核心 | 每次呼叫全量重複拷貝 | 每次呼叫全量重複拷貝 | 僅 epoll_ctl 註冊一次 |
| 核心就緒檢索複雜度 | O(N) 全量線性輪詢 | O(N) 全量線性輪詢 | O(1) 回調通知立即返回 |
| 事件觸發模式 | 僅支援水平觸發 (LT) | 僅支援水平觸發 (LT) | 支援 LT 與邊緣觸發 (ET) |
| 適用高併發規模 | 連線數極少 (< 100) | 連線數較少 (< 1000) | 百萬級長連線 (C1000K) |
2. 第一代 select 與第二代 poll 的瓶頸
2.1 select 的兩大致命缺陷
select 系統呼叫使用 fd_set 位元向量傳遞欲監聽的檔案描述符:
- 1024 限制:核心標頭檔宏定義
FD_SETSIZE固定為 1024,無法直接突破。 - 全量拷貝與
O(N)線性輪詢:- 應用程式每次呼叫
select,都必須將包含所有監聽 FD 的陣列從使用者態完整複製到核心態。 - 核心不知道「究竟哪一個 Socket 收到資料」,只能以
O(N)遍歷所有 1024 個 FD。 - 返回後,使用者端應用程式又必須再次以
O(N)遍歷檢查FD_ISSET,造成嚴重的 CPU 空轉。
- 應用程式每次呼叫
2.2 poll 的微幅改進
poll 採用 pollfd 結構體陣列取代位元遮罩,突破了 1024 的數量限制。然而,其每次呼叫的全量核心態拷貝與核心態/使用者態雙重 O(N) 線性輪詢問題依然完全未獲解決。當監聽 10 萬個連線且只有 10 個有活動時,系統 99.99% 的時間都在做無效掃描。
3. 革命性突破:epoll 核心架構解析
Linux 2.6 核心引入了 epoll,徹底將「註冊監聽」與「等待事件就緒」在核心層面解耦。
3.1 epoll 的核心三部曲 API
int epoll_create(int size):在核心中建立一個eventpoll物件。int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event):向核心註冊、修改或刪除需要監聽的 Socket。int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout):等待就緒事件,僅返回真正有 I/O 事件發生的 Socket 列表!
3.2 核心內部雙核心資料結構
- 零無效輪詢:
epoll_wait永遠只返回就緒列表中的項目,時間複雜度恆定為O(就緒連線數),完全與總連線數 N 無關! - 避免重複拷貝:透過
epoll_ctl註冊的 Socket 長期保留在核心紅黑樹中,呼叫epoll_wait時無需重複傳遞監聽清單。
4. 水平觸發(LT)vs. 邊緣觸發(ET)
epoll 支援兩種事件通知模式:
4.1 水平觸發(Level-Triggered, LT - 預設模式)
- 行為:只要 Socket 的接收緩衝區(Receive Buffer)中還有未讀取的資料,每次呼叫
epoll_wait都會持續通知該事件。 - 優缺點:程式編寫簡單安全,不易遺漏資料;但可能因重複通知帶來微量額外開銷。
4.2 邊緣觸發(Edge-Triggered, ET - 高效能極限)
- 行為:僅在 Socket 狀態由「未就緒」變為「就緒」的瞬間(狀態跳變) 通知一次!若應用程式未將緩衝區資料一次性讀完,核心不會再發送第二次通知。
- 編程要求:
- Socket 必須設定為非阻塞模式(
O_NONBLOCK)。 - 應用程式必須使用
while迴圈持續呼叫read(),直到回傳EAGAIN或EWOULDBLOCK為止,否則會造成資料永久丟失!
- Socket 必須設定為非阻塞模式(
- 代表實踐:Nginx 與 Netty 預設採用 ET 模式以追求極限吞吐量。
5. 現代網路伺服器架構:Reactor 模式
基於 epoll,現代高效能架構普遍採用 Reactor 事件驅動模式:
- 極致利用 CPU:I/O 執行緒專注於非阻塞資料搬移,計算密集型業務由工作執行緒池處理,徹底發揮多核心硬體的極限算力。
