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_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 事件時,主要做了兩件事:

圖 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‌:表示要對目標文件描述符執行的操作,可以是以下幾個值之一:

‌    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:超時時間。

返回值:小於 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 之所以高效,主要有以下原因:  **

(3)epoll 爲阻塞模式是否影響性能?

    當 epoll_wait 未檢測到 epoll 事件時會出讓 CPU 並阻塞進程,這種阻塞是非常有必要的,如果不及時出讓 CPU 會浪費 CPU 資源,導致其他任務無法搶佔 CPU,只要 socket 接收到數據後,及時喚醒 epoll 進程,就不會影響 epoll 性能。

(4)socket 設置成阻塞和非阻塞?

    socket 採用非阻塞方式。socket 設置成阻塞模式會存在以下幾個問題:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/zObydvTaBc0tKzx4G8B3tw