技術筆記 Epoll
技術筆記

Epoll 高效能 I/O

事件驅動I/O 多工C10KET / LTLinux Kernel

一個 Thread 要同時看顧上萬條連線,難處不在「怎麼讀資料」,而在「怎麼知道哪一條有資料」。select 與 poll 的答案是每次都問過所有人;epoll 的答案是讓有事的那條自己舉手——這一個反轉,就是 O(N) 變成 O(1) 的全部原因。

傳統的 select() 和 poll() 雖然讓單一 Thread 能監聽多個 Socket,但連線數量達到數萬時,O(N) 線性掃描讓 CPU 使用率暴增——即使 99% 的 Socket 都沒資料,核心仍然逐一檢查每一個。

  • Epoll(Event Poll)是 Linux 2.6+ 核心提供的 I/O 事件通知機制,採用事件驅動而非輪詢,等待複雜度降到 O(1)。
  • 核心在硬體中斷發生時,透過 Callback 主動把「有事件的 Socket」加入就緒鏈表;應用程式只需要等這條鏈表,不必掃描全部連線。
  • Nginx、Redis、Node.js(libuv)、Java NIO/Netty(Linux 底層)全部拿 epoll 當核心 I/O 模型。
Epoll 解決的是 C10K 問題(同時處理 10,000 個連線)。現代 Nginx 能輕鬆做到 C100K,epoll 是關鍵。
epoll_create()O(1)
在核心中建立 epoll 物件,內含一棵紅黑樹(RB-Tree)管理所有被監聽的 Socket,以及一條就緒鏈表(Ready List)存放有事件的 Socket。
epoll_ctl()O(log N)
把 Socket 加入紅黑樹(ADD)或更新、移除。這一步是關鍵:核心會為該 Socket 掛上回呼函數(Callback),硬體中斷時自動把它推進就緒鏈表。
epoll_wait()O(就緒數量)
阻塞到就緒鏈表非空才返回,返回的只有真正有事件的 Socket。複雜度與總連線數無關——這正是 epoll 與 select/poll 拉開差距的地方。
使用流程:create → ctl(ADD) 逐一註冊 Socket → wait 阻塞等待 → 處理就緒清單 → 回到 wait。

Epoll 支援兩種事件觸發模式,這是面試與實作中最常踩的細節:

Edge Triggered(ET,邊緣觸發)
  • 只在狀態從無到有時通知一次(上升沿)
  • 一次沒讀完不會再通知,資料可能殘留
  • 必須用 Non-blocking I/O + 迴圈讀到 EAGAIN
  • 效率更高,Nginx 預設使用
  • 設計難度較高,容易寫出 Bug
Level Triggered(LT,水平觸發)
  • 只要 Buffer 中還有資料,每次 epoll_wait 都通知
  • 行為類似 select/poll,相容性高
  • 可以用 blocking I/O
  • Epoll 的預設模式
  • 寫起來簡單,不容易出錯
ET 模式最常見的 Bug:收到通知後只讀一次,剩下的資料讓事件永遠丟失。必須迴圈讀取直到 errno == EAGAIN。
實務建議:高效能伺服器選 ET(Nginx 就是);一般業務系統優先選 LT,用一點效率換掉一整類 Bug。
  • 紅黑樹(RB-Tree):存放所有被 epoll_ctl ADD 進來的 Socket fd,增刪查都是 O(log N)。加入的同時,核心在該 Socket 的等待佇列掛上一個 callback。
  • 就緒鏈表(Ready List):網路封包抵達時,驅動程式處理硬體中斷,觸發對應 Socket 的 callback,把它插進就緒鏈表。
  • epoll_wait:只需要把就緒鏈表裡的 Socket 複製到用戶空間,複雜度 O(就緒數量),完全與總連線數無關。
關鍵洞察:Epoll 的高效來自「核心主動推送」而不是「應用程式輪詢」。中斷驅動 vs 輪詢驅動,這是本質差異,不是常數項的優化。

Select 與 Epoll 的掃描差異

琥珀色格子代表 CPU 正在檢查這個 Socket。Select 必須掃過全部 100 個;Epoll 由核心 Callback 推送,直接跳到有事件的那 5 個。

