從青銅到王者:帶你喫透 epoll 核心機制

在當今互網時代,服務器面臨的高併發場景愈發常見。想象一下,一個熱門的電商網站在促銷活動期間,瞬間湧入成千上萬的用戶請求;或者一個在線遊戲服務器,同時承載着海量玩家的實時交互。在這些場景下,服務器需要高效地處理大量併發連接,以確保用戶體驗的流暢性。而這其中,I/O 處理效率成爲了關鍵因素。傳統的 I/O 模型,如阻塞 I/O、非阻塞 I/O 和 I/O 多路複用(select/poll),在處理大量併發連接時逐漸暴露出其侷限性。阻塞 I/O 模型中,當一個線程發起 I/O 操作時,它會被阻塞,直到操作完成。這意味着在高併發情況下,大量線程會被阻塞,導致系統資源的浪費和性能的下降。例如,在一個簡單的 Web 服務器中,如果使用阻塞 I/O,每個客戶端連接都會佔用一個線程,當併發連接數增多時,線程資源會被迅速耗盡,服務器響應能力急劇下降。

非阻塞 I/O 雖然避免了線程的阻塞,但它需要應用程序不斷地輪詢檢查 I/O 操作的狀態,這會消耗大量的 CPU 資源。在併發連接數較少時,這種方式可能還能接受,但當連接數大幅增加,頻繁的輪詢會使 CPU 不堪重負,系統性能反而降低;I/O 多路複用中的 select 和 poll 雖然可以通過一個線程同時監控多個文件描述符,但它們也存在明顯的缺點。select 的文件描述符數量有限,通常在 1024 個左右,難以滿足大規模併發連接的需求。

而且,select 每次調用都需要將所有文件描述符從用戶空間拷貝到內核空間,檢查完後再拷貝回來,這會帶來較大的開銷。poll 雖然解決了文件描述符數量的限制問題,但它同樣存在內核空間和用戶空間的數據拷貝開銷,並且在處理大量文件描述符時,性能會隨着文件描述符數量的增加而急劇下降。這些傳統 I/O 模型在高併發場景下的侷限性,促使我們尋找更高效的解決方案,而 epoll 正是爲解決這些問題而誕生的。

一、epoll 簡介

1.1epoll 是什麼?

epoll 是 Linux 內核爲處理大批量文件描述符而作了改進的 poll,是 Linux 下多路複用 I/O 接口 select/poll 的增強版本。它誕生於 Linux 2.5.44 內核版本 ,專爲解決高併發場景下 I/O 處理的效率問題。epoll 能顯著提高程序在大量併發連接中只有少量活躍的情況下的系統 CPU 利用率。其核心原理是通過內核與用戶空間共享內存,在內核中維護一個事件表,當有文件描述符就緒時,內核會將其加入就緒隊列,用戶空間通過 epoll_wait 函數獲取就緒的文件描述符,而無需像 select 和 poll 那樣遍歷整個文件描述符集合。

1.2 與其他 I/O 多路複用機制的對比

在 I/O 多路複用機制中,select 和 poll 是 epoll 的 “前輩”,但它們存在一些明顯的不足,而 epoll 正是爲克服這些不足而出現的。

select 是最早被廣泛使用的 I/O 多路複用函數,它允許一個進程監視多個文件描述符。然而,select 存在一個硬傷,即單個進程可監視的文件描述符數量被限制在 FD_SETSIZE(通常爲 1024),這在高併發場景下遠遠不夠。例如,一個大型的在線遊戲服務器,可能需要同時處理成千上萬的玩家連接,select 的這個限制就成爲了性能瓶頸。此外,select 每次調用時,都需要將所有文件描述符從用戶空間拷貝到內核空間,檢查完後再拷貝回用戶空間,並且返回後需要通過遍歷 fd_set 來找到就緒的文件描述符,時間複雜度爲 O (n)。當文件描述符數量較多時,這種無差別輪詢會導致效率急劇下降,大量的 CPU 時間浪費在遍歷操作上。

poll 在一定程度上改進了 select 的不足,它沒有了文件描述符數量的硬限制,使用 pollfd 結構體數組來表示文件描述符集合,並且將監聽事件和返回事件分開,簡化了編程操作。但 poll 本質上和 select 沒有太大差別,它同樣需要將用戶傳入的數組拷貝到內核空間,然後查詢每個 fd 對應的設備狀態。在處理大量文件描述符時,poll 每次調用仍需遍歷整個文件描述符數組,時間複雜度依然爲 O (n),隨着文件描述符數量的增加,性能也會顯著下降。而且,poll 在用戶態與內核態之間的數據拷貝開銷也不容忽視。

epoll 則在設計上有了質的飛躍。它沒有文件描述符數量的上限,能輕鬆處理成千上萬的併發連接,這使得它非常適合高併發的網絡應用場景。epoll 採用事件驅動模式,通過 epoll_ctl 函數將文件描述符和感興趣的事件註冊到內核的事件表中,內核使用紅黑樹來管理這些文件描述符,保證了插入、刪除和查找的高效性。當有事件發生時,內核會將就緒的文件描述符加入到就緒鏈表中,應用程序通過 epoll_wait 函數獲取這些就緒的文件描述符,只需處理有狀態變化的文件描述符即可,避免了遍歷所有文件描述符的開銷,時間複雜度爲 O (1)。這種高效的機制使得 epoll 在高併發情況下能夠保持良好的性能,大大提升了系統的吞吐量和響應速度 。

1.3epoll 的設計思路

epoll 是在 select 出現 N 多年後才被髮明的,是 select 和 poll 的增強版本。epoll 通過以下一些措施來改進效率。

措施一:功能分離

select 低效的原因之一是將 “維護等待隊列” 和“阻塞進程”兩個步驟合二爲一。每次調用 select 都需要這兩步操作,然而大多數應用場景中,需要監視的 socket 相對固定,並不需要每次都修改。epoll 將這兩個操作分開,先用 epoll_ctl 維護等待隊列,再調用 epoll_wait 阻塞進程。顯而易見的,效率就能得到提升。

