epoll 的本質是什麼?

從事服務端開發,少不了要接觸網絡編程。epoll 作為 Linux 下高性能網絡服務器的必備技術至關重要,nginx、Redis、Skynet 和大部分遊戲服務器都使用到這一多路複用技術。

epoll 很重要,但是 epoll 與 select 的區別是什麼呢?epoll 高效的原因是什麼?

epoll 的本質是什麼?

網上雖然也有不少講解 epoll 的文章,但要麼是過於淺顯,或者陷入源碼解析,很少能有通俗易懂的。筆者於是決定編寫此文,讓缺乏專業背景知識的讀者也能夠明白 epoll 的原理。

文章核心思想是:要讓讀者清晰明白 epoll 為什麼性能好。

本文會從網卡接收數據的流程講起,串聯起 CPU 中斷、操作系統進程調度等知識;再一步步分析阻塞接收數據、select 到 epoll 的進化過程;最後探究 epoll 的實現細節。

一、從網卡接收數據說起

下邊是一個典型的計算機結構圖,計算機由 CPU、存儲器(內存)與網絡接口等部件組成,瞭解 epoll 本質的第一步,要從硬件的角度看計算機怎樣接收網絡數據。

epoll 的本質是什麼?

計算機結構圖(圖片來源:Linux內核完全註釋之微型計算機組成結構)

下圖展示了網卡接收數據的過程。

  • 在 ① 階段,網卡收到網線傳來的數據;
  • 經過 ② 階段的硬件電路的傳輸;
  • 最終 ③ 階段將數據寫入到內存中的某個地址上。

這個過程涉及到 DMA 傳輸、IO 通路選擇等硬件有關的知識,但我們只需知道:網卡會把接收到的數據寫入內存

epoll 的本質是什麼?

網卡接收數據的過程

通過硬件傳輸,網卡接收的數據存放到內存中,操作系統就可以去讀取它們。

二、如何知道接收了數據?

瞭解 epoll 本質的第二步,要從 CPU 的角度來看數據接收。理解這個問題,要先了解一個概念——中斷。

計算機執行程序時,會有優先級的需求。比如,當計算機收到斷電信號時,它應立即去保存數據,保存數據的程序具有較高的優先級(電容可以保存少許電量,供 CPU 運行很短的一小段時間)。

一般而言,由硬件產生的信號需要 CPU 立馬做出回應,不然數據可能就丟失了,所以它的優先級很高。CPU 理應中斷掉正在執行的程序,去做出響應;當 CPU 完成對硬件的響應後,再重新執行用戶程序。中斷的過程如下圖,它和函數調用差不多,只不過函數調用是事先定好位置,而中斷的位置由“信號”決定。

epoll 的本質是什麼?

中斷程序調用

以鍵盤為例,當用戶按下鍵盤某個按鍵時,鍵盤會給 CPU 的中斷引腳發出一個高電平,CPU 能夠捕獲這個信號,然後執行鍵盤中斷程序。下圖展示了各種硬件通過中斷與 CPU 交互的過程。

epoll 的本質是什麼?

CPU 中斷(圖片來源:net.pku.edu.cn)

現在可以回答“如何知道接收了數據?”這個問題了:當網卡把數據寫入到內存後,網卡向 CPU 發出一箇中斷信號,操作系統便能得知有新數據到來,再通過網卡中斷程序去處理數據。

三、進程阻塞為什麼不佔用 CPU 資源?

瞭解 epoll 本質的第三步,要從操作系統進程調度的角度來看數據接收。阻塞是進程調度的關鍵一環,指的是進程在等待某事件(如接收到網絡數據)發生之前的等待狀態,recv、select 和 epoll 都是阻塞方法。下邊分析一下進程阻塞為什麼不佔用 CPU 資源?

為簡單起見,我們從普通的 recv 接收開始分析,先看看下面代碼:

<code> 

int

s = socket(AF_INET, SOCK_STREAM,

0

);    bind(s, ...) listen(s, ...)

int

c = accept(s, ...) recv(c, ...);

printf

(...)/<code>

這是一段最基礎的網絡編程代碼,先新建 socket 對象,依次調用 bind、listen 與 accept,最後調用 recv 接收數據。recv 是個阻塞方法,當程序運行到 recv 時,它會一直等待,直到接收到數據才往下執行。

那麼阻塞的原理是什麼?

工作隊列

