Linux epoll 完全圖解,徹底搞懂 epoll 機制
1. 從內核看 epoll 機制
select 和 poll 雖然能夠實現 IO 複用的功能,但是由於設計的缺陷,select 和 poll 無法處理海量的網絡連接,並且隨着網絡連接數量的增加,select 和 poll 效率越來越低。
此時急需一種更爲高效的 IO 複用機制解決海量併發請求問題,epoll 機制就是爲了解決該問題而誕生的。要理解 epoll 機制並不容易,很多同學一直學不好 epoll,一個很重要的原因是不理解底層實現原理,我們從內核的角度觀察 epoll 具體做了哪些事情,有了這個基礎再去學習 epoll 編程,學習過程將會變得非常簡單。
圖 1 epoll 內核實現原理
如圖 1 所示,epoll 機制分爲兩個部分:用戶態部分和內核態部分。
用戶態部分通過 3 個系統調用:epoll_create,epoll_ctl,epoll_wait 和內核進行交互。內核態部分實現比較複雜,我們將圍繞 struct eventpoll 內核對象來講解。struct eventpoll 對象是 epoll 機制實現的關鍵數據結構,包含三個重要成員:rbr(紅黑樹),rdlist(就緒隊列),wq(等待隊列)。
-
紅黑樹:用於記錄用戶程序註冊的 epoll 事件。
-
等待隊列:epoll 線程休眠後,用於喚醒 epoll 線程。
-
就緒隊列:socket 接收和發送數據後,就緒隊列會記錄 socket 讀事件和寫事件。
用戶程序調用 epoll_create 函數後,會在內核創建 struct eventpoll 對象,同時會返回一個文件描述符給用戶,該文件描述符用於查詢進程文件表,找到對應的文件,再通過文件找到 struct eventpoll 對象。
用戶程序通過 epoll_ctl 函數添加,修改,刪除 socket 事件,註冊成功的 socket 事件會插入紅黑樹。socket 事件添加成功後,epoll 才能監聽 socket 讀寫事件。
如果 epoll 就緒隊列有就緒事件,用戶程序調用 epoll_wait 函數會成功獲取到就緒事件。如果沒有就緒事件,則 epoll 線程陷入休眠。
當 socket 接收到數據後,通過 socket 等待隊列可以喚醒休眠的 epoll 線程,並將 socket 封裝成 epoll 就緒事件插入就緒隊列。此時 epoll 線程已經被喚醒,epoll 線程可以將就緒事件拷貝至用戶程序。
以上就是 epoll 內核工作原理,該部分建議反覆閱讀。
2.epoll 編程實戰
有了前面 epoll 內核工作原理的分析,我們對 epoll 有了更深入的理解。學習 epoll 編程需要熟練掌握 epoll 3 個接口:epoll_create、epoll_ctl、epoll_wait。
2.1 編程接口
(1)epoll_create 函數
epoll_create 函數是一個系統調用,用於在內核創建 struct eventpoll 實例。
#include <sys/epoll.h>
int epoll_create(int size);
參數:size 參數並沒有實際意義,但一定要大於 0。
返回值:成功返回 epoll 文件描述符;失敗返回 -1,並設置 errno。
我們看一下內核源碼實現:
SYSCALL_DEFINE1(epoll_create, int, size)
{
if (size <= 0) return -EINVAL; //size小於等於0,返回錯誤
return do_epoll_create(0); //傳入參數沒用到size
}
(2)epoll_ctl函數
epoll_ctl 函數用於向 epoll 實例中添加、修改或刪除文件描述符(通常代表一個網絡連接或者文件),並設置這些文件描述符感興趣的事件類型,如可讀、可寫或者有異常發生。
如圖 2 所示,epoll_ctl 函數添加 socket 事件時,主要做了兩件事:
-
插入 socket 事件節點至紅黑樹。
-
創建一個等待隊列項插入 socket 等待隊列,用於 socket 接收數據時喚醒 epoll 線程。
圖 2 epoll_ctl 工作原理
** epoll_ctl 函數原型:**
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
參數:
epfd:指向由 epoll_create 創建的 epoll 實例的文件描述符。
op:表示要對目標文件描述符執行的操作,可以是以下幾個值之一:
-
EPOLL_CTL_ADD:向 epoll 實例中添加一個新的文件描述符。
-
EPOLL_CTL_MOD:修改已存在文件描述符的事件類型。
-
EPOLL_CTL_DEL:從 epoll 實例中刪除一個文件描述符。
fd:需要操作的目標文件描述符。
event:指向 struct epoll_event 結構的指針,該結構指定了需要監聽的事件類型。
返回值:成功返回 0;失敗返回 -1,並設置 errno。
struct epoll_event 結構體定義如下:
struct epoll_event {
uint32_t events;
epoll_data_t data;
};
** events:指定要監聽的事件類型,**常見事件類型見表 1。
表 1 epoll 事件表
data:用戶自定義的數據,通常用於存儲與文件描述符相關的上下文信息,獲取就緒事件成功後,事件數組會記錄 data 數據。
struct epoll_data 結構體定義如下:
typedefunion epoll_data {
void *ptr;
int fd; //設置socket文件描述符
uint32_t u32;
uint64_t u64;
} epoll_data_t;
(3)epoll_wait 函數
epoll_wait 函數用於等待在 epoll 實例上註冊的文件描述符上發生的事件。這個函數會阻塞調用線程,直到有事件發生或超時。
圖 3 epoll_wait 工作原理
如圖 3 所示,用戶程序調用 epoll_wait 後,內核循環檢測就緒隊列是否有就緒事件,如果有就緒事件,將就緒事件返回給用戶,否則繼續往下執行,判斷 epoll 是否超時,超時返回 0,如果沒有超時則將 epoll 線程掛起,epoll 線程陷入休眠狀態,同時插入一個 epoll 等待隊列項。
當 socket 接收到數據後,會通過 socket 等待隊列回調函數去檢測 epoll 等待隊列項,並將 epoll 線程喚醒,epoll 線程被喚醒成功後,epoll 線程再次查詢就緒隊列,此時就能成功返回 socket 事件。
epoll_wait 函數原型:
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
參數:
epfd:epoll 文件描述符。
events:epoll 事件數組。
maxevents:指定 events 數組的大小,即可以存儲的最大事件數。
timeout:超時時間。
-
-1:表示無限等待,直到有事件發生。
-
0:表示立即返回,不等待任何事件。
-
正數:表示等待的最大時間(毫秒)。
返回值:小於 0 表示出錯;等於 0 表示超時;大於 0 表示獲取事件成功,返回就緒事件個數。
3.epoll 編程流程
前面我們已經學會了使用 epoll 3 個接口,接下來我們要實現一個完整 epoll 編程示例,如圖 4 所示,該流程是一個 epoll 編程流程,我們按照這樣一個流程去編寫代碼,思路會很清晰,不容易出錯。
圖 4 epoll 編程流程圖
epoll 示例代碼如下,爲了節省篇幅和易於理解,部分非關鍵代碼已省略。
intmain(int argc, char *argv[]){
structepoll_eventev, events[MAX_EVENTS];
int sock_fd, ret = 0;
int efd = epoll_create(10); //創建epoll實例
ev.data.fd = sock_fd;
ev.events = EPOLLIN;
//註冊監聽套接字事件
epoll_ctl(efd, EPOLL_CTL_ADD, sock_fd, &ev);
while (1) {
//超時1000毫秒,獲取就緒事件
int nfds = epoll_wait(efd, events, MAX_EVENTS, 1000);
if (nfds == -1) return-1; //獲取失敗退出
elseif (nfds == 0) continue; //超時,繼續下一輪事件獲取
for (int i = 0; i < nfds; i++) {//輪詢就緒事件數組
int fd = events[i].data.fd;
if (fd == sock_fd) { //監聽套接字
new_fd = accept(sock_fd, (struct sockaddr *)&peer, &addrlen);
setnonblocking(new_fd); //設置新套接字爲非阻塞模式
ev.data.fd = new_fd;
ev.events = EPOLLIN|EPOLLET;
//添加新套接字
epoll_ctl(efd, EPOLL_CTL_ADD, new_fd, &ev);
} else { //業務套接字
if (events[i].events & EPOLLIN) { //EPOLLIN事件
recv(fd, recv_buf, len, 0); //業務套接字接收數據
}
}
}
}
return0;
}
4.epoll 常見問題?
(1)ET 模式和 LT 模式區別?
ET 模式稱爲邊緣觸發,LT 模式稱爲水平觸發。
添加 socket 事件時如果設置爲 ET 模式,當 socket 接收數據後,epoll 就緒隊列只會插入一次 socket 就緒事件,epoll_wait 檢測到 socket 讀事件後,必須一次性把 socket 緩衝區數據全部讀完,否則數據可能丟失。
如果設置爲 LT 模式,此次調用 epoll_wait 沒有讀 socket 緩衝區,下一次調用 epoll_wait 依然能夠檢測到 socket 就緒事件,直到 socket 緩衝區數據被讀完。
我們通過內核源碼觀察二者區別。
.......
if(!(epi->event.events & EPOLLET)) { // LT模式
//將取出來的就緒事件,繼續插入就緒隊列
list_add_tail(&epi->rdllink, &ep->rdllist);
}
(2)epoll 高效的祕密?
** epoll 之所以高效,主要有以下原因: **
-
epoll 等待隊列機制,當就緒隊列沒有 socket 事件時主動讓出 CPU,阻塞進程,提高 CPU 利用率,就緒隊列收到 socket 事件後,喚醒 epoll 線程處理。
-
紅黑樹提高 epoll 事件增加,刪除,修改效率。
-
任務越多,進程出讓 CPU 概率越小,epoll 線程工作效率越高,所以 epoll 非常適合高併發場景。
(3)epoll 爲阻塞模式是否影響性能?
當 epoll_wait 未檢測到 epoll 事件時會出讓 CPU 並阻塞進程,這種阻塞是非常有必要的,如果不及時出讓 CPU 會浪費 CPU 資源,導致其他任務無法搶佔 CPU,只要 socket 接收到數據後,及時喚醒 epoll 進程,就不會影響 epoll 性能。
(4)socket 設置成阻塞和非阻塞?
socket 採用非阻塞方式。socket 設置成阻塞模式會存在以下幾個問題:
-
IO 複用通常是一個進程處理多個網絡連接,如果 socket 爲阻塞模式,那麼其中一個 socket 阻塞會導致進程阻塞,其他 socket 也無法讀寫數據。
-
阻塞的本質是進程狀態和上下文切換,頻繁的阻塞會把讓 CPU 一直處於上下文切換的狀態中,浪費 CPU 資源。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/zObydvTaBc0tKzx4G8B3tw