相比 select,epoll 拆分了功能

爲方便理解後續的內容,我們再來看看 epoll 的用法。如下的代碼中,先用 epoll_create 創建一個 epoll 對象 epfd,再通過 epoll_ctl 將需要監視的 socket 添加到 epfd 中,最後調用 epoll_wait 等待數據。

int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //將所有需要監聽的 socket 添加到 epfd 中 while(1){
    int n = epoll_wait(...)
    for(接收到數據的 socket){
        //處理
    }
}

功能分離,使得 epoll 有了優化的可能。

措施二:就緒列表

select 低效的另一個原因在於程序不知道哪些 socket 收到數據,只能一個個遍歷。如果內核維護一個 “就緒列表”,引用收到數據的 socket,就能避免遍歷。

1.4 工作方式

LT(level triggered)

水平觸發,缺省方式,同時支持 block 和 no-block socket,在這種做法中,內核告訴我們一個文件描述符是否被就緒了,如果就緒了,你就可以對這個就緒的 fd 進行 IO 操作。如果你不作任何操作,內核還是會繼續通知你的,所以,這種模式編程出錯的可能性較小。傳統的 select\poll 都是這種模型的代表。

ET(edge-triggered)

邊沿觸發,高速工作方式,只支持 no-block socket。在這種模式下,當描述符從未就緒變爲就緒狀態時,內核通過 epoll 告訴你。然後它會假設你知道文件描述符已經就緒,並且不會再爲那個描述符發送更多的就緒通知,直到你做了某些操作導致那個文件描述符不再爲就緒狀態了 (比如:你在發送、接受或者接受請求,或者發送接受的數據少於一定量時導致了一個 EWOULDBLOCK 錯誤)。但是請注意,如果一直不對這個 fs 做 IO 操作 (從而導致它再次變成未就緒狀態),內核不會發送更多的通知。

區別:LT 事件不會丟棄,而是隻要讀 buffer 裏面有數據可以讓用戶讀取,則不斷的通知你。而 ET 則只在事件發生之時通知。

1.5epoll 的接口

int epoll_create(int size)

創建一個 epoll 句柄,size 用來告訴內核,這個句柄監聽的數目一共有多大,當創建好句柄以後,他就會佔用一個 fd 值,在 linux 下如果查看 / proc / 進程 id/fd/,是能夠看到這個 fd 的,所以在使用完 epoll 後,必須調用 close() 關閉,否則可能導致 fd 被耗盡。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event event)*

epoll 的事件註冊函數, 它不同於 select 是在監聽事件時告訴內核要監聽什麼類型的事件,而是在這裏先註冊要監聽的事件類型。第一個參數是 epoll_crete() 的返回值,第二個參數表示動作,用三個宏來表示: LL_CTL_ADD: 註冊新的 fd 到 epfd 中;EPOLL_CTL_MOD:修改已經註冊的 fd 的監聽事件;EPOLL_CTL_DEL:從 epfd 中刪除一個 fd 第三個參數是要監聽的 fd,第四個參數是告訴內核需要監聽什麼事件,struct epoll_event 結構如下:

typedef union epoll_data{
        void *ptr;
        int  fd;
        __uint32_t u32;
        __uint64_t u64;
    }epoll_data_t;

    struct epoll_event{
        __uint32_t events; /* Epoll events */
        epoll_data_t data; /* User data variable */
    }

events 可以是以下幾個宏的集合:

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

等待事件的產生,類似於 select() 調用。參數 events 用來從內核得到事件的集合,maxevents 告之內核這個 events 有多大,這個 maxevents 的值不能大於創建 epoll_create() 時的 size,參數 timeout 是超時時間(毫秒,0 會立即返回,-1 將不確定,也有說法說是永久阻塞)。該函數返回需要處理的事件數目,如返回 0 表示已超時。

關於 ET、LT 兩種工作模式:可以得出這樣的結論

ET 模式僅當狀態發生變化的時候才獲得通知, 這裏所謂的狀態的變化並不包括緩衝區中還有未處理的數據, 也就是說, 如果要採用 ET 模式, 需要一直 read/write 直到出錯爲止, 很多人反映爲什麼採用 ET 模式只接收了一部分數據就再也得不到通知了, 大多因爲這樣; 而 LT 模式是隻要有數據沒有處理 就會一直通知下去的。

二、epoll 的核心原理剖析

2.1 常見問題解析

(1) 爲什麼需要 epoll?

epoll 是 Linux 操作系統提供的一種事件驅動的 I/O 模型,用於高效地處理大量併發連接的網絡編程。它相比於傳統的 select 和 poll 方法,具有更高的性能和擴展性。使用 epoll 可以實現以下幾個優勢:

  1. 高效處理大量併發連接:epoll 採用了事件驅動的方式,只有當有可讀或可寫事件發生時纔會通知應用程序,避免了遍歷所有文件描述符的開銷。

  2. 內核與用戶空間數據拷貝少:使用 epoll 時,內核將就緒的文件描述符直接填充到用戶空間的事件數組中,減少了內核與用戶空間之間數據拷貝次數。

  3. 支持邊緣觸發(Edge Triggered)模式:邊緣觸發模式下,僅在狀態變化時才通知應用程序。這意味着每次通知只包含最新狀態的文件描述符信息,可以有效避免低效循環檢查。

  4. 支持水平觸發(Level Triggered)模式:水平觸發模式下,在就緒期間不斷地進行通知,直到應用程序處理完該文件描述符。

(2)select 與 poll 的缺陷?

select 和 poll 都是 Unix 系統中用來監視一組文件描述符的變化的系統調用。它們可以監視文件描述符的三種變化:可讀性、可寫性和異常條件。select 和 poll 的主要缺陷如下:

2.2epoll 操作

