深入學習 IO 多路複用 select-poll-epoll 實現原理

作者:mingguangtu,騰訊 IEG 後臺開發工程師

select/poll/epoll 是 Linux 服務器提供的三種處理高併發網絡請求的 IO 多路複用技術,是個老生常談又不容易弄清楚其底層原理的知識點,本文打算深入學習下其實現機制。

Linux 服務器處理網絡請求有三種機制,select、poll、epoll,本文打算深入學習下其實現原理。

喫水不忘挖井人,最近兩週花了些時間學習了張彥飛大佬的文章 圖解 | 深入揭祕 epoll 是如何實現 IO 多路複用的其他文章 ,及出版的書籍《深入理解 Linux 網絡》,對阻塞 IO、多路複用、epoll 等的實現原理有了一定的瞭解;飛哥的文章描述底層源碼邏輯比較清晰,就是有時候歸納總結事情本質的抽象程度不夠,涉及內核源碼細節的講述較多,會讓讀者產生一定的學習成本,本文希望在這方面改進一下。

0. 結論

本文其他的內容主要是得出了下面幾個結論:

  1. 服務器要接收客戶端的數據,要建立 socket 內核結構,主要包含兩個重要的數據結構,(進程)等待隊列,和**(數據)接收隊列**,socket 在進程中作爲一個文件,可以用文件描述符 fd 來表示,爲了方便理解,本文中, socket 內核對象 ≈ fd 文件描述符 ≈ TCP 連接;

  2. 阻塞 IO 的主要邏輯是:服務端和客戶端建立了連接 socket 後,服務端的用戶進程通過 recv 函數接收數據時,如果數據沒有到達,則當前的用戶進程的進程描述符和回調函數會封裝到一個進程等待項中,加入到 socket 的進程等待隊列中;如果連接上有數據到達網卡,由網卡將數據通過 DMA 控制器拷貝到內核內存的 RingBuffer 中,並向 CPU 發出硬中斷,然後,CPU 向內核中斷進程 ksoftirqd 發出軟中斷信號,內核中斷進程 ksoftirqd 將內核內存的 RingBuffer 中的數據根據數據報文的 IP 和端口號,將其拷貝到對應 socket 的數據接收隊列中,然後通過 socket 的進程等待隊列中的回調函數,喚醒要處理該數據的用戶進程;

  3. 阻塞 IO 的問題是:一次數據到達會進行兩次進程切換,一次數據讀取有兩處阻塞,單進程對單連接;

  4. 非阻塞 IO 模型解決了 “兩次進程切換,兩處阻塞,單進程對單連接” 中的 “兩處阻塞” 問題,將 “兩處阻塞” 變成了 “一處阻塞”,但依然存在 “兩次進程切換,一處阻塞,單進程對單連接” 的問題;

  5. 用一個進程監聽多個連接的 IO 多路複用技術解決了 “兩次進程切換,一處阻塞,單進程對單連接” 中的 “兩次進程切換,單進程對單連接”,剩下了 “一處阻塞”,這是 Linux 中同步 IO 都會有的問題,因爲 Linux 沒有提供異步 IO 實現;

  6. Linux 的 IO 多路複用用三種實現:select、poll、epoll。select 的問題是:

a)調用 select 時會陷入內核,這時需要將參數中的 fd_set 從用戶空間拷貝到內核空間,高併發場景下這樣的拷貝會消耗極大資源;(epoll 優化爲不拷貝)

b)進程被喚醒後,不知道哪些連接已就緒即收到了數據,需要遍歷傳遞進來的所有 fd_set 的每一位,不管它們是否就緒;(epoll 優化爲異步事件通知)

c)select 只返回就緒文件的個數,具體哪個文件可讀還需要遍歷;(epoll 優化爲只返回就緒的文件描述符,無需做無效的遍歷)