操作系統為了支持多任務,實現了進程調度的功能,會把進程分為“運行”和“等待”等幾種狀態。運行狀態是進程獲得 CPU 使用權,正在執行代碼的狀態;等待狀態是阻塞狀態,比如上述程序運行到 recv 時,程序會從運行狀態變為等待狀態,接收到數據後又變回運行狀態。操作系統會分時執行各個運行狀態的進程,由於速度很快,看上去就像是同時執行多個任務。

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

epoll 的本質是什麼?

工作隊列中有 A、B 和 C 三個進程

等待隊列

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

epoll 的本質是什麼?

創建 socket

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

epoll 的本質是什麼?

socket 的等待隊列

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

喚醒進程

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

四、內核接收網絡數據全過程

這一步,貫穿網卡、中斷與進程調度的知識,敘述阻塞 recv 下,內核接收數據的全過程。

如下圖所示,進程在 recv 阻塞期間,計算機收到了對端傳送的數據(步驟①),數據經由網卡傳送到內存(步驟②),然後網卡通過中斷信號通知 CPU 有數據到達,CPU 執行中斷程序(步驟③)。

此處的中斷程序主要有兩項功能,先將網絡數據寫入到對應 socket 的接收緩衝區裡面(步驟④),再喚醒進程 A(步驟⑤),重新將進程 A 放入工作隊列中。

epoll 的本質是什麼?

內核接收數據全過程

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

epoll 的本質是什麼?

喚醒進程

以上是內核接收數據全過程,這裡我們可能會思考兩個問題:

  • 其一,操作系統如何知道網絡數據對應於哪個 socket?
  • 其二,如何同時監視多個 socket 的數據?

第一個問題:因為一個 socket 對應著一個端口號,而網絡數據包中包含了 ip 和端口的信息,內核可以通過端口號找到對應的 socket。當然,為了提高處理速度,操作系統會維護端口號到 socket 的索引結構,以快速讀取。

第二個問題是多路複用的重中之重,也正是本文後半部分的重點。

五、同時監視多個 socket 的簡單方法

服務端需要管理多個客戶端連接,而 recv 只能監視單個 socket,這種矛盾下,人們開始尋找監視多個 socket 的方法。epoll 的要義就是高效地監視多個 socket

從歷史發展角度看,必然先出現一種不太高效的方法,人們再加以改進,正如 select 之於 epoll。

先理解不太高效的 select,才能夠更好地理解 epoll 的本質。

假如能夠預先傳入一個 socket 列表,如果列表中的 socket 都沒有數據,掛起進程,直到有一個 socket 收到數據,喚醒進程。這種方法很直接,也是 select 的設計思想。

為方便理解,我們先複習 select 的用法。在下邊的代碼中,先準備一個數組 fds,讓 fds 存放著所有需要監視的 socket。然後調用 select,如果 fds 中的所有 socket 都沒有數據,select 會阻塞,直到有一個 socket 接收到數據,select 返回,喚醒進程。用戶可以遍歷 fds,通過 FD_ISSET 判斷具體哪個 socket 收到數據,然後做出處理。

<code>

int

s

=

socket

(AF_INET, SOCK_STREAM,

0

);  

bind

(

s

, ...);

listen

(

s

, ...);

int

fds[] =  存放需要監聽的

socket

;

while

(

1

){    

int

n =

select

(..., fds, ...)    

for

(

int

i=

0

; i < fds.count; i++){        

if

(FD_ISSET(fds[i], ...)){            

//fds

[i]的數據處理         }     }}/<code>

select 的流程

select 的實現思路很直接,假如程序同時監視如下圖的 sock1、sock2 和 sock3 三個 socket,那麼在調用 select 之後,操作系統把進程 A 分別加入這三個 socket 的等待隊列中。

epoll 的本質是什麼?

操作系統把進程 A 分別加入這三個 socket 的等待隊列中

當任何一個 socket 收到數據後,中斷程序將喚起進程。下圖展示了 sock2 接收到了數據的處理流程:

注:recv 和 select 的中斷回調可以設置成不同的內容。

epoll 的本質是什麼?

sock2 接收到了數據,中斷程序喚起進程 A

所謂喚起進程,就是將進程從所有的等待隊列中移除,加入到工作隊列裡面,如下圖所示:

epoll 的本質是什麼?

將進程 A 從所有等待隊列中移除,再加入到工作隊列裡面

經由這些步驟,當進程 A 被喚醒後,它知道至少有一個 socket 接收了數據。程序只需遍歷一遍 socket 列表,就可以得到就緒的 socket。