100 個 Socket 連線模擬
5 個連線收到封包,觀察 Select 與 Epoll 的掃描差異
速度
閒置
封包抵達
CPU 掃描
已處理
kernel_events.log

# 系統就緒,選擇機制開始模擬。

// 連線數 ×10 時兩者差距將更顯著。

把速度調到「慢」跑一次 Select,就會看到那條掃描光帶——它經過的每一格都是一次白白花掉的 CPU 檢查。連線數乘以 10,這條光帶就長 10 倍。

三種機制完整比較

select / poll / epoll 三種機制完整比較
特性selectpollepoll
最大連線數1024(FD_SETSIZE 硬限制)無限制無限制
等待複雜度O(N)O(N)O(1)
fd 集合傳遞每次 syscall 都複製到核心每次 syscall 都複製到核心ctl ADD 一次,不重複複製
就緒事件識別核心線性掃描全部 fd核心線性掃描全部 fdCallback 主動推送,只返回就緒 fd
觸發模式只有 LT只有 LTLT + ET 均支援
跨平台Unix / WindowsUnixLinux 限定(BSD 用 kqueue)
適合場景連線數 < 100 的簡單程式中等連線數 + 跨平台需求C10K+ 高並發伺服器

CPU 耗時 vs 並發連線數

兩條線的形狀差異比絕對數字重要:一條是斜率固定的直線,一條幾乎貼著底。

select 的三大致命傷:① fd 上限 1024;② 每次都要把 fd 集合從用戶空間複製到核心;③ 核心用 for loop 逐一掃描,即使只有 1 個活躍連線也掃全部。
poll 改良了什麼?用 pollfd 陣列取代 fd_set,拿掉 1024 上限——但依然 O(N) 掃描、依然每次複製。本質沒有改變,只是把天花板拆了。
epoll 的突破是反轉控制:不再讓應用程式去「查詢」核心,而是核心在中斷時「通知」應用程式。從 Pull 改成 Push,這才是根本性地解決 O(N)。

主流框架如何使用 Epoll

Nginx
worker process 用 epoll + ET 模式,每個 worker 單 Thread 處理數萬連線。Linux 下設定檔預設 use epoll;。
Redis
ae(Async Event)事件循環,Linux 底層是 epoll,BSD/Mac 是 kqueue。單 Thread 靠 epoll 達到微秒級延遲與高吞吐。
Node.js
libuv 事件循環在 Linux 下封裝 epoll,所有非同步 I/O 都走這條路。EventEmitter 的底層就是 epoll 通知。
Java NIO / Netty
Selector 在 Linux 下自動使用 epoll;JDK 11+ 另有 EpollEventLoop 直接透過 JNI 呼叫,繞過 Java 層開銷。

系統診斷速查

top 裡 sy(system)CPU 佔比過高
過頻繁的 Context Switch 或無效 I/O 輪詢。先確認是否用了 select/poll 搭配大量連線,或程式裡有 busy loop。
確認程序真的在用 epoll
strace -p <PID> -e epoll_wait —— 看得到 epoll_wait 被反覆呼叫就對了。
查 fd/連線數上限
cat /proc/sys/fs/file-max 系統最大 fd 數;ulimit -n 目前用戶限制(預設 1024,高並發要調到 65535+)。

面試必備速記

select / poll / epoll 的本質差異
select 和 poll 是 O(N) 輪詢,每次 syscall 都要把 fd 集合複製進核心。Epoll 是事件驅動,核心用 Callback 主動推送,epoll_wait 的複雜度是 O(就緒數量),與總連線數無關。
ET 與 LT 的使用場景
ET 必須搭配 Non-blocking + 迴圈讀到 EAGAIN,效率高但易出 Bug,Nginx 使用。LT 是預設模式,有資料就一直通知,程式好寫,適合業務系統。
為什麼 Redis 單 Thread 卻很快?
Epoll 讓單 Thread 同時管理數萬 Socket,I/O 等待時不阻塞。Redis 本身是記憶體操作(沒有 CPU 密集計算),單 Thread + epoll 完全夠用,還省掉了鎖的開銷。
Epoll 不是萬靈丹
Epoll 解決的是「大量連線的 I/O 等待」。CPU 密集型任務(影像處理、加解密)它幫不上忙,那要靠多 Thread/多 Process。混合場景用 Thread Pool + epoll。