d)同時能夠監聽的文件描述符數量太少,是 1024 或 2048;(poll 基於鏈表結構解決了長度限制)

  1. poll 只是基於鏈表的結構解決了最大文件描述符限制的問題,其他 select 性能差的問題依然沒有解決;終極的解決方案是 epoll,解決了 select 的前三個缺點;

  2. epoll 的實現原理看起來很複雜,其實很簡單,注意兩個回調函數的使用:數據到達 socket 的等待隊列時,通過回調函數 ep_poll_callback 找到 eventpoll 對象中紅黑樹的 epitem 節點,並將其加入就緒列隊 rdllist,然後通過回調函數 default_wake_function 喚醒用戶進程 ,並將 rdllist 傳遞給用戶進程,讓用戶進程準確讀取就緒的 socket 的數據。這種回調機制能夠定向準確的通知程序要處理的事件,而不需要每次都循環遍歷檢查數據是否到達以及數據該由哪個進程處理,日常開發中可以學習借鑑下這種思想。

1. Linux 怎樣處理網絡請求

1.1 阻塞 IO

要講 IO 多路複用,最好先把傳統的同步阻塞的網絡 IO 的交互方式剖析清楚。

如果客戶端想向 Linux 服務器發送一段數據 ,C 語言的實現方式是:

int main()
{
     int fd = socket();      // 創建一個網絡通信的socket結構體
     connect(fd, ...);       // 通過三次握手跟服務器建立TCP連接
     send(fd, ...);          // 寫入數據到TCP連接
     close(fd);              // 關閉TCP連接
}

服務端通過如下 C 代碼接收客戶端的連接和發送的數據:

int main()
{
     fd = socket(...);        // 創建一個網絡通信的socket結構體
     bind(fd, ...);           // 綁定通信端口
     listen(fd, 128);         // 監聽通信端口,判斷TCP連接是否可以建立
     while(1) {
         connfd = accept(fd, ...);              // 阻塞建立連接
         int n = recv(connfd, buf, ...);        // 阻塞讀數據
         doSomeThing(buf);                      // 利用讀到的數據做些什麼
         close(connfd);                         // 關閉連接,循環等待下一個連接
    }
}

把服務端處理請求的細節展開,得到如下圖所示的同步阻塞網絡 IO 的數據接收流程:

圖 1.1 同步阻塞網絡 IO 的數據接收流程

主要步驟是:

1)服務端通過 socket() 函數陷入內核態進行 socket 系統調用,該內核函數會創建 socket 內核對象,主要有兩個重要的結構體,(進程)等待隊列,和(**數據)接收隊列,**爲了方便理解,等待隊列前可以加上進程二字,其實不加更準確,接收隊列同樣;進程等待隊列,存放了服務端的用戶進程 A 的進程描述符和回調函數;socket 的數據接收隊列,存放網卡接收到的該 socket 要處理的數據;

2)進程 A 調用 recv() 函數接收數據,會進入到 recvfrom() 系統調用函數,發現 socket 的數據等待隊列沒有它要接收的數據到達時,進程 A 會讓出 CPU,進入阻塞狀態,進程 A 的進程描述符和它被喚醒用到的回調函數 callback func 會組成一個結構體叫等待隊列項,放入 socket 的進程等待隊列;

3)客戶端的發送數據到達服務端的網卡;

4)網卡首先會將網絡傳輸過來的數據通過 DMA 控制程序複製到內存環形緩衝區 RingBuffer 中;

5)網卡向 CPU 發出硬中斷

6)CPU 收到了硬中斷後,爲了避免過度佔用 CPU 處理網絡設備請求導致其他設備如鼠標和鍵盤的消息無法被處理,會調用網絡驅動註冊的中斷處理函數,進行簡單快速處理後向內核中斷進程 ksoftirqd 發出軟中斷,就釋放 CPU,由軟中斷進程處理複雜耗時的網絡設備請求邏輯;

7)內核中斷進程 ksoftirqd 收到軟中斷信號後,會將網卡複製到內存的數據,根據數據報文的 IP 和端口號,將其拷貝到對應 socket 的接收隊列;