epoll 在 linux 內核中申請了一個簡易的文件系統,把原先的一個 select 或者 poll 調用分爲了三個部分:調用 epoll_create 建立一個 epoll 對象(在 epoll 文件系統中給這個句柄分配資源)、調用 epoll_ctl 向 epoll 對象中添加連接的套接字、調用 epoll_wait 收集發生事件的連接。這樣只需要在進程啓動的時候建立一個 epoll 對象,並在需要的時候向它添加或者刪除連接就可以了,因此,在實際收集的時候,epoll_wait 的效率會非常高,因爲調用的時候只是傳遞了發生 IO 事件的連接。

(1)epoll 實現

我們以 linux 內核 2.6 爲例,說明一下 epoll 是如何高效的處理事件的,當某一個進程調用 epoll_create 方法的時候,Linux 內核會創建一個 eventpoll 結構體,這個結構體中有兩個重要的成員。

第一個是 rb_root rbr,這是紅黑樹的根節點,存儲着所有添加到 epoll 中的事件,也就是這個 epoll 監控的事件。
第二個是 list_head rdllist 這是一個雙向鏈表,保存着將要通過 epoll_wait 返回給用戶的、滿足條件的事件。

每一個 epoll 對象都有一個獨立的 eventpoll 結構體,這個結構體會在內核空間中創造獨立的內存,用於存儲使用 epoll_ctl 方法向 epoll 對象中添加進來的事件。這些事件都會掛到 rbr 紅黑樹中,這樣就能夠高效的識別重複添加的節點。

所有添加到 epoll 中的事件都會與設備(如網卡等)驅動程序建立回調關係,也就是說,相應的事件發生時會調用這裏的方法。這個回調方法在內核中叫做 ep_poll_callback,它把這樣的事件放到 rdllist 雙向鏈表中。在 epoll 中,對於每一個事件都會建立一個 epitem 結構體。

當調用 epoll_wait 檢查是否有發生事件的連接時,只需要檢查 eventpoll 對象中的 rdllist 雙向鏈表中是否有 epitem 元素,如果 rdllist 鏈表不爲空,則把這裏的事件複製到用戶態內存中的同時,將事件數量返回給用戶。通過這種方法,epoll_wait 的效率非常高。epoll-ctl 在向 epoll 對象中添加、修改、刪除事件時,從 rbr 紅黑樹中查找事件也非常快。這樣,epoll 就能夠輕易的處理百萬級的併發連接。

(2)epoll 工作模式

epoll 有兩種工作模式,LT(水平觸發)模式與 ET(邊緣觸發)模式。默認情況下,epoll 採用 LT 模式工作。

兩個的區別是:

Level_triggered(水平觸發):當被監控的文件描述符上有可讀寫事件發生時,epoll_wait() 會通知處理程序去讀寫。如果這次沒有把數據一次性全部讀寫完 (如讀寫緩衝區太小),那麼下次調用 epoll_wait() 時,它還會通知你在上沒讀寫完的文件描述符上繼續讀寫,當然如果你一直不去讀寫,它會一直通知你。如果系統中有大量你不需要讀寫的就緒文件描述符,而它們每次都會返回,這樣會大大降低處理程序檢索自己關心的就緒文件描述符的效率。

Edge_triggered(邊緣觸發):當被監控的文件描述符上有可讀寫事件發生時,epoll_wait() 會通知處理程序去讀寫。如果這次沒有把數據全部讀寫完 (如讀寫緩衝區太小),那麼下次調用 epoll_wait() 時,它不會通知你,也就是它只會通知你一次,直到該文件描述符上出現第二次可讀寫事件纔會通知你。這種模式比水平觸發效率高,系統不會充斥大量你不關心的就緒文件描述符。

當然,在 LT 模式下開發基於 epoll 的應用要簡單一些,不太容易出錯,而在 ET 模式下事件發生時,如果沒有徹底地將緩衝區的數據處理完,則會導致緩衝區的用戶請求得不到響應。注意,默認情況下 Nginx 採用 ET 模式使用 epoll 的。

2.3epoll 原理(源碼)

(1) 創建 epoll 對象

如下圖所示,當某個進程調用 epoll_create 方法時,內核會創建一個 eventpoll 對象(也就是程序中 epfd 所代表的對象)。eventpoll 對象也是文件系統中的一員,和 socket 一樣,它也會有等待隊列。

最後 epoll_create 生成的文件描述符如下圖所示:

/*
 * 此結構體存儲在file->private_data中
 */
struct eventpoll {
	// 自旋鎖,在kernel內部用自旋鎖加鎖,就可以同時多線()程對此結構體進行操作
	// 主要是保護ready_list
	spinlock_t lock;
	// 這個互斥鎖是爲了保證在eventloop使用對應的文件描述符的時候,文件描述符不會被移除掉
	struct mutex mtx;
	// epoll_wait使用的等待隊列,和進程喚醒有關
	wait_queue_head_t wq;
	// file->poll使用的等待隊列,和進程喚醒有關
	wait_queue_head_t poll_wait;
	// 就緒的描述符隊列
	struct list_head rdllist;
	// 通過紅黑樹來組織當前epoll關注的文件描述符
	struct rb_root rbr;
	// 在向用戶空間傳輸就緒事件的時候,將同時發生事件的文件描述符鏈入到這個鏈表裏面
	struct epitem *ovflist;
	// 對應的user
	struct user_struct *user;
	// 對應的文件描述符
	struct file *file;
	// 下面兩個是用於環路檢測的優化
	int visited;
	struct list_head visited_list_link;
};

本文講述的是 kernel 是如何將就緒事件傳遞給 epoll 並喚醒對應進程上,因此在這裏主要聚焦於 (wait_queue_head_t wq) 等成員。

(2) 就緒列表的數據結構

就緒列表引用着就緒的 socket,所以它應能夠快速的插入數據。

