epoll的那些事
一直沒搞明白 epoll 的機制,以前看不明白 epoll 資料就放棄了。最近重新看這些資料,感覺看明白了大部分。記一下,省的以後又糊塗了。以下內容都是各種資料的小結,以後翻閱省事一點。
I/O 模型與 epoll
I/O 流
阻塞模式下,一個執行緒很難處理多個 I/O 流。比如一個執行緒要讀兩個 I/O 事件流,可能 read 第一個I/O 時,因為資料沒有就緒,所以整個執行緒都阻塞了,而第二個 I/O 資料雖然已經就緒,卻得不到處理。具體原因個人理解是,執行緒不知道哪個 I/O 事件已經就緒,只能一個個試。第二個原因是阻塞模式下,如果事件沒有就緒,系統呼叫會阻塞,導致整個執行緒都阻塞。
非阻塞模式,執行緒可以通過忙輪詢處理多個 I/O 流,同樣因為無法知道哪個 I/O 流是否已經就緒,導致很多系統呼叫都是無效的,效率非常低下。
I/O 多路複用
如果有一個代理,幫助管理多個 I/O 流,當沒有可用的 I/O,執行緒繼續阻塞,I/O 就緒時,喚醒執行緒處理 I/O,效率會大大提高。在 Linux 平臺上,select,poll,epoll 就是這個代理。它們之間具體的優缺點就不講了,這裡只講 epoll 的機制。
I/O 相關的機制,可以參考知乎上面的討論 I/O與epoll
epoll 基礎
以下內容基本上來自 The method to epoll’s madness 和 manpage。
核心內部用資料結構來維護 epoll 的相關資訊,epoll 的三個 API 分別操作這些資料結構。
epoll_create
epoll_create 在核心建立 epoll instance(圖中下方褐色方塊),返回指向這個 epoll instance 的 file descriptor。
epoll_ctl
epoll_ctl 可以讓 file descriptor 註冊到 epoll instance 中,這些 file descriptor 稱為 epoll set(圖中 INTEREST LIST)。當 epoll set 裡面的 file descriptor 有 I/O 就緒情況下,這些 file descriptor 會放到 READY LIST 裡面(圖中藍色部分)。
#include <sys/epoll.h> int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- epfd - epoll_create 返回的 epoll file descriptor
- fd - epoll instance 要監聽的 file descriptor
- op - 對 file descriptor 的操作
- EPOLL_CTL_ADD - 註冊 fd 到 epoll instance,fd 成為 epoll set 一員
- EPOLL_CTL_DEL - 把 fd 從 epoll set 刪除,刪除後進程無法得到 fd 任何事件的通知。如果 fd 註冊到多個epoll instance 中,fd 關閉將導致 fd 從所有 epoll set 中刪除
- EPOLL_CTL_MOD - 修改監聽 fd 的事件
- event - 事件資訊,具體如下所示
typedef union epoll_data { void*ptr; intfd; uint32_tu32; uint64_tu64; } epoll_data_t; struct epoll_event { uint32_tevents;/* Epoll events */ epoll_data_t data;/* User data variable */ };
其中事件型別通過 uint32_t events 的 bit 表示,epoll_data 一般存放發生事件的 fd。
epoll_wait
執行緒呼叫 epoll_wait 會一直阻塞,直到 epoll set 裡面有 fd 的 I/O 就緒。當 epoll_wait 返回後,執行緒遍歷 evlist,處理 READY LIST 裡面的就緒的 I/O 事件。
#include <sys/epoll.h> int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
LT & ET
對於 write 來說,當核心緩衝區非滿(包括空和有部分資料資料),LT 模式下 EPOLLOUT 會一直觸發,當緩衝區從滿到非滿,ET 模式下 EPOLLOUT 才會觸發。對於 read 來說,當緩衝區非空(包括滿和有部分資料),LT 模式下 EPOLLIN 會一直觸發,當緩衝區從空到非空,ET 模式下 EPOLLIN 才會觸發。預設觸發方式是 LT,如果是 ET,在 epoll_ctl 函式裡面設定引數 event.events | EPOLLET 。
因為 LT & ET 觸發方式不同,處理事件的邏輯也不同。先看 manpage 裡面的一個例子
1. The file descriptor that represents the read side of a pipe (rfd) is registered on the epoll instance. 2. A pipe writer writes 2 kB of data on the write side of the pipe. 3. A call to epoll_wait(2) is done that will return rfd as a ready file descriptor. 4. The pipe reader reads 1 kB of data from rfd. 5. A call to epoll_wait(2) is done.
在 ET 模式下,這種情況可能導致程序一直阻塞。
- 假設 pipe 剛開始是空的,A端傳送 2KB,然後等待B端的響應。
- 步驟2完成後,緩衝區從空變成非空,ET 會觸發 EPOLLIN 事件
- 步驟3 epoll_wait 正常返回
- B開始讀操作,但是隻從管道讀 1KB 資料
- 步驟5呼叫 epoll_wait 將一直阻塞。因為 ET 下,緩衝區從空變成非空,才會觸發 EPOLLIN 事件,緩衝區從滿變成非滿,才會觸發 EPOLLOUT 事件。而當前情況不滿足任何觸發條件,所以 epoll_wait 會一直阻塞。
如何解決呢,一個辦法就是步驟4一直讀,直到資料全部讀完,但是在 blocking IO 下會出現另外一個問題,如果某次讀完核心緩衝區後,再次呼叫 read 時,執行緒將會阻塞。所以需要設定 fd 是非阻塞的,當呼叫 read 或者 write 時,當返回 EAGIN/EWOULDBLOCK 後才去呼叫 epoll_wait。
在LT模式下,步驟4結束後,緩衝區還有資料,所以步驟5的 epoll_wait 不會阻塞,因為 EPOLLIN 事件不會丟失,會一直觸發。但是也有一個問題,如果一次讀的資料太少,將導致多次呼叫 epoll_wait,所以效率會有所下降。
為了減少 epoll_wait 呼叫次數,也可以採用ET的模式,使用非阻塞 IO,然後讀寫直到返回 EAGIN/EWOULDBLOCK。
LT/ET 在非阻塞處理有一點點不同,具體參考網路大神的總結 epoll LT/ET 深度剖析
reference
- 《Unix環境高階程式設計》
- I/O與epoll
- epoll LT/ET 深度剖析
- The method to epoll’s madness