8)內核中斷進程 ksoftirqd 根據 socket 的數據接收隊列的數據,通過進程等待隊列中的回調函數,喚醒要處理該數據的進程 A,進程 A 會進入 CPU 的運行隊列,等待獲取 CPU 執行數據處理邏輯;

9)進程 A 獲取 CPU 後,會回到之前調用 recvfrom() 函數時阻塞的位置繼續執行,這時發現 socket 內核空間的等待隊列上有數據,會在內核態將內核空間的 socket 等待隊列的數據拷貝到用戶空間,然後纔會回到用戶態執行進程的用戶程序,從而真的解除阻塞**;**

用戶進程 A 在調用 recvfrom() 系統函數時,有兩個階段都是等待的:在數據沒有準備好的時候,進程 A 等待內核 socket 準備好數據;內核準備好數據後,進程 A 繼續等待內核將 socket 等待隊列的數據拷貝到自己的用戶緩衝區;在內核完成數據拷貝到用戶緩衝區後,進程 A 纔會從 recvfrom() 系統調用中返回,並解除阻塞狀態。整體流程如下:

圖 1.2 阻塞 IO 模型

在 IO 阻塞邏輯中,存在下面三個問題:

  1. 進程在 recv 的時候大概率會被阻塞掉,導致一次進程切換;

  2. 當 TCP 連接上的數據到達服務端的網卡、並從網卡複製到內核空間 socket 的數據等待隊列時,進程會被喚醒,又是一次進程切換;並且,在用戶進程繼續執行完 recvfrom() 函數系統調用,將內核空間的數據拷貝到了用戶緩衝區後,用戶進程纔會真正拿到所需的數據進行處理;

  3. 一個進程同時只能等待一條連接,如果有很多併發,則需要很多進程;

總結:一次數據到達會進行兩次進程切換,一次數據讀取有兩處阻塞,單進程對單連接

1.2 非阻塞 IO

爲了解決同步阻塞 IO 的問題,操作系統提供了非阻塞的 recv() 函數,這個函數的效果是:如果沒有數據從網卡到達內核 socket 的等待隊列時,系統調用會直接返回,而不是阻塞的等待。

如果我們要產生一個非阻塞的 socket,在 C 語言中如下代碼所示:

// 創建socket
int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
...
// 更改socket爲nonblock
fcntl(sock_fd, F_SETFL, fdflags | O_NONBLOCK);
// connect
....
while(1)  {
    int recvlen = recv(sock_fd, recvbuf, RECV_BUF_SIZE) ;
    ......
}
...

非阻塞 IO 模型如下圖所示:

圖 1.3 非阻塞 IO 模型

從上圖中,我們知道,非阻塞 IO,是將等待數據從網卡到達 socket 內核空間這一部分變成了非阻塞的,用戶進程調用 recvfrom() 會重複發送請求檢查數據是否到達內核空間,如果沒有到,則立即返回,不會阻塞。不過,當數據已經到達內核空間的 socket 的等待隊列後,用戶進程依然要等待 recvfrom() 函數將數據從內核空間拷貝到用戶空間,纔會從 recvfrom() 系統調用函數中返回。

非阻塞 IO 模型解決了 “兩次進程切換,兩處阻塞,單進程對單連接” 中的 “兩處阻塞” 問題,將 “兩處阻塞” 變成了 “一處阻塞”,但依然存在 “兩次進程切換,一處阻塞,單進程對單連接” 的問題。

1.3 IO 多路複用

要解決 “兩次進程切換,單進程對單連接” 的問題,服務器引入了 IO 多路複用技術,通過一個進程處理多個 TCP 連接,不僅降低了服務器處理網絡請求的進程數,而且不用在每個連接的數據到達時就進行進程切換,進程可以一直運行並只處理有數據到達的連接,當然,如果要監聽的所有連接都沒有數據到達,進程還是會進入阻塞狀態,直到某個連接有數據到達時被回調函數喚醒。

IO 多路複用模型如下圖所示:

圖 1.4 IO 多路複用模型