程序可能隨時調用 epoll_ctl 添加監視 socket,也可能隨時刪除。當刪除時,若該 socket 已經存放在就緒列表中,它也應該被移除。

所以就緒列表應是一種能夠快速插入和刪除的數據結構。雙向鏈表就是這樣一種數據結構,epoll 使用雙向鏈表來實現就緒隊列(對應上圖的 rdllist)。

(3) 索引結構

既然 epoll 將 “維護監視隊列” 和“進程阻塞”分離,也意味着需要有個數據結構來保存監視的 socket。至少要方便的添加和移除,還要便於搜索,以避免重複添加。紅黑樹是一種自平衡二叉查找樹,搜索、插入和刪除時間複雜度都是 O(log(N)),效率較好。epoll 使用了紅黑樹作爲索引結構(對應上圖的 rbr)

ps:因爲操作系統要兼顧多種功能,以及由更多需要保存的數據,rdlist 並非直接引用 socket,而是通過 epitem 間接引用,紅黑樹的節點也是 epitem 對象。同樣,文件系統也並非直接引用着 socket。爲方便理解,本文中省略了某些間接結構。

(4) 維護監視列表

創建 epoll 對象後,可以用 epoll_ctl 添加或刪除所要監聽的 socket。以添加 socket 爲例,如下圖,如果通過 epoll_ctl 添加 sock1、sock2 和 sock3 的監視,內核會將 eventpoll 添加到這三個 socket 的等待隊列中。

當 socket 收到數據後,中斷程序會操作 eventpoll 對象,而不是直接操作進程。

(5) 接收數據

當 socket 收到數據後,中斷程序會給 eventpoll 的 “就緒列表” 添加 socket 引用。如下圖展示的是 sock2 和 sock3 收到數據後,中斷程序讓 rdlist 引用這兩個 socket。

eventpoll 對象相當於是 socket 和進程之間的中介,socket 的數據接收並不直接影響進程,而是通過改變 eventpoll 的就緒列表來改變進程狀態。

當程序執行到 epoll_wait 時,如果 rdlist 已經引用了 socket,那麼 epoll_wait 直接返回,如果 rdlist 爲空,阻塞進程。

(6) 阻塞和喚醒進程

假設計算機中正在運行進程 A 和進程 B,在某時刻進程 A 運行到了 epoll_wait 語句。如下圖所示,內核會將進程 A 放入 eventpoll 的等待隊列中,阻塞進程。

當 socket 接收到數據,中斷程序一方面修改 rdlist,另一方面喚醒 eventpoll 等待隊列中的進程,進程 A 再次進入運行狀態(如下圖)。也因爲 rdlist 的存在,進程 A 可以知道哪些 socket 發生了變化。

值得注意的是,我們在 close 對應的文件描述符的時候,會自動調用 eventpoll_release 將對應的 file 從其關聯的 epoll_fd 中刪除。

close fd
      |->filp_close
            |->fput
                  |->__fput
                        |->eventpoll_release
                              |->ep_remove
所以我們在關閉對應的文件描述符後,並不需要通過 epoll_ctl_del 來刪掉對應 epoll 中相應的描述符。

2.4 I/O 多路複用

(1) 阻塞 OR 非阻塞

我們知道,對於 linux 來說,I/O 設備爲特殊的文件,讀寫和文件是差不多的,但是 I/O 設備因爲讀寫與內存讀寫相比,速度差距非常大。與 cpu 讀寫速度更是沒法比,所以相比於對內存的讀寫,I/O 操作總是拖後腿的那個。網絡 I/O 更是如此,我們很多時候不知道網絡 I/O 什麼時候到來,就好比我們點了一份外賣,不知道外賣小哥們什麼時候送過來,這個時候有兩個處理辦法:

第一個是我們可以先去睡覺,外賣小哥送到樓下了自然會給我們打電話,這個時候我們在醒來取外賣就可以了。
第二個是我們可以每隔一段時間就給外賣小哥打個電話,這樣就能實時掌握外賣的動態信息了。

第一種方式對應的就是阻塞的 I/O 處理方式,進程在進行 I/O 操作的時候,進入睡眠,如果有 I/O 時間到達,就喚醒這個進程。第二種方式對應的是非阻塞輪詢的方式,進程在進行 I/O 操作後,每隔一段時間向內核詢問是否有 I/O 事件到達,如果有就立刻處理。

①阻塞的原理

工作隊列

阻塞是進程調度的關鍵一環,指的是進程在等待某事件(如接收到網絡數據)發生之前的等待狀態,recv、select 和 epoll 都是阻塞方法,以簡單網絡編程爲例。

下圖中的計算機中運行着 A、B、C 三個進程,其中進程 A 執行着上述基礎網絡程序,一開始,這 3 個進程都被操作系統的工作隊列所引用,處於運行狀態,會分時執行:

當進程 A 執行到創建 socket 的語句時,操作系統會創建一個由文件系統管理的 socket 對象(如下圖)。這個 socket 對象包含了發送緩衝區、接收緩衝區、等待隊列等成員。等待隊列是個非常重要的結構,它指向所有需要等待該 socket 事件的進程。

當程序執行到 recv 時,操作系統會將進程 A 從工作隊列移動到該 socket 的等待隊列中(如下圖)。由於工作隊列只剩下了進程 B 和 C,依據進程調度,cpu 會輪流執行這兩個進程的程序,不會執行進程 A 的程序。所以進程 A 被阻塞,不會往下執行代碼,也不會佔用 cpu 資源。

ps:操作系統添加等待隊列只是添加了對這個 “等待中” 進程的引用,以便在接收到數據時獲取進程對象、將其喚醒,而非直接將進程管理納入自己之下。上圖爲了方便說明,直接將進程掛到等待隊列之下。

②喚醒進程

當 socket 接收到數據後,操作系統將該 socket 等待隊列上的進程重新放回到工作隊列,該進程變成運行狀態,繼續執行代碼。也由於 socket 的接收緩衝區已經有了數據,recv 可以返回接收到的數據。

