圖解|深入揭秘epoll是如何實現IO多路複用的!

導語 | 提起epoll,大家都不陌生,知道它效能不錯。但是它內部是如何工作的,如何達到高效能的效果呢,鮮有文章能把原理介紹清楚,所以我就擼起袖子搞了一篇文章,獻給大家!

引言

程序在Linux上是一個開銷不小的傢伙,先不說建立,光是上下文切換一次就得幾個微秒。所以為了高效地對海量使用者提供服務,必須要讓一個程序能同時處理很多個tcp連線才行。現在假設一個程序保持了10000條連線,那麼如何發現哪條連線上有資料可讀了、哪條連線可寫了?

我們當然可以採用迴圈遍歷的方式來發現IO事件,但這種方式太低階了。我們希望有一種更高效的機制,在很多連線中的某條上有IO事件發生的時候直接快速把它找出來。其實這個事情Linux作業系統已經替我們都做好了,它就是我們所熟知的IO多路複用機制。這裡的複用指的就是對程序的複用。

在Linux上多路複用方案有select、poll、epoll。它們三個中epoll的效能表現是最優秀的,能支援的併發量也最大。所以我們今天把epoll作為要拆解的物件,深入揭秘核心是如何實現多路的IO管理的。

為了方便討論,我們舉一個使用了epoll的簡單示例(只是個例子,實踐中不這麼寫):

其中和epoll相關的函式是如下三個:

epoll_create:建立一個epoll物件

epoll_ctl:向epoll物件中新增要管理的連線

epoll_wait:等待其管理的連線上的IO事件

藉助這個demo,我們來展開對epoll原理的深度拆解。相信等你理解了這篇文章以後,你對epoll的駕馭能力將變得爐火純青!!

一、accept建立新的socket

我們直接從伺服器端的accept講起。當accept之後,程序會建立一個新的socket出來,專門用於和對應的客戶端通訊,然後把它放到當前程序的開啟檔案列表中。

圖解|深入揭秘epoll是如何實現IO多路複用的!

接下來我們來看一下接收連線時socket核心物件的建立原始碼。accept的系統呼叫程式碼位於原始檔net/socket。c下。

圖解|深入揭秘epoll是如何實現IO多路複用的!

(一)初始化struct socket物件

在上述的原始碼中,首先是呼叫sock_alloc申請一個struct socket物件出來。然後接著把listen狀態的socket物件上的協議操作函式集合ops賦值給新的socket。(對於所有的AF_INET協議族下的socket來說,它們的ops方法都是一樣的,所以這裡可以直接複製過來)

圖解|深入揭秘epoll是如何實現IO多路複用的!

其中inet_stream_ops的定義如下:

(二)為新socket物件申請file

struct socket物件中有一個重要的成員—— file核心物件指標。這個指標初始化的時候是空的。在accept方法裡會呼叫sock_alloc_file來申請記憶體並初始化。然後將新file物件設定到sock->file上。

來看sock_alloc_file的實現過程:

sock_alloc_file又會接著呼叫到alloc_file。注意在alloc_file 方法中,把socket_file_ops函式集合一併賦到了新file->f_op 裡了。

socket_file_ops的具體定義如下:

這裡看到,在accept裡建立的新socket裡的file->f_op->poll函式指向的是sock_poll。接下來我們會呼叫到它,後面我們再說。

其實file物件內部也有一個socket指標,指向socket物件。

(三)接收連線

在socket核心物件中除了file物件指標以外,有一個核心成員sock。

這個struct sock資料結構非常大,是socket的核心核心物件。傳送佇列、接收佇列、等待佇列等核心資料結構都位於此。其定義位置檔案include/net/sock。h,由於太長就不展示了。

在accept的原始碼中:

sock->ops->accept對應的方法是inet_accept。它執行的時候會從握手佇列裡直接獲取建立好的sock。sock物件的完整建立過程涉及到三次握手,比較複雜,不展開了說了。咱們只看struct sock初始化過程中用到的一個函式:

在這裡把sock物件的sk_data_ready函式指標設定為sock_def_readable。這個這裡先記住就行了,後面會用到。

(四)新增新檔案到當前程序的開啟檔案列表中

當file、socket、sock等關鍵核心物件建立完畢以後,剩下要做的一件事情就是把它掛到當前程序的開啟檔案列表中就行了。

二、epoll_create實現

在使用者程序呼叫epoll_create時,核心會建立一個struct eventpoll的核心物件。並同樣把它關聯到當前程序的已開啟檔案列表中。