從上圖可知,系統調用 select 函數阻塞執行並返回數據就緒的連接個數,然後調用 recvfrom() 函數將到達內核空間的數據拷貝到用戶空間,儘管這兩個階段都是阻塞的,但是由於只會處理有數據到達的連接,整體效率會有極大的提升。

到這裏,阻塞 IO 模型的 “兩次進程切換,兩處阻塞,單進程對單連接” 問題,通過非阻塞 IO 和多路複用技術,就只剩下了 “一處阻塞” 這個問題,即 Linux 服務器上用戶進程一定要等待數據從內核空間拷貝到用戶空間,如果這個步驟也變成非阻塞的,也就是進程調用 recvfrom 後立刻返回,內核自行去準備好數據並將數據從內核空間拷貝到用戶空間、再 notify 通知用戶進程去讀取數據,那就是 IO 異步調用,不過,Linux 沒有提供異步 IO 的實現,真正意義上的網絡異步 IO 是 Windows 下的 IOCP(IO 完成端口)模型,這裏就不探討了。

2. 詳解 select、poll、epoll 實現原理

2.1 select 實現原理

select 函數定義

Linux 提供的 select 函數的定義如下:

int select(
    int nfds,                     // 監控的文件描述符集裏最大文件描述符加1
    fd_set *readfds,              // 監控有讀數據到達文件描述符集合,引用類型的參數
    fd_set *writefds,             // 監控寫數據到達文件描述符集合,引用類型的參數
    fd_set *exceptfds,            // 監控異常發生達文件描述符集合,引用類型的參數
    struct timeval *timeout);     // 定時阻塞監控時間

readfds、writefds、errorfds 是三個文件描述符集合。select 會遍歷每個集合的前 nfds 個描述符,分別找到可以讀取、可以寫入、發生錯誤的描述符,統稱爲 “就緒” 的描述符。然後用找到的子集替換這三個引用參數中的對應集合,返回所有就緒描述符的數量。

timeout 參數表示調用 select 時的阻塞時長。如果所有 fd 文件描述符都未就緒,就阻塞調用進程,直到某個描述符就緒,或者阻塞超過設置的 timeout 後,返回。如果 timeout 參數設爲 NULL,會無限阻塞直到某個描述符就緒;如果 timeout 參數設爲 0,會立即返回,不阻塞。

文件描述符 fd

文件描述符(file descriptor)是一個非負整數,從 0 開始。進程使用文件描述符來標識一個打開的文件。Linux 中一切皆文件。

系統爲每一個進程維護了一個文件描述符表,表示該進程打開文件的記錄表,而文件描述符實際上就是這張表的索引。每個進程默認都有 3 個文件描述符:0 (stdin)、1 (stdout)、2 (stderr)。

socket

socket 可以用於同一臺主機的不同進程間的通信,也可以用於不同主機間的通信。操作系統將 socket 映射到進程的一個文件描述符上,進程就可以通過讀寫這個文件描述符來和遠程主機通信。

socket 是進程間通信規則的高層抽象,而 fd 提供的是底層的具體實現。socket 與 fd 是一一對應的。通過 socket 通信,實際上就是通過文件描述符 fd 讀寫文件。

本文中,爲了方便理解,可以認爲 socket 內核對象 ≈ fd 文件描述符 ≈ TCP 連接。

fd_set 文件描述符集合

select 函數參數中的 fd_set 類型表示文件描述符的集合。

由於文件描述符 fd 是一個從 0 開始的無符號整數,所以可以使用 fd_set 的二進制每一位來表示一個文件描述符。某一位爲 1,表示對應的文件描述符已就緒。比如比如設 fd_set 長度爲 1 字節,則一個 fd_set 變量最大可以表示 8 個文件描述符。當 select 返回 fd_set = 00010011 時,表示文件描述符 1、2、5 已經就緒。

fd_set 的 API

fd_set 的使用涉及以下幾個 API:

#include <sys/select.h>
int FD_ZERO(int fd, fd_set *fdset);  // 將 fd_set 所有位置 0
int FD_CLR(int fd, fd_set *fdset);   // 將 fd_set 某一位置 0
int FD_SET(int fd, fd_set *fd_set);  // 將 fd_set 某一位置 1
int FD_ISSET(int fd, fd_set *fdset); // 檢測 fd_set 某一位是否爲 1