(2) 線程池 OR 輪詢

在現實中,我們當然選擇第一種方式,但是在計算機中,情況就要複雜一些。我們知道,在 linux 中,不管是線程還是進程都會佔用一定的資源,也就是說,系統總的線程和進程數是一定的。如果有許多的線程或者進程被掛起,無疑是白白消耗了系統的資源。而且,線程或者進程的切換也是需要一定的成本的,需要上下文切換,如果頻繁的進行上下文切換,系統會損失很大的性能。一個網絡服務器經常需要連接成千上萬個客戶端,而它能創建的線程可能之後幾百個,線程耗光就不能對外提供服務了。這些都是我們在選擇 I/O 機制的時候需要考慮的。這種阻塞的 I/O 模式下,一個線程只能處理一個流的 I/O 事件,這是問題的根源。

這個時候我們首先想到的是採用線程池的方式限制同時訪問的線程數,這樣就能夠解決線程不足的問題了。但是這又會有第二個問題了,多餘的任務會通過隊列的方式存儲在內存只能夠,這樣很容易在客戶端過多的情況下出現內存不足的情況。

還有一種方式是採用輪詢的方式,我們只要不停的把所有流從頭到尾問一遍,又從頭開始。這樣就可以處理多個流了。

(3) 代理

採用輪詢的方式雖然能夠處理多個 I/O 事件,但是也有一個明顯的缺點,那就是會導致 CPU 空轉。試想一下,如果所有的流中都沒有數據,那麼 CPU 時間就被白白的浪費了。

爲了避免 CPU 空轉,可以引進了一個代理。這個代理比較厲害,可以同時觀察許多流的 I/O 事件,在空閒的時候,會把當前線程阻塞掉,當有一個或多個流有 I/O 事件時,就從阻塞態中醒來,於是我們的程序就會輪詢一遍所有的流,這就是 select 與 poll 所做的事情,可見,採用 I/O 複用極大的提高了系統的效率。

2.5 內核接收網絡數據全過程

如下圖所示,進程在 recv 阻塞期間,計算機收到了對端傳送的數據(步驟①)。數據經由網卡傳送到內存(步驟②),然後網卡通過中斷信號通知 CPU 有數據到達,CPU 執行中斷程序(步驟③)。此處的中斷程序主要有兩項功能,先將網絡數據寫入到對應 socket 的接收緩衝區裏面(步驟④),再喚醒進程 A(步驟⑤),重新將進程 A 放入工作隊列中。

喚醒線程的過程如下圖所示:

三、epoll 系統調用函數

epoll 主要通過三個系統調用實現其強大的功能,分別是 epoll_create、epoll_ctl 和 epoll_wait。下面我們詳細介紹這三個系統調用的功能、參數和返回值,並結合代碼示例展示它們的使用方法。

3.1epoll_create

#include <sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);

epoll_create 用於創建一個 epoll 實例,返回一個文件描述符,後續對 epoll 的操作都將通過這個文件描述符進行。在 Linux 2.6.8 之後,size 參數被忽略,但仍需傳入一個大於 0 的值。epoll_create1 是 epoll_create 的增強版本,flags 參數可以設置爲 0,功能與 epoll_create 相同;也可以設置爲 EPOLL_CLOEXEC,表示在執行 exec 系列函數時自動關閉該文件描述符。

例如:

int epfd = epoll_create1(0);
if (epfd == -1) {
    perror("epoll_create1");
    return 1;
}

上述代碼創建了一個 epoll 實例,並檢查創建是否成功。如果返回值爲 - 1,說明創建失敗,通過 perror 打印錯誤信息。

3.2epoll_ctl

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll_ctl 用於控制 epoll 實例,對指定的文件描述符 fd 執行操作 op。epfd 是 epoll_create 返回的 epoll 實例文件描述符;op 有三個取值:EPOLL_CTL_ADD 表示將文件描述符 fd 添加到 epoll 實例中,並監聽 event 指定的事件;EPOLL_CTL_MOD 用於修改已添加的文件描述符 fd 的監聽事件;EPOLL_CTL_DEL 則是將文件描述符 fd 從 epoll 實例中刪除,此時 event 參數可以爲 NULL。

event 是一個指向 epoll_event 結構體的指針,該結構體定義如下:

typedef union epoll_data {
    void        *ptr;
    int          fd;
    uint32_t     u32;
    uint64_t     u64;
} epoll_data_t;

struct epoll_event {
    uint32_t     events;      /* Epoll events */
    epoll_data_t data;        /* User data variable */
};

events 字段表示要監聽的事件類型,常見的有 EPOLLIN(表示對應的文件描述符可以讀)、EPOLLOUT(表示對應的文件描述符可以寫)、EPOLLRDHUP(表示套接字的一端已經關閉,或者半關閉)、EPOLLERR(表示對應的文件描述符發生錯誤)、EPOLLHUP(表示對應的文件描述符被掛起)等。data 字段是一個聯合體,可用於存儲用戶自定義的數據,通常會將 fd 存儲在這裏,以便在事件觸發時識別是哪個文件描述符。

例如,將標準輸入(STDIN_FILENO)添加到 epoll 實例中,監聽可讀事件:

struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = STDIN_FILENO;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &event) == -1) {
    perror("epoll_ctl");
    close(epfd);
    return 1;
}

上述代碼將標準輸入的文件描述符添加到 epoll 實例中,監聽可讀事件 EPOLLIN。如果 epoll_ctl 調用失敗,打印錯誤信息並關閉 epoll 實例。

3.3epoll_wait

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll_wait 用於等待 epoll 實例上的事件發生。epfd 是 epoll 實例的文件描述符;events 是一個指向 epoll_event 結構體數組的指針,用於存儲發生的事件;maxevents 表示 events 數組最多能容納的事件數量;timeout 是超時時間,單位爲毫秒。如果 timeout 爲 - 1,表示無限期等待,直到有事件發生;如果爲 0,則立即返回,不等待任何事件;如果爲正數,則等待指定的毫秒數,超時後返回。

