IO多路複用完全解析

蟬沐風發表於2023-01-31

上一篇文章以近乎囉嗦的方式詳細描述了BIO與非阻塞IO的各種細節。如果各位還沒有讀過這篇文章,強烈建議先閱讀一下,然後再來看本篇,因為邏輯關係是層層遞進的。

1. 多路複用的誕生

非阻塞IO使用一個執行緒就可以處理所有socket,但是付出的代價是必須頻繁呼叫系統呼叫來輪詢每一個socket的資料,這種輪詢太耗費效能,而且大部分輪詢都是空輪詢。

我們希望有個元件能同時監控多個socket,並在socket把資料準備好的時候告訴程式哪些socket已“就緒”,然後程式只對就緒的socket進行資料讀寫。

Java在JDK1.4的時候引入了NIO,並提供了Selector這個元件來實現這個功能。

2. NIO

在引入NIO程式碼之前,有點事情需要解釋一下。

就緒”這個詞用得有點曖昧,因為不同的socket對就緒有不同的表達。比如對於監聽socket而言,如果有客戶端對其進行了連線,就說明處於就緒狀態,它並不像連線socket一樣,需要對資料的收發進行處理;相反,連線socket的就緒狀態就至少包含了資料準備好讀is ready for reading)與資料準備好寫is ready for writing)這兩種。

因此,可以想象,我們讓Selector對多個socket進行監聽時,必然需要告訴Selector,我們對哪些socket的哪些事件感興趣。這個動作叫註冊。

接下來看程式碼。

public class NIOServer {

    static Selector selector;