select 監聽多個連接的用法

服務端使用 select 監控多個連接的 C 代碼是:

#define MAXCLINE 5       // 連接隊列中的個數
int fd[MAXCLINE];        // 連接的文件描述符隊列

int main(void)
{
      sock_fd = socket(AF_INET,SOCK_STREAM,0)          // 建立主機間通信的 socket 結構體
      .....
      bind(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr);         // 綁定socket到當前服務器
      listen(sock_fd, 5);  // 監聽 5 個TCP連接

      fd_set fdsr;         // bitmap類型的文件描述符集合,01100 表示第1、2位有數據到達
      int max;

      for(i = 0; i < 5; i++)
      {
          .....
          fd[i] = accept(sock_fd, (struct sockaddr *)&client_addr, &sin_size);   // 跟 5 個客戶端依次建立 TCP 連接,並將連接放入 fd 文件描述符隊列
      }

      while(1)               // 循環監聽連接上的數據是否到達
      {
        FD_ZERO(&fdsr);      // 對 fd_set 即 bitmap 類型進行復位,即全部重置爲0

        for(i = 0; i < 5; i++)
        {
             FD_SET(fd[i]&fdsr);      // 將要監聽的TCP連接對應的文件描述符所在的bitmap的位置置1,比如 0110010110 表示需要監聽第 1、2、5、7、8個文件描述符對應的 TCP 連接
        }

        ret = select(max + 1, &fdsr, NULL, NULL, NULL);  // 調用select系統函數進入內核檢查哪個連接的數據到達

        for(i=0;i<5;i++)
        {
            if(FD_ISSET(fd[i]&fdsr))      // fd_set中爲1的位置表示的連接,意味着有數據到達,可以讓用戶進程讀取
            {
                ret = recv(fd[i], buf,sizeof(buf), 0);
                ......
            }
        }
  }

從註釋中,我們可以看到,在一個進程中使用 select 監控多個連接的主要步驟是:

1)調用 socket() 函數建立主機間通信的 socket 結構體,bind() 綁定 socket 到當前服務器,listen() 監聽五個 TCP 連接;

2)調用 accept() 函數建立和 5 個客戶端的 TCP 連接,並把連接的文件描述符放入 fd 文件描述符隊列;

3) 定義一個 fd_set 類型的變量 fdsr;

4)調用 FD_ZERO,將 fdsr 所有位置 0;

5)調用 FD_SET,將 fdsr 要監聽的幾個文件描述符的位置 1,表示要監聽這幾個文件描述符指向的連接;

6)調用 select() 函數,並將 fdsr 參數傳遞給 select;

7)select 會將 fdsr 中就緒的位置 1,未就緒的位置 0,返回就緒的文件描述符的數量;

8)當 select 返回後,調用 FD_ISSET 檢測哪些位爲 1,表示對應文件描述符對應的連接的數據已經就緒,可以調用 recv 函數讀取該連接的數據了。

select 的執行過程

在服務器進程 A 啓動的時候,要監聽的連接的 socket 文件描述符是 3、4、5,如果這三個連接均沒有數據到達網卡,則進程 A 會讓出 CPU,進入阻塞狀態,同時會將進程 A 的進程描述符和被喚醒時用到的回調函數組成等待隊列項加入到 socket 對象 3、4、5 的進程等待隊列中,注意,這時 select 調用時,fdsr 文件描述符集會從用戶空間拷貝到內核空間,如下圖所示:

圖 2.1 select 進程啓動時,沒有數據到達網卡

當網卡接收到數據,然後網卡通過中斷信號通知 CPU 有數據到達,執行中斷程序,中斷程序主要做了兩件事:

1)將網絡數據寫入到對應 socket 的數據接收隊列裏面;

2)喚醒隊列中的等待進程 A,重新將進程 A 放入 CPU 的運行隊列中;

假設連接 3、5 有數據到達網卡,注意,這時 select 調用結束時,fdsr 文件描述符集會從內核空間拷貝到用戶空間:

圖 2.2 select 進程有數據到達時,會通過回調函數喚醒進程進行數據的讀取

select 的缺點

從上面兩圖描述的執行過程,可以發現 select 實現多路複用有以下缺點:

  1. 性能開銷大

1)調用 select 時會陷入內核,這時需要將參數中的 fd_set 從用戶空間拷貝到內核空間,select 執行完後,還需要將 fd_set 從內核空間拷貝回用戶空間,高併發場景下這樣的拷貝會消耗極大資源;(epoll 優化爲不拷貝)

2)進程被喚醒後,不知道哪些連接已就緒即收到了數據,需要遍歷傳遞進來的所有 fd_set 的每一位,不管它們是否就緒;(epoll 優化爲異步事件通知)

3)select 只返回就緒文件的個數,具體哪個文件可讀還需要遍歷;(epoll 優化爲只返回就緒的文件描述符,無需做無效的遍歷)

  1. 同時能夠監聽的文件描述符數量太少。受限於 sizeof(fd_set) 的大小,在編譯內核時就確定了且無法更改。一般是 32 位操作系統是 1024,64 位是 2048。(poll、epoll 優化爲適應鏈表方式)

第 2 個缺點被 poll 解決,第 1 個性能差的缺點被 epoll 解決。

2.2 poll 實現原理

和 select 類似,只是描述 fd 集合的方式不同,poll 使用 pollfd 結構而非 select 的 fd_set 結構。

struct pollfd {
    int fd;           // 要監聽的文件描述符
    short events;     // 要監聽的事件
    short revents;    // 文件描述符fd上實際發生的事件
};

管理多個描述符也是進行輪詢,根據描述符的狀態進行處理,但 poll 無最大文件描述符數量的限制因其基於鏈表存儲

select 和 poll 在內部機制方面並沒有太大的差異。相比於 select 機制,poll 只是取消了最大監控文件描述符數限制,並沒有從根本上解決 select 存在的問題。

2.3 epoll 實現原理

epoll 是對 select 和 poll 的改進,解決了 “性能開銷大” 和“文件描述符數量少”這兩個缺點,是性能最高的多路複用實現方式,能支持的併發量也是最大。

epoll 的特點是:

1)使用紅黑樹存儲一份文件描述符集合,每個文件描述符只需在添加時傳入一次,無需用戶每次都重新傳入;—— 解決了 select 中 fd_set 重複拷貝到內核的問題

2)通過異步 IO 事件找到就緒的文件描述符,而不是通過輪詢的方式;

3)使用隊列存儲就緒的文件描述符,且會按需返回就緒的文件描述符,無須再次遍歷;

epoll 的基本用法是:

int main(void)
{
      struct epoll_event events[5];
      int epfd = epoll_create(10);         // 創建一個 epoll 對象
      ......
      for(i = 0; i < 5; i++)
      {
          static struct epoll_event ev;
          .....
          ev.data.fd = accept(sock_fd, (struct sockaddr *)&client_addr, &sin_size);
          ev.events = EPOLLIN;
          epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);  // 向 epoll 對象中添加要管理的連接
      }

      while(1)
      {
         nfds = epoll_wait(epfd, events, 5, 10000);   // 等待其管理的連接上的 IO 事件

         for(i=0; i<nfds; i++)
         {
             ......
             read(events[i].data.fd, buff, MAXBUF)
         }
  }

主要涉及到三個函數:

int epoll_create(int size);   // 創建一個 eventpoll 內核對象

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);   // 將連接到socket對象添加到 eventpoll 對象上,epoll_event是要監聽的事件

int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);      // 等待連接 socket 的數據是否到達

epoll_create

epoll_create 函數會創建一個 struct eventpoll 的內核對象,類似 socket,把它關聯到當前進程的已打開文件列表中。

eventpoll 主要包含三個字段:

struct eventpoll {
 wait_queue_head_t wq;      // 等待隊列鏈表,存放阻塞的進程