返回值爲發生的事件數量,如果返回 0 表示超時且沒有事件發生;如果返回 - 1,表示發生錯誤,可通過 errno 獲取具體錯誤信息。

例如:

struct epoll_event events[10];
int nfds = epoll_wait(epfd, events, 10, -1);
if (nfds == -1) {
    perror("epoll_wait");
    close(epfd);
    return 1;
}
for (int i = 0; i < nfds; i++) {
    if (events[i].data.fd == STDIN_FILENO) {
        char buffer[1024];
        ssize_t count = read(STDIN_FILENO, buffer, sizeof(buffer));
        if (count == -1) {
            perror("read");
            return 1;
        }
        printf("Read %zd bytes\n", count);
    }
}

上述代碼使用 epoll_wait 等待 epoll 實例上的事件發生,最多等待 10 個事件,無限期等待。當有事件發生時,遍歷 events 數組,檢查是否是標準輸入的可讀事件。如果是,讀取標準輸入的數據並打印讀取的字節數。

通過這三個系統調用,我們可以創建 epoll 實例,註冊文件描述符及其感興趣的事件,然後等待事件發生並處理,實現高效的 I/O 多路複用。

四、epoll 數據結構支撐

epoll 能夠高效地處理大量併發連接,離不開其精心設計的數據結構。epoll 主要基於紅黑樹和雙向鏈表這兩種數據結構,它們相互配合,爲 epoll 的高性能提供了堅實的基礎。

4.1 紅黑樹

epoll 使用紅黑樹來管理所有註冊的文件描述符。紅黑樹是一種自平衡的二叉搜索樹,它具有以下優點,使其非常適合用於 epoll 的文件描述符管理:

例如,在一個高併發的 Web 服務器中,可能會同時有數千個客戶端連接,每個連接對應一個文件描述符。epoll 使用紅黑樹管理這些文件描述符,當有新的客戶端連接到來時,能夠迅速將其對應的文件描述符插入紅黑樹中;當某個客戶端斷開連接時,也能快速從紅黑樹中刪除對應的文件描述符,確保服務器能夠高效地管理大量的併發連接。

4.2 雙向鏈表

epoll 使用雙向鏈表來存儲就緒的文件描述符及其對應的事件。當某個文件描述符上發生了感興趣的事件(如可讀、可寫等),內核會將該文件描述符及其事件信息封裝成一個節點,添加到雙向鏈表中。雙向鏈表的主要優點如下:

例如,在一個網絡聊天服務器中,當有多個客戶端同時發送消息時,這些客戶端對應的文件描述符會因爲有可讀數據而變爲就緒狀態,內核會將它們添加到雙向鏈表中。當服務器調用 epoll_wait 時,能夠迅速從雙向鏈表中獲取到這些就緒的文件描述符,讀取客戶端發送的消息並進行處理,確保聊天的實時性和流暢性。

通過紅黑樹和雙向鏈表的結合,epoll 實現了高效的文件描述符管理和事件通知機制。紅黑樹負責高效地管理所有註冊的文件描述符,確保插入、刪除和查找操作的高效性;雙向鏈表則專注於存儲就緒的文件描述符,使得應用程序能夠快速獲取到需要處理的事件,大大提高了 epoll 在高併發場景下的性能和效率 。

五、epoll 在實際項目中的應用

5.1 應用場景捶胸頓足薩維爾 55645678

epoll 憑藉其高效的 I/O 多路複用能力,在衆多實際項目場景中發揮着關鍵作用。

在高性能網絡服務器領域,epoll 是當之無愧的 “寵兒”。以知名的 Web 服務器 Nginx 爲例,它廣泛應用 epoll 來處理大量併發的 HTTP 請求。在高併發的 Web 應用中,Nginx 通過 epoll 能夠同時監聽數以萬計的客戶端連接。當有新的 HTTP 請求到達時,epoll 能迅速捕獲到事件並通知 Nginx 進行處理,確保服務器能夠快速響應客戶端的請求,實現高效的數據傳輸。在 “雙 11” 這樣的電商購物狂歡節期間,大量用戶同時訪問電商網站,Nginx 利用 epoll 機制可以輕鬆應對海量的併發連接,保障網站的穩定運行,爲用戶提供流暢的購物體驗。

在實時通信系統中,如即時通訊軟件、在線遊戲服務器等,對消息的實時性和系統的併發處理能力要求極高。epoll 同樣大顯身手。在一款熱門的在線遊戲中,服務器需要同時與成千上萬的玩家保持實時連接,處理玩家的操作指令、同步遊戲狀態等。通過 epoll,遊戲服務器可以高效地管理這些併發連接,及時接收和處理玩家發送的操作數據,並將遊戲狀態的更新推送給玩家。當玩家在遊戲中進行實時對戰時,epoll 確保了服務器能夠快速響應玩家的操作,如移動、攻擊等指令,使得遊戲畫面流暢,玩家之間的交互更加實時,極大地提升了遊戲的趣味性和競技性。

在分佈式系統中,各個節點之間需要頻繁進行通信和數據交互。epoll 可以用於實現分佈式系統中的消息隊列、RPC(遠程過程調用)框架等組件。在一個大規模的分佈式電商系統中,訂單處理模塊、庫存管理模塊、支付模塊等多個組件之間需要通過消息隊列進行異步通信。epoll 被應用於消息隊列服務器,用於高效地處理大量的消息收發事件,確保各個模塊之間的通信穩定、高效,從而保證整個分佈式系統的正常運行 。

5.2 代碼實現與實踐

下面我們通過一個完整的代碼示例,展示如何使用 epoll 實現一個簡單的 TCP 服務器。這個示例將逐步解釋代碼邏輯,包括創建 socket、設置非阻塞、註冊 epoll 事件、處理連接和數據讀寫等操作。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <unistd.h>
#include <fcntl.h>