    public static void main(String[] args) {

        try {
            // 獲得selector多路複用器
            selector = Selector.open();

            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
            // 監聽socket的accept將不會阻塞
            serverSocketChannel.configureBlocking(false);
            serverSocketChannel.socket().bind(new InetSocketAddress(8099));

            // 需要把監聽socket註冊到多路複用器上,並告訴selector,需要關注監聽socket的OP_ACCEPT事件
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

            while (true) {
                // 該方法會阻塞
                selector.select();

                // 得到所有就緒的事件,事件被封裝成了SelectionKey
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                Iterator<SelectionKey> iterator = selectionKeys.iterator();
                while (iterator.hasNext()) {
                    SelectionKey key = iterator.next();
                    iterator.remove();
                    if (key.isAcceptable()) {
                        handleAccept(key);
                    } else if (key.isReadable()) {
                        handleRead(key);
                    } else if (key.isWritable()) {
                        //傳送資料
                    }
                }

            }

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
		
    // 處理「讀」事件的業務邏輯
    private static void handleRead(SelectionKey key) {
        SocketChannel socketChannel = (SocketChannel) key.channel();
        ByteBuffer allocate = ByteBuffer.allocate(1024);
        try {
            socketChannel.read(allocate);
            System.out.println("From Client:" + new String(allocate.array()));
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

  	// 處理「連線」事件的業務邏輯
    private static void handleAccept(SelectionKey key) {
        ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();

        try {
            // socketChannel一定是非空,並且這裡不會阻塞
            SocketChannel socketChannel = serverSocketChannel.accept();
            // 將連線socket的讀寫設定為非阻塞
            socketChannel.configureBlocking(false);
            socketChannel.write(ByteBuffer.wrap("Hello Client, I am Server!".getBytes()));
            // 註冊連線socket的「讀事件」
            socketChannel.register(selector, SelectionKey.OP_READ);
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

}

我們首先使用Selector.open();得到了selector這個多路複用物件;然後在服務端建立了監聽socket,並將其設定為非阻塞,最後將監聽socket註冊到selector多路複用器上,並告訴selector,如果監聽socket有OP_ACCEPT事件發生的話就要告訴我們。

我們在while迴圈中呼叫selector.select();方法,程式將會阻塞在該方法上,直到註冊在selector上的任意一個socket有事件發生為止,才會返回。如果不信的話可以在selector.select();的下一行打個斷點,debug模式執行後,在沒有客戶端連線的情況下斷點不會被觸發。

select()返回,意味著有一個或多個socket已經處於就緒狀態,我們使用Set<SelectionKey>來儲存所有事件,SelectionKey封裝了就緒的事件,我們迴圈每個事件,根據不同的事件型別進行不同的業務邏輯處理。

OP_READ事件就緒的話,我們就準備一個緩衝空間,將資料從核心空間讀到緩衝中;如果是OP_ACCEPT就緒,那就呼叫監聽socket的accept()方法得到連線socket,並且accept()不會阻塞,因為在最開始的時候我們已經將監聽socket設定為非阻塞了。得到的連線socket同樣需要設定為非阻塞,這樣連線socket的讀寫操作就是非阻塞的,最後將連線socket註冊到selector多路複用器上,並告訴selector,如果連線socket有OP_READ事件發生的話就要告訴我們。

上個動圖對Java的多路複用程式碼做個解釋。

多路複用

接下來的重點自然是NIO中的select()的底層原理了,還是那句話,NIO之所以能提供多路複用的功能,本質上還是作業系統底層提供了多路複用的系統呼叫。

多路複用本質上就是同時監聽多個socket的請求,當我們訂閱的socket上有我們感興趣的事件發生的時候,多路複用函式會返回,然後我們的使用者程式根據返回結果繼續處理這些就緒狀態的socket。

但是,不同的多路複用模型在具體的實現上有所不同,主要體現在三個方面:

  1. 多路複用模型最多可以同時監聽多少個socket?
  2. 多路複用模型會監聽socket上哪些事件?
  3. 當socket就緒時,多路複用模型如何找到就緒的socket?

多路複用主要有3種,分別是selectpollepoll,接下來將帶著上面3個問題分別介紹這3種底層模型。

下文相關函式的宣告以及引數的定義源於64位CentOS 7.9,核心版本為3.10.0

3. select

我們可以透過select告訴核心,我們對哪些描述符(這些描述符可以表示標準輸入、監聽socket或者連線socket等)的哪些事件(可讀、可寫、發生異常)感興趣,或者某個超時時間之後直接返回。

舉個例子,我們呼叫select告訴核心僅在下列情況下發生時才返回:

  • 集合{1, 4, 7}中有任何描述符就緒;
  • 集合{2, 9}中有任何描述符就緒;
  • 集合{1, 3, 5}中有任何描述符有異常發生
  • 超過了10S,啥事兒也沒有發生。

3.1. select使用方法

// 返回:若有就緒描述符則為其數目,若超時則為0,若出錯則為-1
int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout);

nfds引數用來告訴select需要檢查的描述符的個數,取值為我們感興趣的最大描述符 + 1,按照剛才的例子來講nfds應該是{{1, 4, 7}, {2, 9}, {1, 3, 5}}中的最大描述符+1,也就是9 + 1,為10。至於為什麼這樣,別急,我們下文再說。

timeout引數允許我們設定select的超時時間,如果超過指定時間還沒有我們感興趣的事件發生,就停止阻塞,直接返回。

readfds裡儲存的是我們對讀就緒事件感興趣的描述符,writefds儲存的是我們對寫就緒事件感興趣的描述符,exceptfds儲存的是我們對發生異常這種事件感興趣的描述符。這三個引數會告訴核心,分別需要在哪些描述符上檢測資料可讀、可寫以及發生異常。

但是這些描述符並非像我的例子一樣,直接把集合{1, 4, 7}作為陣列存起來,設計者從記憶體空間和使用效率的角度設計了fd_set這個資料結構,我們看一下它的定義以及某些重要資訊。

// file: /usr/include/sys/select.h
/* __fd_mask 是 long int 型別的別名  */
typedef long int __fd_mask;

#define __NFDBITS	(8 * (int) sizeof (__fd_mask))

typedef struct  {
   ...
   __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
   ...
} fd_set;

因此,fd_set的定義,其實就是long int型別的陣列,元素個數為__FD_SETSIZE / __NFDBITS,我直接在我的CentOS上輸出了一下兩個常量值,如下:

#include <stdio.h>
#include "sys/select.h"

int main(){
   printf("__FD_SETSIZE:%d\n",__FD_SETSIZE);
   printf("__NFDBITS:%d\n",__NFDBITS);
   return 0;
}

// 輸出結果
__FD_SETSIZE:1024
__NFDBITS:64

因此該陣列中一共有16個元素(1024 / 64 = 16),每個元素為long int型別,佔64位。

陣列的第1個元素用於表示描述符0~63,第2個元素用於表示描述符64~127,以此類推,每1個bit位用01兩種狀態表示是否檢測當前描述符的事件。

假設我們對{1, 4, 7}號描述符的讀就緒事件感興趣,那麼readfds引數的陣列第1個元素的二進位制表示就如下圖所示,第1、4、7位分別被標記為1,實際儲存的10進位制數字為146。

實際使用select的時候如果讓我們自己推導上面這個過程進行引數設定那可費了勁了,於是作業系統提供了4個宏來幫我們設定陣列中每個元素的每一位。

// 將陣列每個元素的二進位制位重置為0
void FD_ZERO(fd_set *fdset);

// 將第fd個描述符表示的二進位制位設定為1
void FD_SET(int fd, fd_set *fdset);

// 將第fd個描述符表示的二進位制位設定為0
void FD_CLR(int fd, fd_set *fdset);

// 檢查第fd個描述符表示的二進位制位是0還是1
int  FD_ISSET(int fd, fd_set *fdset);

還是上面{1, 4, 7}這個例子,再順帶著介紹一下用法,知道有這麼回事兒就行了。

fd_set readSet;
FD_ZERO(&readSet);
FD_SET(1, &readSet);
FD_SET(4, &readSet);
FD_SET(7, &readSet);

既然fd_set底層用的是陣列,那就一定有長度限制,也就是說select同時監聽的socket數量是有限的,你之前可能聽過這個有限的數量是1024,但是1024是怎麼來的呢?

3.2. 上限為什麼是1024

其實select的監聽上限就等於fds_bits陣列中所有元素的二進位制位總數。接下來我們用初中數學的解題步驟推理一下這個二進位制位到底有多少。

已知:

證明如下:

結論就是__FD_SETSIZE這個宏其實就是select同時監聽socket的最大數量。該數值在原始碼中有定義,如下所示:

// file: /usr/include/bits/typesizes.h
/* Number of descriptors that can fit in an `fd_set'.  */
#define __FD_SETSIZE		1024

所以,select函式對每一個描述符集合fd_set,最多可以同時監聽1024個描述符

3.3. nfds的作用

為什麼偏偏把最大值設定成1024呢?沒人知道,或許只是程式設計師喜歡這個數字罷了。

最初設計select的時候,設計者考慮到大多數的應用程式根本不會用到很多的描述符,因此最大描述符的上限被設定成了31(4.2BSD版本),後來在4.4BSD中被設定成了256,直到現在被設定成了1024

這個數量說多不多,說少也不算少,select()需要迴圈遍歷陣列中的位判斷此描述符是否有對應的事件發生,如果每次都對1024個描述符進行判斷,在我們感興趣的監聽描述符比較少的情況下(比如我上文的例子)那就是一種極大的浪費。於是,select給我們提供了nfds這個引數,讓我們告訴select()只需要迭代陣列中的前nfds個就行了,而不要總是在每次呼叫的時候遍歷整個陣列。

身為一個系統函式,執行效率自然需要最佳化到極致

3.4. 再談阻塞

上一篇文章講過,當使用者執行緒發起一個阻塞式的read系統呼叫,資料未就緒時,執行緒就會阻塞。阻塞其實是呼叫執行緒被投入睡眠,直到核心在某個時機喚醒執行緒,阻塞也就結束。這裡我們藉著select再聊一聊這個阻塞。

本小節中不做「程式」和「執行緒」的明確區分,執行緒作為輕量級程式來看待

核心會為每一個程式建立一個名為task_struct的資料結構,這個資料結構本身是分配在核心空間的,其中儲存了當前程式的程式號、socket資訊、CPU的執行上下文以及其他很重要但是我不講的資訊(/狗頭)。

Linux核心維護了一個執行佇列,裡邊放的都是處於TASK_RUNNING狀態的程式的task_struct,這些程式以雙向連結串列的方式排隊等待CPU極短時間的臨幸。

程式在執行佇列

阻塞的本質就是將程式的task_struct移出執行佇列,讓出CPU的排程,將程式的狀態的置為TASK_UNINTERRUPTIBLE或者TASK_INTERRUPTIBLE,然後新增到等待佇列中,直到被喚醒。

那這個等待佇列在哪兒呢?比如我們對一個socket發起一個阻塞式的 read 呼叫,使用者程式肯定是需要和這個socket進行繫結的,要不然socket就緒之後都不知道該喚醒誰。這個等待佇列其實就是儲存在socket資料結構中,我們瞄一眼socket原始碼:

struct socket {
	...
  // 這個在epoll中會提到
	struct file		*file;
  ...
  // struct sock - network layer representation of sockets
	struct sock		*sk;
	...
};

struct sock {
  ...
  // incoming packets
	struct sk_buff_head	sk_receive_queue;
	...
  // Packet sending queue
	struct sk_buff_head	sk_write_queue;
  ...
  // socket的等待佇列,wq的意思就是wait_queue
  struct socket_wq __rcu	*sk_wq;
	
};

socket資料結構

不用深入理解哈,只要知道socket自己維護了一個等待佇列sk_wq,這個佇列中每個元素儲存的是:

  • 阻塞在當前socket上的程式描述符
  • 程式被喚醒之後應該呼叫的回撥函式

這個回撥函式是程式在加入等待佇列的時候設定的一個函式指標(行話叫,向核心註冊了一個回撥函式),告訴核心:我正等著這個socket上的資料呢,先睡一會兒,等有資料了你就執行這個回撥函式吧,裡邊有把我喚醒的邏輯。

就這樣,經過網路卡接收資料、硬中斷以及軟中斷再到核心呼叫回撥函式喚醒程式,把程式的task_struct從等待佇列移動到執行佇列,程式再次得到CPU的臨幸,函式返回結果,阻塞結束。

現在回到select

使用者程式會阻塞在select之上,由於select會同時監聽多個socket,因此當前程式會被新增到每個被監聽的socket的等待佇列中,每次喚醒還需要從每個socket等待佇列中移除。

select的阻塞與喚醒

select的喚醒也有個問題,呼叫select的程式被喚醒之後是一臉懵啊,核心直接扔給他一個整數,程式不知道哪些socket收到資料了,還必須遍歷一下才能知道。

剛睡醒,一臉懵

3.5. select如何多路複用

select在超時時間內會被阻塞,直到我們感興趣的socket讀就緒、寫就緒或者有異常事件發生(這話好像囉嗦了好多遍了,是不是自然而然已經記住了),然後select會返回已就緒的描述符數。

其實讀就緒寫就緒或者有異常事件發生這3種事件裡邊的道道兒非常多,這裡我們就僅作字面上的理解就好了,更多細節,可以參考《Unix網路程式設計 卷一》。

使用者程式拿到這個整數說明了兩件事情:

  1. 我們上文講的所有select操作都是在核心態執行的,select返回之後,許可權交還到了使用者空間;
  2. 使用者程式拿到這個整數,需要對select監聽的描述符逐個進行檢測,判斷二進位制位是否被設定為1,進而進行相關的邏輯處理。可是問題是,核心把“就緒”的這個狀態儲存在了哪裡呢?換句話說,使用者程式該遍歷誰?

selectreadfdswritefdsexceptfds 3個引數都是指標型別,使用者程式傳遞這3個引數告訴核心對哪些socket的哪些事件感興趣,執行完畢之後反過來核心會將就緒的描述符狀態也放在這三個引數變數中,這種引數稱為值-結果引數。

使用者程式透過呼叫FD_ISSET(int fd, fd_set *fdset)對描述符集進行判斷即可,看個整體流程的動圖。

select動圖

  • 使用者程式設定fd_set引數,呼叫select()函式,並將描述符集合複製到核心空間;

  • 為了提高效率,核心透過nfds引數避免檢測那些總為0的位,遍歷的過程發生在核心空間,不存在系統呼叫切換上下文的開銷;

  • select函式修改由指標readsetwriteset以及exceptset所指向的描述符集,函式返回時,描述符集中只有之前我們標記過的並且處於就緒狀態的描述符對應的二進位制位才是1,其餘都會被重置為0(因此每次重新呼叫select時,我們必須把所有描述符集中感興趣的位再次設定為1);

  • 程式根據select()返回的結果判斷操作是否正常,如果為0表示超時,-1表示出錯,大於0表示有相應數量的描述符就緒了,進而利用FD_ISSET遍歷檢查所有相應型別的fd_set中的所有描述符,如果為1,則進行業務邏輯處理即可。

3.6. 總結

select(包括下文講到的poll)是阻塞的,程式會阻塞在select之上,而不是阻塞在真正的I/O系統呼叫上,模型示意圖見下圖:

I/O多路複用模型

我們從頭到尾都是使用一個使用者執行緒來處理所有socket,同時又避免了非阻塞IO的那種無效輪詢,為此付出的代價是一次select系統呼叫的阻塞,外加N次就緒檔案描述符的系統呼叫。

4. poll

pollselect的繼任者,接下來聊它。

4.1. 函式原型

先看一下函式原型:

// 返回:若有就緒描述符則為其數目,若超時則為0,若出錯則為-1
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

函式有3個引數,第一個引數是一個pollfd型別的陣列,其中pollfd結構如下:

struct pollfd {
    int    fd;       /* file descriptor */
    short  events;   /* events to look for */
    short  revents;  /* events returned */
 };

4.2. poll訂閱的事件

pollfd由3部分組成,首先是描述符fd,其次events表示描述符fd上待檢測的事件型別,一個short型別的數字用來表示多種事件,自然可以想到用的是二進位制掩碼的方式來進行位操作。

原始碼中我們可以找到所有事件的定義,我根據事件的分類對原始碼的順序做了一定調整,如下:

// file: /usr/include/bits/poll.h
/* 第一類:可讀事件  */
#define POLLIN		0x001		/* There is data to read.  */
#define POLLPRI		0x002		/* There is urgent data to read.  */
#define POLLRDNORM	0x040		/* Normal data may be read.  */
#define POLLRDBAND	0x080		/* Priority data may be read.  */


/* 第二類:可寫事件  */
#define POLLOUT		0x004		/* Writing now will not block.  */
#define POLLWRNORM	0x100		/* Writing now will not block.  */
#define POLLWRBAND	0x200		/* Priority data may be written.  */


/* 第三類:錯誤事件 */
#define POLLERR		0x008		/* Error condition.  */
#define POLLHUP		0x010		/* Hung up.  */
#define POLLNVAL	0x020		/* Invalid polling request.  */

pollfd結構中還有一個revents欄位,全稱是“returned events”,這是pollselect的第1個不同點。

poll會將每次遍歷之後的結果儲存到revents欄位中,沒有select那種值-結果引數,也就不需要每次呼叫poll的時候重置我們感興趣的描述符以及相關事件。

還有一點,錯誤事件不能在events中進行設定,但是當相應事件發生時會透過revents欄位返回。這是pollselect的第2個不同點。

再來看poll的第2個引數nfds,表示的是陣列fds的元素個數,也就是使用者程式想讓poll同時監聽的描述符的個數。

如此一來,poll函式將設定最大監聽數量的許可權給了程式設計者,自由控制pollfd結構陣列的大小,突破了select函式1024個最大描述符的限制。這是pollselect的第3個不同點。

至於timeout引數就更好理解了,就是設定超時時間罷了,更多細節朋友們可以檢視一下api。

pollselect是完全不同的API設計,因此要說不同點那真是海了去了,但是由於本質上和select沒有太大的變化,因此我們也只關注上面的這幾個不同點也就罷了。需要注意的是poll函式返回之後,被喚醒的使用者程式依然是懵的,踉踉蹌蹌地去遍歷檔案描述符、檢查相關事件、進行相應邏輯處理。

其他的細節就再參考一下select吧,poll我們到此為止。

5. epoll

epoll是三者之中最強大的多路複用模型,自然也更難講,要三言兩語只講一下epoll的優勢倒也不難,不過會喪失很多細節,用原始碼解釋又太枯燥,思來想去,於是。。。

我拖更了。。。

都是epoll的鍋

5.1. epoll入門

還是先從epoll的函式使用開始,不同於select/poll單個函式走天下,epoll用起來稍微麻煩了一點點,它提供了函式三件套,epoll_createepoll_ctlepoll_wait,我們一個個來看。

5.1.1. 建立epoll例項

// size引數從Linux2.6.8之後失去意義,為保持向前相容,需要使size引數 > 0
int epoll_create(int size);

// 這個函式是最新款,如果falgs為0,等同於epoll_create()
int epoll_create1(int flags);

epoll_create() 方法建立了一個 epoll 例項,並返回了指向epoll例項的描述符,這個描述符用於下文即將介紹的另外兩個函式。也可以使用epoll_create1()這個新函式,這個函式相比前者可以多新增EPOLL_CLOEXEC這個可選項,至於有啥含義,對本文並不重要。

這個epoll例項內部維護了兩個重要結構,分別是需要監聽的檔案描述符樹就緒的檔案描述符(這兩個結構下文會講),對於就緒的檔案描述符,他們會被返回給使用者程式進行處理,從這個角度來說,epoll避免了每次select/poll之後使用者程式需要掃描所有檔案描述符的問題

5.1.2. epoll註冊事件

建立完epoll例項之後,我們可以使用epoll_ctlctl就是control的縮寫)函式,向epoll例項中新增、修改或刪除我們感興趣的某個檔案描述符的某些事件。

//  返回值: 若成功返回0;若返回-1表示出錯
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  

第一個引數epfd就是剛才呼叫epoll_create建立的epoll例項的描述符,也就是epoll的控制程式碼。

第二個引數op表示要進行什麼控制操作,有3個選項

  • EPOLL_CTL_ADD: 向 epoll 例項註冊檔案描述符對應的事件;
  • EPOLL_CTL_DEL:向 epoll 例項刪除檔案描述符對應的事件;
  • EPOLL_CTL_MOD修改檔案描述符對應的事件。

第三個引數fd很簡單,就是被操作的檔案描述符。

第四個引數就是註冊的事件型別,我們先看一下epoll_event的定義:

struct epoll_event {
     uint32_t     events;      /* 向epoll訂閱的事件 */
     epoll_data_t data;        /* 使用者資料 */
};

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

events這個欄位和pollevents引數一樣,都是透過二進位制掩碼設定事件型別,epoll的事件型別在/usr/include/sys/epoll.h中有定義,更詳細的可以使用man epoll_ctl看一下文件說明,其中內容很多,知道有這麼回事兒就行了,但是注意一下EPOLLET這個事件,我特意加了一下注釋,下文會講到。

enum EPOLL_EVENTS {
      EPOLLIN = 0x001,
  		#define EPOLLIN EPOLLIN
      EPOLLPRI = 0x002,
  		#define EPOLLPRI EPOLLPRI
      EPOLLOUT = 0x004,
  		#define EPOLLOUT EPOLLOUT
      EPOLLRDNORM = 0x040,
  		#define EPOLLRDNORM EPOLLRDNORM
      EPOLLRDBAND = 0x080,
  		#define EPOLLRDBAND EPOLLRDBAND
      EPOLLWRNORM = 0x100,
  		#define EPOLLWRNORM EPOLLWRNORM
      EPOLLWRBAND = 0x200,
  		#define EPOLLWRBAND EPOLLWRBAND
      EPOLLMSG = 0x400,
  		#define EPOLLMSG EPOLLMSG
      EPOLLERR = 0x008,
  		#define EPOLLERR EPOLLERR
      EPOLLHUP = 0x010,
  		#define EPOLLHUP EPOLLHUP
      EPOLLRDHUP = 0x2000,
  		#define EPOLLRDHUP EPOLLRDHUP
      EPOLLWAKEUP = 1u << 29,
  		#define EPOLLWAKEUP EPOLLWAKEUP
      EPOLLONESHOT = 1u << 30,
  		#define EPOLLONESHOT EPOLLONESHOT
  		// 設定為 edge-triggered,預設為 level-triggered
      EPOLLET = 1u << 31
  		#define EPOLLET EPOLLET
};

data欄位比較有意思,我們可以在data中設定我們需要的資料,具體是什麼意思現在說起來還有點麻煩,稍安勿躁,我們接著看最後一個函式。

5.1.3. epoll_wait

// 返回值: 成功返回的是一個大於0的數,表示事件的個數;0表示超時;出錯返回-1.
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

這個是不是就感覺很熟悉了啊。epoll_wait的用法和select/poll很類似,使用者程式被阻塞。不同的是,epoll會直接告訴使用者程式哪些描述符已經就緒了。

第一個引數是epoll例項的描述符。

第二個引數是返回給使用者空間的需要處理的I/O事件,是一個epoll_event型別的陣列,陣列的長度就是epoll_wait函式的返回值,再看一眼這個結構吧。

struct epoll_event {
     uint32_t     events;      /* 向epoll訂閱的事件 */
     epoll_data_t data;        /* 使用者資料 */
};

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

events 表示具體的事件型別,至於這個data就是在epoll_ctl中設定的data,這樣使用者程式收到這個epoll_event,根據之前設定的data就能獲取到相關資訊,然後進行邏輯處理了。

第三個引數是一個大於 0 的整數,表示 epoll_wait 可以返回的最大事件值。

第四個引數是 epoll_wait 阻塞呼叫的超時值,如果設定為 -1,表示不超時;如果設定為 0 則立即返回,即使沒有任何 I/O 事件發生。

5.2. edge-triggered 和 level-triggered

epoll還提供了一個利器——邊緣觸發(edge-triggered),也就是上文我沒解釋的EPOLLET 引數。

啥意思呢?我舉個例子。如果有個socket有100個位元組的資料可讀,邊緣觸發(edge-triggered)和條件觸發(level-triggered)都會產生讀就緒事件。

但是如果使用者程式只讀取了50個位元組,邊緣觸發就會陷入等待,資料不會丟失,但是你愛讀不讀,反正老子已經通知過你了;而條件觸發會因為你還沒有讀完,兢兢業業地不停產生讀就緒事件催你去讀。

邊緣觸發只會產生一次事件提醒,效率和效能要高於條件觸發,這是epoll的一個大殺器。

5.3. epoll進階

5.3.1. file_operations與poll

進階之前問個小問題,Linux下所有檔案都可以使用select/poll/epoll來監聽檔案變化嗎?

答案是不行!

只有底層驅動實現了 file_operationspoll 函式的檔案型別才可以被 epoll 監視!

注意,這裡的file_operations中定義的poll和上文講到的poll()是兩碼事兒,只是恰好名字一樣罷了。

socket 型別的檔案驅動實現了 poll 函式,具體實現是sock_poll(),因此才可以被 epoll 監視

下面我摘錄了 file_operations 中我們常見的函式定義給大家看一下。

// file: include/linux/fs.h
struct file_operations {
	...
	ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
	ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
	...
	unsigned int (*poll) (struct file *, struct poll_table_struct *);
	...
	int (*open) (struct inode *, struct file *);
	int (*flush) (struct file *, fl_owner_t id);
	int (*release) (struct inode *, struct file *);
	...
};

有點懵對吧,繼續看。

Linux對檔案的操作做了高度的抽象,每個開發者都可以開發自己的檔案系統,Linux並不知道其中的具體檔案應該怎樣openread/write或者release,所以Linux定義了file_operations這個“介面”,裝置型別需要自己實現struct file_operations結構中定義的函式的細節。有點類似於Java中的介面和具體實現類的關係。

poll函式的作用我們下文再說。

5.3.2. epoll核心物件的建立

epoll_create()的主要作用是建立一個struct eventpoll核心物件,後續epoll的操作大部分都是對這個資料結構的操作。

eventpoll物件

  • wq:等待佇列。雙向連結串列,軟中斷就緒的時候會透過wq找到阻塞在epoll物件上的程式;
  • rdllist:就緒的描述符連結串列。雙向連結串列,當描述符就緒時,核心會將就緒的描述符放到rdllist,這樣使用者程式就可以透過該連結串列直接找到就緒的描述符;
  • rbrRed Black Root。指向紅黑樹根節點,裡邊的每個節點表示的就是epoll監聽的檔案描述符。

然後,核心將eventpoll加入到當前程式已開啟的檔案列表中。啥?eventpoll也是一個檔案?別急,我們看看epoll_create1的原始碼。

//file: /fs/eventpoll.c
SYSCALL_DEFINE1(epoll_create1, int, flags)
{
	int error, fd;
	struct eventpoll *ep = NULL;
	struct file *file;
	
  ...
  
  // 1. 為struct eventpoll分配記憶體並初始化
  // 		初始化操作主要包括初始化等待佇列wq、rdllist、rbr等
	error = ep_alloc(&ep);
	
  ...
  
  // 2. 獲取一個可用的描述符號fd,此時fd還未與具體的file繫結
	fd = get_unused_fd_flags(O_RDWR | (flags & O_CLOEXEC));
	
  ...
    
  // 3. 建立一個名為"[eventpoll]"的匿名檔案file
  //		並將eventpoll物件賦值到匿名檔案file的private_data欄位進行關聯
	file = anon_inode_getfile("[eventpoll]", &eventpoll_fops, ep,
				 O_RDWR | (flags & O_CLOEXEC));
	
  // 4. 將eventpoll物件的file指標指向剛建立的匿名檔案file
	ep->file = file;
  
  // 5. 將fd和匿名檔案file進行繫結
	fd_install(fd, file);
	return fd;
}

好好看一下程式碼中的註釋(一定要看!),程式碼執行完畢的結果就如下圖這般。

程式與epoll

呼叫epoll_create1後得到的檔案描述符本質上是匿名檔案[eventpoll]的描述符,該匿名檔案中的private_data欄位才指向了真正的eventpoll物件。

Linux中的一切皆檔案並非虛言。這樣一來,eventpoll檔案也可以被epoll本身監測,也就是說epoll例項可以監聽其他的epoll例項,這一點很重要。

至此,epoll_create1呼叫結束。是不是很簡單吶~

5.3.3. 新增socket到epoll

現在我們考慮使用EPOLL_CTL_ADD向epoll例項中新增fd的情況。

接下來會涉及到較多的原始碼,別恐懼,都很簡單

這時候就要用到上文的rbr紅黑樹了, epoll_ctl對fd的增刪改操查作實際上就是對這棵紅黑樹進行操作,樹的節點結構epitem如下所示:

// file: /fs/eventpoll.c
struct epitem {
	/* 紅黑樹的節點 */
	struct rb_node rbn;

	/* 用於將當前epitem連線到eventpoll中rdllist中的工具 */
	struct list_head rdllink;

	...

	/* 該結構儲存了我們想讓epoll監聽的fd以及該fd對應的file */
	struct epoll_filefd ffd;


	/* 當前epitem屬於哪個eventpoll */
	struct eventpoll *ep;

};

紅黑樹與epitem

接著我們看一下epoll_ctl的原始碼。

// file: /fs/eventpoll.c
SYSCALL_DEFINE4(epoll_ctl, int, epfd, int, op, int, fd,
		struct epoll_event __user *, event)
{
	struct file *file, *tfile;
	struct eventpoll *ep;
	struct epitem *epi;

	...

	/* 根據epfd找到eventpoll對應的匿名檔案 */
	file = fget(epfd);

	/* fd是我們感興趣的socket描述符,根據它找到對應的檔案 */
	tfile = fget(fd);
	
  /* 根據file的private_data欄位找到eventpoll例項 */
	ep = file->private_data;

	...
	/* 在紅黑樹中查詢一下,看看是不是已經存在了
			如果存在了,那就報錯;否則,執行ep_insert */
  epi = ep_find(ep, tfile, fd);
  
	switch (op) {
	case EPOLL_CTL_ADD:
		if (!epi) {
			epds.events |= POLLERR | POLLHUP;
			error = ep_insert(ep, &epds, tfile, fd);
		} else
			error = -EEXIST;
		clear_tfile_check_list();
		break;
	...
	}
  
	...
}

epoll_ctl中,首先根據傳入的epfd以及fd找到相關的核心物件,然後在紅黑樹中判斷這個epitem是不是已經存在,存在的話就報錯,否則繼續執行ep_insert函式。

ep_insert故名思義就是將epitem結構插入到紅黑樹當中,但是並非單純插入那麼簡單,其中涉及到一些細節。

5.3.3.1. ep_insert

很多關鍵操作都是在ep_insert函式中完成的,看一下原始碼。

// file: /fs/eventpoll.c
static int ep_insert(struct eventpoll *ep, struct epoll_event *event,
		     struct file *tfile, int fd)
{
	int error, revents, pwake = 0;
	unsigned long flags;
	long user_watches;
	struct epitem *epi;
	struct ep_pqueue epq;

	// 1. 分配epitem記憶體空間
	if (!(epi = kmem_cache_alloc(epi_cache, GFP_KERNEL)))
		return -ENOMEM;
	
  ...
    
	// 2. 將epitem進行初始化
	INIT_LIST_HEAD(&epi->rdllink);
	INIT_LIST_HEAD(&epi->fllink);
	INIT_LIST_HEAD(&epi->pwqlist);
	epi->ep = ep;
	ep_set_ffd(&epi->ffd, tfile, fd);
	epi->event = *event;
	epi->nwait = 0;
	epi->next = EP_UNACTIVE_PTR;
	
  ...

	/* 3. 初始化 poll table,設定回撥函式為ep_ptable_queue_proc */
	epq.epi = epi;
	init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);

	/*
	 * 4. 呼叫ep_ptable_queue_proc函式,
	 * 		設定socket等待佇列的回撥函式為ep_poll_callback
	 */
	revents = ep_item_poll(epi, &epq.pt);
	
  ...
	
	/* 5. epitem插入eventpoll的紅黑樹 */
	ep_rbtree_insert(ep, epi);

	...
}
5.3.3.2. 分配與初始化epitem

雖然原始碼行數不少,但是這一步非常簡單,就是將epitem中的資料準備好,到插入的時候直接拿來用就行了。用一張圖來說明這一步的重點問題。

epitem初始化

epitem已經準備好了,也就是監聽的socket物件已經有了,就差插入到紅黑樹了,但是在插入之前需要解決個問題,當監聽的物件就緒了之後核心該怎麼辦?

那就是設定回撥函式!

這個回撥函式是透過函式 ep_ptable_queue_proc 來進行設定的。回撥函式是幹什麼的呢?就是當對應的檔案描述符上有事件發生,就會呼叫這個函式,比如socket緩衝區有資料了,核心就會回撥這個函式。這個函式就是 ep_poll_callback

5.3.3.3. 設定回撥函式

這一小節就是透過原始碼講解如何設定ep_poll_callback回撥函式的。

沒有耐心的話可以暫時跳過這一小節,但是強烈建議整體看完之後回看這部分內容。

// file: /include/linux/poll.h
static inline void init_poll_funcptr(poll_table *pt, poll_queue_proc qproc)
{
	pt->_qproc = qproc;
	pt->_key   = ~0UL; /* all events enabled */
}

init_poll_funcptr函式將poll_table結構的_qproc函式指標設定為qproc引數,也就是在ep_insert中看到的ep_ptable_queue_proc函式。

接下來輪到ep_item_poll了,扒開它看看。

// file: /fs/eventpoll.c
static inline unsigned int ep_item_poll(struct epitem *epi, poll_table *pt)
{
  pt->_key = epi->event.events;
	
  // 這行是重點
	return epi->ffd.file->f_op->poll(epi->ffd.file, pt) & epi->event.events;
}

重點來了,透過上文我們知道了,ffd.file指的是socket代表的檔案,也就是呼叫了socket檔案自己實現的poll方法,也就是上文提到過的sock_poll()

然後經過下面層層函式呼叫,最終來到了poll_wait函式。

// file: /include/linux/poll.h
static inline void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p)
{
	if (p && p->_qproc && wait_address)
		p->_qproc(filp, wait_address, p);
}

你看,poll_wait又呼叫了poll_table_qproc函式,我們剛剛在init_poll_funcptr中將其設定為了ep_ptable_queue_proc,於是,程式碼來到了ep_ptable_queue_proc

// file: /fs/eventpoll.c
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead,
				 poll_table *pt)
{
	...
    
	if (epi->nwait >= 0 && (pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL))) {
		
    // 設定最終的回撥方法ep_poll_callback
    init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
		
    ...
    
    // 將包含ep_poll_callback在內的資訊放入socket的等待佇列
		add_wait_queue(whead, &pwq->wait);
		...
	} 
}