圖解|深入揭秘epoll是如何實現IO多路複用的!

對於struct eventpoll物件,更詳細的結構如下(同樣只列出和今天主題相關的成員)。

圖解|深入揭秘epoll是如何實現IO多路複用的!

epoll_create的原始碼相對比較簡單。在fs/eventpoll。c下

struct eventpoll的定義也在這個原始檔中。

eventpoll這個結構體中的幾個成員的含義如下:

wq:等待佇列連結串列。軟中斷資料就緒的時候會透過wq來找到阻塞在epoll物件上的使用者程序。

rbr:一棵紅黑樹。為了支援對海量連線的高效查詢、插入和刪除,eventpoll內部使用了一棵紅黑樹。透過這棵樹來管理使用者程序下新增進來的所有socket連線。

rdllist:就緒的描述符的連結串列。當有的連線就緒的時候,核心會把就緒的連線放到rdllist連結串列裡。這樣應用程序只需要判斷連結串列就能找出就緒程序,而不用去遍歷整棵樹。

當然這個結構被申請完之後,需要做一點點的初始化工作,這都在ep_alloc中完成。

說到這兒,這些成員其實只是剛被定義或初始化了,還都沒有被使用。它們會在下面被用到。

三、epoll_ctl新增socket

理解這一步是理解整個epoll的關鍵。

為了簡單,我們只考慮使用EPOLL_CTL_ADD新增socket,先忽略刪除和更新。

假設我們現在和客戶端們的多個連線的socket都建立好了,也建立好了epoll核心物件。在使用epoll_ctl註冊每一個socket的時候,核心會做如下三件事情:

分配一個紅黑樹節點物件epitem。

新增等待事件到socket的等待佇列中,其回撥函式是ep_poll_callback。

將epitem插入到epoll物件的紅黑樹裡。

透過epoll_ctl新增兩個socket以後,這些核心資料結構最終在程序中的關係圖大致如下:

圖解|深入揭秘epoll是如何實現IO多路複用的!

我們來詳細看看socket是如何新增到epoll物件裡的,找到epoll_ctl的原始碼。

在epoll_ctl中首先根據傳入fd找到eventpoll、socket相關的核心物件。對於EPOLL_CTL_ADD操作來說,會然後執行到ep_insert函式。所有的註冊都是在這個函式中完成的。

(一)分配並初始化epitem

對於每一個socket,呼叫epoll_ctl的時候,都會為之分配一個epitem。該結構的主要資料如下:

對epitem進行了一些初始化,首先在epi->ep=ep這行程式碼中將其ep指標指向eventpoll物件。另外用要新增的socket的file、fd來填充epitem->ffd。

其中使用到的ep_set_ffd函式如下。

(二)設定socket等待佇列

在建立epitem並初始化之後,ep_insert中第二件事情就是設定socket物件上的等待任務佇列。並把函式fs/eventpoll。c檔案下的ep_poll_callback設定為資料就緒時候的回撥函式。

圖解|深入揭秘epoll是如何實現IO多路複用的!

這一塊的原始碼稍微有點繞,沒有耐心的話直接跳到下面的加粗字型來看。首先來看ep_item_poll。

看,這裡呼叫到了socket下的file->f_op->poll。透過上面第一節的socket的結構圖,我們知道這個函式實際上是sock_poll。

同樣回看第一節裡的socket的結構圖,sock->ops->poll其實指向的是tcp_poll。

在sock_poll_wait的第二個引數傳參前,先呼叫了sk_sleep函式。在這個函數里它獲取了sock物件下的等待佇列列表頭wait_queue_head_t,待會等待佇列項就插入這裡。這裡稍微注意下,是socket的等待佇列,不是epoll物件的。來看sk_sleep原始碼:

接著真正進入sock_poll_wait。

這裡的qproc是個函式指標,它在前面的init_poll_funcptr呼叫時被設定成了ep_ptable_queue_proc函式。

敲黑板!!!注意,劃重點:在ep_ptable_queue_proc函式中,新建了一個等待佇列項,並註冊其回撥函式為ep_poll_callback函式。然後再將這個等待項新增到socket的等待佇列中。

我們今天的socket是交給epoll來管理的,不需要在一個socket就緒的時候就喚醒程序,所以這裡的q->private沒有什麼用,就設定成了NULL。

如上,等待佇列項中僅僅只設定了回撥函式q->func為ep_poll_callback。在後面的第5節資料來啦中我們將看到,軟中斷將資料收到socket的接收佇列後,會透過註冊的這個ep_poll_callback函式來回調,進而通知到epoll物件。

(三)插入紅黑樹

分配完epitem物件後,緊接著並把它插入到紅黑樹中。一個插入了一些socket描述符的epoll裡的紅黑樹的示意圖如下:

這裡我們再聊聊為啥要用紅黑樹,很多人說是因為效率高。其實我覺得這個解釋不夠全面,要說查詢效率樹哪能比的上HASHTABLE。我個人認為覺得更為合理的一個解釋是為了讓epoll在查詢效率、插入效率、記憶體開銷等等多個方面比較均衡,最後發現最適合這個需求的資料結構是紅黑樹。

四、epoll_wait等待接收

epoll_wait做的事情不復雜,當它被呼叫時它觀察eventpoll->rdllist 連結串列裡有沒有資料即可。有資料就返回,沒有資料就建立一個等待佇列項,將其新增到eventpoll的等待佇列上,然後把自己阻塞掉就完事。

圖解|深入揭秘epoll是如何實現IO多路複用的!

注意:epoll_ctl新增socket時也建立了等待佇列項。不同的是這裡的等待佇列項是掛在epoll物件上的,而前者是掛在socket物件上的。

其原始碼如下:

(一)判斷就緒佇列上有沒有事件就緒

首先呼叫ep_events_available來判斷就緒連結串列中是否有可處理的事件。

(二)定義等待事件並關聯當前程序

假設確實沒有就緒的連線,那接著會進入init_waitqueue_entry中定義等待任務,並把current(當前程序)新增到waitqueue上。

注意:當沒有IO事件的時候,epoll也是會阻塞掉當前程序。這個是合理的,因為沒有事情可做了佔著CPU也沒啥意義。網上的很多文章有個很不好的習慣,討論阻塞、非阻塞等概念的時候都不說主語。這會導致你看的雲裡霧裡。拿epoll來說,epoll本身是阻塞的,但一般會把socket設定成非阻塞。只有說了主語,這些概念才有意義。

注意這裡的回撥函式名稱是default_wake_function。後續在第5節資料來啦時將會呼叫到該函式。

(三)新增到等待佇列

在這裡,把上一小節定義的等待事件新增到了epoll物件的等待佇列中。

(四)讓出CPU主動進入睡眠狀態

透過set_current_state把當前程序設定為可打斷。呼叫schedule_hrtimeout_range讓出CPU,主動進入睡眠狀態。

在schedule中選擇下一個程序排程

五、資料來啦

在前面epoll_ctl執行的時候,核心為每一個socket上都添加了一個等待佇列項。在epoll_wait執行完的時候,又在event poll物件上添加了等待佇列元素。在討論資料開始接收之前,我們把這些佇列項的內容再稍微總結一下。

圖解|深入揭秘epoll是如何實現IO多路複用的!

socket->sock->sk_data_ready設定的就緒處理函式是sock_def_readable。

在socket的等待佇列項中,其回撥函式是ep_poll_callback。另外其private沒有用了,指向的是空指標null。

在eventpoll的等待佇列項中,回撥函式是default_wake_function。其private指向的是等待該事件的使用者程序。

在這一小節裡,我們將看到軟中斷是怎麼樣在資料處理完之後依次進入各個回撥函式,最後通知到使用者程序的。

(一)接收資料到任務佇列

關於軟中斷是怎麼處理網路幀,為了避免篇幅過於臃腫,這裡不再介紹。我們今天直接從tcp協議棧的處理入口函式tcp_v4_rcv開始說起。

在tcp_v4_rcv中首先根據收到的網路包的header裡的source和dest資訊來在本機上查詢對應的socket。找到以後,我們直接進入接收的主體函式tcp_v4_do_rcv來看。

我們假設處理的是ESTABLISH狀態下的包,這樣就又進入tcp_rcv_established函式中進行處理。

在tcp_rcv_established中透過呼叫tcp_queue_rcv函式中完成了將接收資料放到socket的接收佇列上。

圖解|深入揭秘epoll是如何實現IO多路複用的!

如下原始碼所示:

(二)查詢就緒回撥函式

呼叫tcp_queue_rcv接收完成之後,接著再呼叫sk_data_ready來喚醒在socket上等待的使用者程序。這又是一個函式指標。回想上面第一節我們在accept函式建立socket流程裡提到的sock_init_data函式,在這個函數里已經把sk_data_ready設定成sock_def_readable函數了。它是預設的資料就緒處理函式。

當socket上資料就緒時候,核心將以sock_def_readable這個函式為入口,找到epoll_ctl新增socket時在其上設定的回撥函式ep_poll_callback。

圖解|深入揭秘epoll是如何實現IO多路複用的!

我們來詳細看下細節:

這裡的函式名其實都有迷惑人的地方。

wq_has_sleeper,對於簡單的recvfrom系統呼叫來說,確實是判斷是否有程序阻塞。但是對於epoll下的socket只是判斷等待佇列不為空,不一定有程序阻塞的。

wake_up_interruptible_sync_poll,只是會進入到socket等待佇列項上設定的回撥函式,並不一定有喚醒程序的操作。

那接下來就是我們重點看wake_up_interruptible_sync_poll。

我們看一下核心是怎麼找到等待佇列項裡註冊的回撥函式的。

接著進入__wake_up_common

在__wake_up_common中,選出等待佇列裡註冊某個元素curr,回撥其curr->func。回憶我們ep_insert呼叫的時候,把這個func設定成ep_poll_callback了。

(三)執行socket就緒回撥函式

在上一小節找到了socket等待佇列項裡註冊的函式ep_poll_callback,軟中斷接著就會呼叫它。

在ep_poll_callback根據等待任務佇列項上的額外的base指標可以找到epitem,進而也可以找到eventpoll物件。

首先它做的第一件事就是把自己的epitem新增到epoll的就緒佇列中。

接著它又會檢視eventpoll物件上的等待佇列裡是否有等待項(epoll_wait執行的時候會設定)。

如果沒執行軟中斷的事情就做完了。如果有等待項,那就查詢到等待項裡設定的回撥函式。

圖解|深入揭秘epoll是如何實現IO多路複用的!

呼叫:wake_up_locked()=>__wake_up_locked()=>__wake_up_common。

在__wake_up_common裡,呼叫curr->func。這裡的func是在epoll_wait是傳入的default_wake_function函式。

(四)執行epoll就緒通知

在default_wake_function中找到等待佇列項裡的程序描述符,然後喚醒之。

圖解|深入揭秘epoll是如何實現IO多路複用的!

原始碼如下:

等待佇列項curr->private指標是在epoll物件上等待而被阻塞掉的程序。

將epoll_wait程序推入可執行佇列,等待核心重新排程程序。然後epoll_wait對應的這個程序重新執行後,就從schedule恢復。

當程序醒來後,繼續從epoll_wait時暫停的程式碼繼續執行。把rdlist中就緒的事件返回給使用者程序。

從使用者角度來看,epoll_wait只是多等了一會兒而已,但執行流程還是順序的。

總結

我們來用一幅圖總結一下epoll的整個工作路程。

圖解|深入揭秘epoll是如何實現IO多路複用的!

其中軟中斷回撥的時候回撥函式也整理一下:sock_def_readable:sock物件初始化時設定的=>ep_poll_callback : epoll_ctl時新增到 socket上的=>default_wake_function: epoll_wait是設定到epoll上的。

總結下,epoll相關的函數里核心執行環境分兩部分:

使用者程序核心態。進行呼叫epoll_wait等函式時會將程序陷入核心態來執行。這部分程式碼負責檢視接收佇列,以及負責把當前程序阻塞掉,讓出CPU。

硬軟中斷上下文。在這些元件中,將包從網絡卡接收過來進行處理,然後放到socket的接收佇列。對於epoll來說,再找到socket關聯的epitem,並把它新增到epoll物件的就緒連結串列中。這個時候再捎帶檢查一下epoll上是否有被阻塞的程序,如果有喚醒之。

為了介紹到每個細節,本文涉及到的流程比較多,把阻塞都介紹進來了。但其實在實踐中,只要活兒足夠的多,epoll_wait根本都不會讓程序阻塞。使用者程序會一直幹活,一直幹活,直到epoll_wait裡實在沒活兒可乾的時候才主動讓出CPU。這就是epoll高效的地方所在!

恭喜你沒被核心原始碼勸退,一直能堅持到了現在。趕快給先自己鼓個掌,晚飯去加個雞腿!

當然網路程式設計剩下還有一些概念我們沒有講到,比如Reactor和Proactor等。不過相對核心來講,這些使用者層的技術相對就很簡單了。這些只是在討論當多程序一起配合工作時誰負責檢視IO事件、誰該負責計算、誰負責傳送和接收,僅僅是使用者程序的不同分工模式罷了。

作者簡介

張彥飛

騰訊開發工程師

騰訊開發工程師,有騰訊搜狗累計十多年的開發經驗,目前負責騰訊瀏覽器業務後端開發。