#define PORT 8888
#define MAX_EVENTS 10
#define BUFFER_SIZE 1024

// 設置文件描述符爲非阻塞模式
int setnonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    int listen_fd, conn_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    int epoll_fd;
    struct epoll_event event, events[MAX_EVENTS];
    char buffer[BUFFER_SIZE];

    // 創建監聽socket
    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1) {
        perror("socket creation failed");
        return 1;
    }

    // 設置socket地址和端口
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    // 綁定socket到指定地址和端口
    if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind failed");
        close(listen_fd);
        return 1;
    }

    // 開始監聽
    if (listen(listen_fd, 10) == -1) {
        perror("listen failed");
        close(listen_fd);
        return 1;
    }

    // 創建epoll實例
    epoll_fd = epoll_create1(0);
    if (epoll_fd == -1) {
        perror("epoll_create1 failed");
        close(listen_fd);
        return 1;
    }

    // 設置監聽socket爲非阻塞模式
    if (setnonblocking(listen_fd) == -1) {
        perror("setnonblocking failed");
        close(listen_fd);
        close(epoll_fd);
        return 1;
    }

    // 將監聽socket添加到epoll實例中,監聽可讀事件
    event.events = EPOLLIN;
    event.data.fd = listen_fd;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &event) == -1) {
        perror("epoll_ctl add listen_fd failed");
        close(listen_fd);
        close(epoll_fd);
        return 1;
    }

    // 進入事件循環
    while (1) {
        // 等待事件發生,最多等待10個事件,-1表示無限期等待
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        if (nfds == -1) {
            perror("epoll_wait failed");
            break;
        }

        for (int i = 0; i < nfds; i++) {
            if (events[i].data.fd == listen_fd) {
                // 有新的連接請求
                conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
                if (conn_fd == -1) {
                    perror("accept failed");
                    continue;
                }

                // 設置新連接的socket爲非阻塞模式
                if (setnonblocking(conn_fd) == -1) {
                    perror("setnonblocking for new connection failed");
                    close(conn_fd);
                } else {
                    // 將新連接的socket添加到epoll實例中,監聽可讀事件
                    event.events = EPOLLIN;
                    event.data.fd = conn_fd;
                    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &event) == -1) {
                        perror("epoll_ctl add conn_fd failed");
                        close(conn_fd);
                    }
                }
            } else {
                // 有數據可讀
                conn_fd = events[i].data.fd;
                ssize_t bytes_read = read(conn_fd, buffer, sizeof(buffer));
                if (bytes_read == -1) {
                    if (errno == EAGAIN || errno == EWOULDBLOCK) {
                        // 沒有數據可讀,繼續循環
                        continue;
                    } else {
                        perror("read failed");
                        close(conn_fd);
                        epoll_ctl(epoll_fd, EPOLL_CTL_DEL, conn_fd, NULL);
                    }
                } else if (bytes_read == 0) {
                    // 對方關閉連接
                    printf("Client closed connection\n");
                    close(conn_fd);
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, conn_fd, NULL);
                } else {
                    buffer[bytes_read] = '\0';
                    printf("Received: %s\n", buffer);

                    // 回顯數據給客戶端
                    ssize_t bytes_written = write(conn_fd, buffer, bytes_read);
                    if (bytes_written == -1) {
                        perror("write failed");
                    }
                }
            }
        }
    }

    // 關閉監聽socket和epoll實例
    close(listen_fd);
    close(epoll_fd);

    return 0;
}
  1. 創建 socket:使用 socket 函數創建一個 TCP 套接字,指定協議族爲 AF_INET(IPv4),套接字類型爲 SOCK_STREAM(流式套接字)。

  2. 設置 socket 地址和端口:填充 sockaddr_in 結構體,指定服務器的 IP 地址爲 INADDR_ANY(表示接受任意 IP 地址的連接),端口號爲 PORT(這裏設置爲 8888)。

  3. 綁定 socket:使用 bind 函數將創建的 socket 綁定到指定的地址和端口,確保服務器能夠在該地址和端口上監聽連接請求。

  4. 開始監聽:調用 listen 函數,將 socket 設置爲監聽模式,允許客戶端連接,參數 10 表示最大連接隊列長度。

  5. 創建 epoll 實例:通過 epoll_create1 函數創建一個 epoll 實例,返回一個文件描述符 epoll_fd,後續對 epoll 的操作都將通過這個文件描述符進行。

  6. 設置非阻塞模式:使用 setnonblocking 函數將監聽 socket 設置爲非阻塞模式,這樣在沒有數據可讀或可寫時,read 和 write 操作不會阻塞線程,而是立即返回錯誤碼 EAGAIN 或 EWOULDBLOCK,提高程序的併發處理能力。

  7. 註冊 epoll 事件:將監聽 socket 添加到 epoll 實例中,使用 epoll_ctl 函數,操作類型爲 EPOLL_CTL_ADD,表示添加一個新的文件描述符到 epoll 實例中,並指定監聽事件爲 EPOLLIN(可讀事件)。

  8. 事件循環:進入一個無限循環,調用 epoll_wait 函數等待 epoll 實例上的事件發生。epoll_wait 會阻塞線程,直到有事件發生或超時。當有事件發生時,它會返回發生事件的文件描述符數量 nfds。

  9. 處理新連接:如果發生事件的文件描述符是監聽 socket,說明有新的連接請求到來。調用 accept 函數接受連接,返回一個新的 socket 描述符 conn_fd,用於與客戶端進行通信。然後將新連接的 socket 也設置爲非阻塞模式,並添加到 epoll 實例中,監聽可讀事件。

  10. 處理數據讀寫:如果發生事件的文件描述符不是監聽 socket,說明是已連接的客戶端有數據可讀。調用 read 函數讀取客戶端發送的數據。如果讀取到的數據長度爲 0,說明客戶端關閉了連接,關閉對應的 socket 並從 epoll 實例中刪除;如果讀取過程中出現錯誤,且錯誤碼不是 EAGAIN 或 EWOULDBLOCK,則打印錯誤信息並關閉 socket 和從 epoll 實例中刪除;如果讀取到數據,則將數據打印出來,並使用 write 函數將數據回顯給客戶端。

通過這個示例,我們可以看到如何使用 epoll 實現一個簡單而高效的 TCP 服務器,能夠同時處理多個客戶端的連接和數據讀寫操作,充分體現了 epoll 在高併發網絡編程中的強大能力 。

六、epoll 使用中的注意事項與優化技巧

6.1 常見問題及解決方案

在使用 epoll 時,開發者常常會遇到一些棘手的問題,其中 ET 模式下數據讀取不完整以及 epoll 驚羣問題較爲典型。

在 ET 模式下,數據讀取不完整是一個常見的 “陷阱”。由於 ET 模式的特性,只有當文件描述符的狀態發生變化時纔會觸發事件通知。在讀取數據時,如果沒有一次性將緩衝區中的數據全部讀完,後續即使緩衝區中仍有剩餘數據,只要狀態不再變化,就不會再次觸發可讀事件通知。這就導致可能會遺漏部分數據,影響程序的正常運行。

例如,在一個網絡通信程序中,客戶端向服務器發送了一個較大的數據包,服務器在 ET 模式下接收數據。如果服務器在第一次讀取時只讀取了部分數據,而沒有繼續讀取剩餘數據,那麼剩餘的數據就會被 “遺忘”,導致數據傳輸的不完整。解決這個問題的關鍵在於,當檢測到可讀事件時,要循環讀取數據,直到 read 函數返回 EAGAIN 錯誤,表示緩衝區中已無數據可讀。這樣才能確保將緩衝區中的數據全部讀取完畢,避免數據丟失 。

epoll 驚羣問題也是使用 epoll 時需要關注的重點。epoll 驚羣通常發生在多個進程或線程使用各自的 epoll 實例監聽同一個 socket 的場景中。當有事件發生時,所有阻塞在 epoll_wait 上的進程或線程都會被喚醒,但實際上只有一個進程或線程能夠成功處理該事件,其他進程或線程在處理失敗後又會重新休眠。這會導致大量不必要的進程或線程上下文切換,浪費系統資源,降低程序性能。在一個多進程的 Web 服務器中,多個工作進程都使用 epoll 監聽同一個端口。當有新的 HTTP 請求到來時,所有工作進程的 epoll_wait 都會被喚醒,但只有一個進程能夠成功接受連接並處理請求,其他進程的喚醒操作就成爲了無效的開銷。

爲了避免 epoll 驚羣問題,可以使用 epoll 的 EPOLLEXCLUSIVE 模式,該模式在 Linux 4.5 + 內核版本中可用。當設置了 EPOLLEXCLUSIVE 標誌後,epoll 在喚醒等待事件的進程或線程時,只會喚醒一個,從而避免了多個進程或線程同時被喚醒的情況,有效減少了系統資源的浪費 。同時,也可以結合使用 SO_REUSEPORT 選項,每個進程或線程都有自己獨立的 socket 綁定到同一個端口,內核會根據四元組信息進行負載均衡,將新的連接分配給不同的進程或線程,進一步優化高併發場景下的性能 。

6.2 性能優化建議

爲了充分發揮 epoll 的優勢,提升程序性能,我們可以從以下幾個方面進行優化:

合理設置 epoll_wait 的超時時間至關重要。epoll_wait 的 timeout 參數決定了等待事件發生的最長時間。如果設置爲 - 1,表示無限期等待,直到有事件發生;設置爲 0,則立即返回,不等待任何事件;設置爲正數,則等待指定的毫秒數。在實際應用中,需要根據具體業務場景來合理選擇。

在一些對實時性要求極高的場景,如在線遊戲服務器,可能需要將超時時間設置爲較短的值,以確保能夠及時響應玩家的操作。但如果設置得過短,可能會導致頻繁的 epoll_wait 調用,增加系統開銷。因此,需要通過測試和調優,找到一個平衡點,既能滿足實時性需求,又能降低系統開銷。可以根據業務的平均響應時間和事件發生的頻率來估算合適的超時時間,然後在實際運行中根據性能指標進行調整 。

批量處理事件也是提高 epoll 性能的有效方法。當 epoll_wait 返回多個就緒事件時,一次性處理多個事件可以減少函數調用和上下文切換的開銷。在一個高併發的文件服務器中,可能同時有多個客戶端請求讀取文件。當 epoll_wait 返回多個可讀事件時,可以將這些事件對應的文件描述符放入一個隊列中,然後批量讀取文件數據。可以使用線程池或協程來並行處理這些事件,進一步提高處理效率。通過批量處理事件,能夠充分利用系統資源,提高程序的吞吐量 。

使用 EPOLLONESHOT 事件可以避免重複觸發帶來的性能問題。對於註冊了 EPOLLONESHOT 的文件描述符,操作系統最多觸發其上註冊的一個可讀、可寫或者異常的事件,且只觸發一次,除非使用 epoll_ctl 函數重置該文件描述符上註冊的 EPOLLONESHOT 事件。這在多線程環境中尤爲重要,它可以確保一個 socket 在同一時刻只被一個線程處理,避免多個線程同時操作同一個 socket 導致的競態條件。

在一個多線程的網絡爬蟲程序中,每個線程負責處理一個網頁的下載和解析。通過爲每個 socket 設置 EPOLLONESHOT 事件,可以保證每個 socket 在下載過程中不會被其他線程干擾,提高程序的穩定性和性能。在處理完事件後,要及時重置 EPOLLONESHOT 事件,以便該 socket 在後續有新事件發生時能夠再次被觸發 。

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