 struct list_head rdllist;  // 數據就緒的文件描述符都會放到這裏

 struct rb_root rbr;        // 紅黑樹,管理用戶進程下添加進來的所有 socket 連接
        ......
}

wq:等待隊列,如果當前進程沒有數據需要處理,會把當前進程描述符和回調函數 default_wake_functon 構造一個等待隊列項,放入當前 wq 對待隊列,軟中斷數據就緒的時候會通過 wq 來找到阻塞在 epoll 對象上的用戶進程。

rbr:一棵紅黑樹,管理用戶進程下添加進來的所有 socket 連接。

rdllist:就緒的描述符的鏈表。當有的連接數據就緒的時候,內核會把就緒的連接放到 rdllist 鏈表裏。這樣應用進程只需要判斷鏈表就能找出就緒進程,而不用去遍歷整棵樹。

eventpoll 的結構如圖 2.3 所示:

圖 2.3 eventpoll 對象的結構

epoll_ctl

epoll_ctl 函數主要負責把服務端和客戶端建立的 socket 連接註冊到 eventpoll 對象裏,會做三件事:

1)創建一個 epitem 對象,主要包含兩個字段,分別存放 socket fd 即連接的文件描述符,和所屬的 eventpoll 對象的指針;

2)將一個數據到達時用到的回調函數添加到 socket 的進程等待隊列中,注意,跟第 1.1 節的阻塞 IO 模式不同的是,這裏添加的 socket 的進程等待隊列結構中,只有回調函數,沒有設置進程描述符,因爲在 epoll 中,進程是放在 eventpoll 的等待隊列中,等待被 epoll_wait 函數喚醒,而不是放在 socket 的進程等待隊列中;

3)將第 1)步創建的 epitem 對象插入紅黑樹;

圖 2.4 epoll_ctl 執行結果

epoll_wait

epoll_wait 函數的動作比較簡單,檢查 eventpoll 對象的就緒的連接 rdllist 上是否有數據到達,如果沒有就把當前的進程描述符添加到一個等待隊列項裏,加入到 eventpoll 的進程等待隊列裏,然後阻塞當前進程,等待數據到達時通過回調函數被喚醒。

當 eventpoll 監控的連接上有數據到達時,通過下面幾個步驟喚醒對應的進程處理數據:

1)socket 的數據接收隊列有數據到達,會通過進程等待隊列的回調函數 ep_poll_callback 喚醒紅黑樹中的節點 epitem;

2)ep_poll_callback 函數將有數據到達的 epitem 添加到 eventpoll 對象的就緒隊列 rdllist 中;

3)ep_poll_callback 函數檢查 eventpoll 對象的進程等待隊列上是否有等待項,通過回調函數 default_wake_func 喚醒這個進程,進行數據的處理;

4)當進程醒來後,繼續從 epoll_wait 時暫停的代碼繼續執行,把 rdlist 中就緒的事件返回給用戶進程,讓用戶進程調用 recv 把已經到達內核 socket 等待隊列的數據拷貝到用戶空間使用。

圖 2.5 epoll_wait 在有數據到達 socket 時、依次通過兩個回調函數喚醒進程

3. 總結

從阻塞 IO 到 epoll 的實現中,我們可以看到 wake up 回調函數機制被頻繁的使用,至少有三處地方:一是阻塞 IO 中數據到達 socket 的等待隊列時,通過回調函數喚醒進程,二是 epoll 中數據到達 socket 的等待隊列時,通過回調函數 ep_poll_callback 找到 eventpoll 中紅黑樹的 epitem 節點,並將其加入就緒列隊 rdllist,三是通過回調函數 default_wake_func 喚醒用戶進程 ,並將 rdllist 傳遞給用戶進程,讓用戶進程準確讀取數據 。從中可知,這種回調機制能夠定向準確的通知程序要處理的事件,而不需要每次都循環遍歷檢查數據是否到達以及數據該由哪個進程處理,提高了程序效率,在日常的業務開發中,我們也可以借鑑下這一機制。

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