ep_ptable_queue_proc被我簡化地只剩2個函式呼叫了,我們在3.4節中提到了socket自己維護了一個等待佇列sk_wq,並且這個等待佇列中的每一項儲存了阻塞在當前socket上的程式描述符(明確知道該喚醒誰)以及回撥函式(核心明確知道資料來了該怎麼做)。

這一系列的操作就是設定回撥函式為ep_poll_callback,並封裝佇列項資料結構,然後把這個結構放到socket的等待佇列中。

還有一個小問題,不知道朋友們注意到了沒有,我沒提儲存當前使用者程式資訊這回事兒。這也是epoll更加高效的一個原因,現在socket已經完全託管給epoll了,因此我們不能在一個socket準備就緒的時候就立刻去喚醒程式,喚醒的時機得交給epoll,這就是為什麼eventpoll物件還有一個佇列的原因,裡邊存放的就是阻塞在epoll上的程式。

再看一遍這個結構。

eventpoll物件

說完這些,你可能在想,交給epoll不也是讓epoll喚醒嘛,有啥區別?還有ep_poll_callback這個回撥具體怎麼用也沒解釋。

別急,現在還不是解釋的時候,繼續往下。

5.3.3.4. 插入紅黑樹

最後一步就是透過ep_rbtree_insert(ep, epi)epitem插入到紅黑樹中。

插入到紅黑樹

至此,epoll_ctl的整個呼叫過程全部結束。

此過程中我沒有解釋關於紅黑樹的任何操作,我也建議大家把它當成一個黑盒,只需要知道epoll底層採用了紅黑樹對epitem進行增刪改查即可,畢竟學習紅黑樹不是我們的重點。

至於為什麼核心開發者選擇了紅黑樹這個結構,自然就是為了高效地管理epitem,使得在插入、查詢、刪除等各個方面不會因為epitem數量的增加而產生效能的劇烈波動。

上面幾個小節的所有工作,得到了如下這一張圖。

5.3.4. epoll_wait

epoll本身是阻塞的,阻塞也正是在這一步中體現的。

大部分人聽到阻塞這個詞就覺得很低效,這種想法並不對。

epoll_wait做的事情就是檢查eventpoll物件中的就緒fd列表rdllist中是否有資料,如果有,就說明有socket已經準備好了,那就直接返回,使用者程式對該列表中的fd進行處理。

如果列表為空,那就將當前程式加入到eventpoll的程式等待佇列wq中,讓出CPU,主動進入睡眠狀態。

也就是說,只要有活兒(fd就緒),epoll會玩兒命一直幹,絕對不阻塞。但是一旦沒活兒了,阻塞就是一種正確的選擇,要不然一直佔用CPU也是一種極大的浪費。因此,epoll避免了很多不必要的程式上下文切換。

好了,現在來看epoll_wait的實現吧。

// file: /fs/eventpoll.c
SYSCALL_DEFINE4(epoll_wait, int, epfd, struct epoll_event __user *, events,
		int, maxevents, int, timeout)
{
	...
	error = ep_poll(ep, events, maxevents, timeout);
	...
}
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
		   int maxevents, long timeout)
{
...

fetch_events:
	...
	
  // 如果就緒佇列上沒有時間發生,進入下面的邏輯
  // 否則,就返回
	if (!ep_events_available(ep)) {
		/*
		 * We don't have any available event to return to the caller.
		 * We need to sleep here, and we will be wake up by
		 * ep_poll_callback() when events will become available.
		 */
    // 定義等待佇列項,並將當前執行緒和其進行繫結,並設定回撥函式
		init_waitqueue_entry(&wait, current);
    // 將等待佇列項加入到wq等待佇列中
		__add_wait_queue_exclusive(&ep->wq, &wait);

		for (;;) {
			/*
			 * We don't want to sleep if the ep_poll_callback() sends us
			 * a wakeup in between. That's why we set the task state
			 * to TASK_INTERRUPTIBLE before doing the checks.
			 */
      // 讓出CPU,進入睡眠狀態
			set_current_state(TASK_INTERRUPTIBLE);
			...
			if (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS))
				timed_out = 1;
			...
		}
	...
	}
...
}

原始碼中有部分英文註釋我沒有刪除,讀一下這些註釋可能會對理解整個過程有幫助。

ep_poll做了以下幾件事:

  1. 判斷eventpollrdllist佇列上有沒有就緒fd,如果有,那就直接返回;否則執行下面的步驟;
  2. 定義eventpollwq等待佇列項,將當前程式繫結至佇列項,並且設定回撥函式;
  3. 將等待佇列項加入到wq佇列;
  4. 當前程式讓出CPU,進入睡眠狀態,程式阻塞。

每一步都比較好理解,我們重點來看一下第2步,也就是init_waitqueue_entry函式。

// file: /include/linux/wait.h
static inline void init_waitqueue_entry(wait_queue_t *q, struct task_struct *p)
{
	q->flags = 0;
	q->private = p;
	q->func = default_wake_function;
}

wait_queue_t就是wq等待佇列項的結構體型別,將其中的private欄位設定成了當前程式的task_struct結構體指標。然後將default_wake_function作為回撥函式,賦值給了func欄位,至於這個回撥函式幹嘛用的,還是別急,下文會說的。

於是,這個圖又完整了一些。

完整的epoll圖示

5.3.5. 來活兒了

收到資料之後,首先幹苦力活的是網路卡,網路卡會將資料放到某塊關聯的記憶體當中,這個操作不需要CPU的參與。等到資料儲存完了之後,網路卡會向CPU發起一個硬中斷,通知CPU資料來了。

這個時候CPU就要開始對中斷進行處理了,但是CPU太忙了,它必須時時刻刻準備好接收各種裝置的中斷,比如滑鼠、鍵盤等,而且還不能卡在一箇中斷上太長時間,要不然可以想像我們的計算機得“卡”成什麼樣子。

所以實際設計中硬中斷只負責做一些簡單的事情,然後接著觸發軟中斷,比較耗時且複雜的工作就交給軟中斷處理程式去做了。

軟中斷以核心執行緒的方式執行,每個CPU都會對應一個軟中斷核心執行緒,名字叫做ksoftirqd/CPU編號,比如 0 號 CPU 對應的軟中斷核心執行緒的名字是 ksoftirqd/0,為了方便,我們直接叫做ksoftirqd好了。

從這個角度上來說,作業系統就是一個死迴圈,在迴圈中不斷接收各種中斷,處理不同邏輯。

核心執行緒經過各個函式呼叫,最終會呼叫到就緒的socket等待佇列項中的回撥函式ep_poll_callback,是時候看看這個函式了。

// file: /fs/eventpoll.c
static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key)
{
	...
  // 獲取等待佇列項對應的epitem
	struct epitem *epi = ep_item_from_wait(wait);
  
  // 獲取epitem對應的eventpoll例項
	struct eventpoll *ep = epi->ep;

	...

	/* 如果當前epitem指向的socket已經在就緒佇列裡了,那就直接退出
			否則,將epitem新增到eventpoll的就緒佇列rdllist中
			If this file is already in the ready list we exit soon 
	*/
	if (!ep_is_linked(&epi->rdllink)) {
		list_add_tail(&epi->rdllink, &ep->rdllist);
	}

	// 檢視eventpoll等待佇列上是否有等待的程式
	if (waitqueue_active(&ep->wq))
		wake_up_locked(&ep->wq);
	
  ...
}

ep_poll_callback的邏輯非常簡潔清晰。

先找到就緒socket對應的等待佇列項中的epitem,繼而找到對應的eventpoll例項,再介面著判斷當前的epitem是不是已經在rdllist就緒佇列裡了,如果在,那就沒啥好做的了,函式退出就行了;如果不在,那就把epitem加入到rdllist中。

最後看看eventpoll的等待佇列上是不是有阻塞的程式,有的話就呼叫5.3.4節中設定的default_wake_function回撥函式來喚醒這個程式。

epoll中重點介紹的兩個回撥函式,ep_poll_callbackdefault_wake_function就串起來了。前者呼叫了後者,後者喚醒了程式。epoll_wait的最終使命就是將rdllist中的就緒fd返回給使用者程式。

喚醒使用者程式

5.4. epoll總結

我們來梳理一下epoll的整個過程。

  1. epoll_create建立了eventpoll例項,並對其中的就緒佇列rdllist、等待佇列wq以及紅黑樹rbr進行了初始化;
  2. epoll_ctl將我們感興趣的socket封裝成epitem物件加入紅黑樹,除此之外,還封裝了socket的sk_wq等待佇列項,裡邊儲存了socket就緒之後的函式回撥,也就是ep_poll_callback
  3. epoll_wait檢查eventpoll的就緒佇列是不是有就緒的socket,有的話直接返回;否則就封裝一個eventpoll的等待佇列項,裡邊儲存了當前的使用者程式資訊以及另一個回撥函式default_wake_function,然後把當前程式投入睡眠;
  4. 直到資料到達,核心執行緒找到就緒的socket,先呼叫ep_poll_callback,然後ep_poll_callback又呼叫default_wake_function,最終喚醒eventpoll等待佇列中儲存的程式,處理rdllist中的就緒fd;
  5. epoll結束!

等下,還沒結束!在5.3.2節中還留了一個小坑,我說:eventpoll檔案也可以被epoll本身監測,也就是說epoll例項可以監聽其他的epoll例項,這一點很重要。

怎麼個重要法,這就涉及到eventpoll例項中的另一個佇列了,叫做poll_wait

struct eventpoll {
	
	...

	wait_queue_head_t wq;

	/* 就是它!!!!!!! */
	wait_queue_head_t poll_wait;

	struct list_head rdllist;

	struct rb_root rbr;

	struct file *file;
};

遞迴監聽的情況

如上圖所示

  • epollfd1監聽了2個普通描述符fd1fd2
  • epollfd2監聽了epollfd1和2個普通描述符fd3fd4

如果fd1fd2可讀事件觸發,那麼就緒的fd的回撥函式ep_poll_callback對將該fd放到epollfd1rdllist就緒佇列中。由於epollfd1本身也是個檔案,它的可讀事件此時也被觸發,但是ep_poll_callback怎麼知道該把epollfd1放到誰的rdllist中呢?

poll_wait來嘍~~

當epoll監聽epoll型別的檔案的時候,會把監聽者放入被監聽者的poll_wait佇列中,上面的例子就是epollfd1poll_wait佇列儲存了epollfd2,這樣一來,當epollfd1有可讀事件觸發,就可以在poll_wait中找到epollfd2,呼叫epollfd1ep_poll_callbackepollfd1放入epollfd2rdllist中。

所以poll_wait佇列就是用來處理這種遞迴監聽的情況的。


到此為止,多路複用徹底結束~~~

相關文章