1. 程式人生 > 程式設計 >從硬體入手深入理解epoll 的本質

從硬體入手深入理解epoll 的本質

這篇文章從硬體開始說起,從根上理解資料傳輸的過程,還不錯,收藏起來。

原文連結

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

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

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

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

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

一、從網路卡接收資料說起

下邊是一個典型的計算機結構圖,計算機由 CPU、儲存器(記憶體)與網路介面等部件組成,瞭解 epoll 本質的第一步,要從硬體的角度看計算機怎樣接收網路資料。

下圖展示了網路卡接收資料的過程。

  • 在 ① 階段,網路卡收到網線傳來的資料;
  • 經過 ② 階段的硬體電路的傳輸;
  • 最終 ③ 階段將資料寫入到記憶體中的某個地址上。

這個過程涉及到 DMA 傳輸、IO 通路選擇等硬體有關的知識,但我們只需知道:網路卡會把接收到的資料寫入記憶體

網路卡接收資料的過程

通過硬體傳輸,網路卡接收的資料存放到記憶體中,作業系統就可以去讀取它們。

二、如何知道接收了資料?

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

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

一般而言,由硬體產生的訊號需要 CPU 立馬做出迴應,不然資料可能就丟失了,所以它的優先順序很高。CPU 理應中斷掉正在執行的程式,去做出響應;當 CPU 完成對硬體的響應後,再重新執行使用者程式。中斷的過程如下圖,它和函式呼叫差不多,只不過函式呼叫是事先定好位置,而中斷的位置由“訊號”決定。

中斷程式呼叫

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

現在可以回答“如何知道接收了資料?”這個問題了:當網路卡把資料寫入到記憶體後,網路卡向 CPU 發出一箇中斷訊號,作業系統便能得知有新資料到來,再通過網路卡中斷程式去處理資料。

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

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

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


//建立socket
int s = socket(AF_INET,SOCK_STREAM,0);   
//繫結
bind(s,...)
//監聽
listen(s,...)
//接受客戶端連線
int c = accept(s,...)
//接收客戶端資料
recv(c,...);
//將資料打印出來
printf(...)
複製程式碼

這是一段最基礎的網路程式設計程式碼,先新建 socket 物件,依次呼叫 bind、listen 與 accept,最後呼叫 recv 接收資料。recv 是個阻塞方法,當程式執行到 recv 時,它會一直等待,直到接收到資料才往下執行。

那麼阻塞的原理是什麼?

工作佇列

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

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

等待佇列

當程式 A 執行到建立 socket 的語句時,作業系統會建立一個由檔案系統管理的 socket 物件(如下圖)。這個 socket 物件包含了傳送緩衝區、接收緩衝區與等待佇列等成員。等待佇列是個非常重要的結構,它指向所有需要等待該 socket 事件的程式。

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

注:作業系統新增等待佇列只是添加了對這個“等待中”程式的引用,以便在接收到資料時獲取程式物件、將其喚醒,而非直接將程式管理納入自己之下。上圖為了方便說明,直接將程式掛到等待佇列之下。

喚醒程式

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

四、核心接收網路資料全過程

這一步,貫穿網路卡、中斷與程式排程的知識,敘述阻塞 recv 下,核心接收資料的全過程。

如下圖所示,程式在 recv 阻塞期間,計算機收到了對端傳送的資料(步驟①),資料經由網路卡傳送到記憶體(步驟②),然後網路卡通過中斷訊號通知 CPU 有資料到達,CPU 執行中斷程式(步驟③)。

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

核心接收資料全過程

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

喚醒程式

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

  • 其一,作業系統如何知道網路資料對應於哪個 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 收到資料,然後做出處理。

int s = socket(AF_INET,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]的資料處理
        }
    }}
複製程式碼

select 的流程

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

作業系統把程式 A 分別加入這三個 socket 的等待佇列中

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

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

sock2 接收到了資料,中斷程式喚起程式 A

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

將程式 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 阻塞程式。顯而易見地,效率就能得到提升。

相比 select,epoll 拆分了功能

為方便理解後續的內容,我們先了解一下 epoll 的用法。如下的程式碼中,先用 epoll_create 建立一個 epoll 物件 epfd,再通過 epoll_ctl 將需要監視的 socket 新增到 epfd 中,最後呼叫 epoll_wait 等待資料:

int s = socket(AF_INET,0);   
bind(s,...)
listen(s,...)

int epfd = epoll_create(...);
epoll_ctl(epfd,...); //將所有需要監聽的socket新增到epfd中

while(1){
    int n = epoll_wait(...)
    for(接收到資料的socket){
        //處理
    }
}
複製程式碼

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

###措施二:就緒列表

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

就緒列表示意圖

七、epoll 的原理與工作流程

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

建立 epoll 物件

如下圖所示,當某個程式呼叫 epoll_create 方法時,核心會建立一個 eventpoll 物件(也就是程式中 epfd 所代表的物件)。eventpoll 物件也是檔案系統中的一員,和 socket 一樣,它也會有等待佇列。

核心建立 eventpoll 物件

建立一個代表該 epoll 的 eventpoll 物件是必須的,因為核心要維護“就緒列表”等資料,“就緒列表”可以作為 eventpoll 的成員。

維護監視列表

建立 epoll 物件後,可以用 epoll_ctl 新增或刪除所要監聽的 socket。以新增 socket 為例,如下圖,如果通過 epoll_ctl 新增 sock1、sock2 和 sock3 的監視,核心會將 eventpoll 新增到這三個 socket 的等待佇列中。

新增所要監聽的 socket

當 socket 收到資料後,中斷程式會操作 eventpoll 物件,而不是直接操作程式。

接收資料

當 socket 收到資料後,中斷程式會給 eventpoll 的“就緒列表”新增 socket 引用。如下圖展示的是 sock2 和 sock3 收到資料後,中斷程式讓 rdlist 引用這兩個 socket。

給就緒列表新增引用

eventpoll 物件相當於 socket 和程式之間的中介,socket 的資料接收並不直接影響程式,而是通過改變 eventpoll 的就緒列表來改變程式狀態。

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

阻塞和喚醒程式

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

epoll_wait 阻塞程式

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

epoll 喚醒程式

八、epoll 的實現細節

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

此外,就緒佇列應該應使用什麼資料結構?eventpoll 應使用什麼資料結構來管理通過 epoll_ctl 新增或刪除的 socket?

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

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,結束此文。希望讀者能有所收穫。

oscimg.oschina.net/oscnet/7159…