這種簡單方式行之有效,在幾乎所有操作系統都有對應的實現。

但是簡單的方法往往有缺點,主要是:

其一,每次調用 select 都需要將進程加入到所有監視 socket 的等待隊列,每次喚醒都需要從每個隊列中移除。這裡涉及了兩次遍歷,而且每次都要將整個 fds 列表傳遞給內核,有一定的開銷。正是因為遍歷操作開銷大,出於效率的考量,才會規定 select 的最大監視數量,默認只能監視 1024 個 socket。

其二,進程被喚醒後,程序並不知道哪些 socket 收到數據,還需要遍歷一次。

那麼,有沒有減少遍歷的方法?有沒有保存就緒 socket 的方法?這兩個問題便是 epoll 技術要解決的

補充說明: 本節只解釋了 select 的一種情形。當程序調用 select 時,內核會先遍歷一遍 socket,如果有一個以上的 socket 接收緩衝區有數據,那麼 select 直接返回,不會阻塞。這也是為什麼 select 的返回值有可能大於 1 的原因之一。如果沒有 socket 有數據,進程才會阻塞。

六、epoll 的設計思路

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

措施一:功能分離

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

epoll 的本質是什麼?

相比 select,epoll 拆分了功能

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

<code>

int

s

=

socket

(AF_INET, SOCK_STREAM,

0

);

bind

(

s

, ...)

listen

(

s

, ...)

int

epfd = epoll_create(...); epoll_ctl(epfd, ...);

//

將所有需要監聽的

socket

添加到epfd中

while

(

1

){

int

n = epoll_wait(...)

for

(接收到數據的

socket

){

//

處理 } }/<code>

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

措施二:就緒列表

select 低效的另一個原因在於程序不知道哪些 socket 收到數據,只能一個個遍歷。如果內核維護一個“就緒列表”,引用收到數據的 socket,就能避免遍歷。如下圖所示,計算機共有三個 socket,收到數據的 sock2 和 sock3 被就緒列表 rdlist 所引用。當進程被喚醒後,只要獲取 rdlist 的內容,就能夠知道哪些 socket 收到數據。

epoll 的本質是什麼?

就緒列表示意圖

七、epoll 的原理與工作流程

本節會以示例和圖表來講解 epoll 的原理和工作流程。

創建 epoll 對象

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

epoll 的本質是什麼?

內核創建 eventpoll 對象

創建一個代表該 epoll 的 eventpoll 對象是必須的,因為內核要維護“就緒列表”等數據,“就緒列表”可以作為 eventpoll 的成員。

維護監視列表

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

epoll 的本質是什麼?

添加所要監聽的 socket

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

接收數據

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

epoll 的本質是什麼?

給就緒列表添加引用

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

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

阻塞和喚醒進程

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

epoll 的本質是什麼?

epoll_wait 阻塞進程

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

epoll 的本質是什麼?

epoll 喚醒進程

八、epoll 的實現細節

至此,相信讀者對 epoll 的本質已經有一定的瞭解。但我們還需要知道 eventpoll 的數據結構是什麼樣子?

此外,就緒隊列應該應使用什麼數據結構?eventpoll 應使用什麼數據結構來管理通過 epoll_ctl 添加或刪除的 socket?

如下圖所示,eventpoll 包含了 lock、mtx、wq(等待隊列)與 rdlist 等成員,其中 rdlist 和 rbr 是我們所關心的。

epoll 的本質是什麼?

epoll 原理示意圖,圖片來源:《深入理解Nginx:模塊開發與架構解析(第二版)》,陶輝

就緒列表的數據結構

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

程序可能隨時調用 epoll_ctl 添加監視 socket,也可能隨時刪除。當刪除時,若該 socket 已經存放在就緒列表中,它也應該被移除。所以就緒列表應是一種能夠快速插入和刪除的數據結構。

雙向鏈表就是這樣一種數據結構,epoll 使用雙向鏈表來實現就緒隊列(對應上圖的 rdllist)。

索引結構

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

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

九、小結

epoll 在 select 和 poll 的基礎上引入了 eventpoll 作為中間層,使用了先進的數據結構,是一種高效的多路複用技術。這裡也以表格形式簡單對比一下 select、poll 與 epoll,結束此文。希望讀者能有所收穫。

epoll 的本質是什麼?

原文:https://my.oschina.net/editorial-story/blog/3052308


分享到:


相關文章: