微言Netty:百萬併發基石上的epoll之劍

說道本章標題,相信很多人知道我在暗喻石中劍這個典故,在此典故中,天命註定的亞瑟很容易的就拔出了這把石中劍,但是由於資歷不被其他人認可,所以他頗費了一番周折才成爲了真正意義上的英格蘭全境之王,亞瑟王。說道這把劍,劍身上銘刻着這樣一句話:ONLY THE KING CAN TAKE THE SWORD FROM THE STONE。雖然典故中的the king是指英明之主亞瑟王,但是在本章中,這個king就是讀者自己,我們今天不僅要從百萬併發基石上拔出這把epoll之劍,也就是Netty,而且要利用這把劍大殺四方,一如當年的亞瑟王憑藉此劍統一了英格蘭全境一樣。

說到石中劍Netty,我們知道他極其強悍的性能以及純異步模型,釋放出了極強的生產力,內置的各種編解碼編排,心跳包檢測,粘包拆包處理等,高效且易於使用,以至於很多耳熟能詳的組件都在使用,比如hadoop,dubbo等。但是他是如何做到這些的呢?本章將會以庖丁解牛的方式,一步一步的來拔出此劍。

Netty的異步模型

說起netty的異步模型,我相信大多數人,只要是寫過服務端的話,都是耳熟能詳的,bossGroup和workerGroup被ServerBootstrap所驅動,用起來簡直是如虎添翼,再加上各種配置化的handler加持,組裝起來也是行雲流水,俯拾即是。但是,任何一個好的架構,都不是一蹴而就實現的,那她經歷了怎樣的心路歷程呢?

1. 經典的多線程模型

05b0b81a-0ff8-4ed3-9b86-21ebe414a9a9

此模型中,服務端起來後,客戶端連接到服務端,服務端會爲每個客戶端開啓一個線程來進行後續的讀寫操作。客戶端少的時候,整體性能和功能還是可以的,但是如果客戶端非常多的時候,線程的創建將會導致內存的急劇飆升從而導致服務端的性能下降,嚴重者會導致新客戶端連接不上來,更有甚者,服務器直接宕機。此模型雖然簡單,但是由於其簡單粗暴,所以難堪大用,建議在寫服務端的時候,要徹底的避免此種寫法。

2. 經典的Reactor模型

由於多線程模型難堪大用,所以更好的模型一直在研究之中,Reactor模型,作爲天選之子,也被引入了進來,由於其強大的基於事件處理的特性,使得其成爲異步模型的不二之選,其起源可以參考這篇文章

Reactor模型由於是基於事件處理的,所以一旦有事件被觸發,將會派發到對應的event handler中進行處理。所以在此模型中,有兩個最重要的參與者,列舉如下:

Reactor:主要用來將IO事件派發到相對應的handler中,可以將其想象爲打電話時候的分發總機,你先打電話到總機號碼,然後通過總機,你可以分撥到各個分機號碼。

Handlers:主要用來處理IO事件相關的具體業務,可以將其想象爲撥通分機號碼後,實際上爲你處理事件的員工。

bbabc809-b59b-4492-a124-65668c436fbf

上圖爲Reactor模型的描述圖,具體來說一下:

Initiation Dispatcher其實扮演的就是Reactor的角色,主要進行Event Demultiplexer,即事件派發。而其內部一般都有一個Acceptor,用於通過對系統資源的操縱來獲取資源句柄,然後交由Reactor,通過handle_events方法派發至具體的EventHandler的。

Synchronous Event Demultiplexer其實就是Acceptor的角色,此角色內部通過調用系統的方法來進行資源操作,比如說,假如客戶端連接上來,那麼將會獲得當前連接,假如需要刪除文件,那麼將會獲得當前待操作的文件句柄等等。這些句柄實際上是要返回給Reactor的,然後經由Reactor派發下放給具體的EventHandler。

Event Handler這裏,其實就是具體的事件操作了。其內部針對不同的業務邏輯,擁有不同的操作方法,比如說,鑑權EventHandler會檢測傳入的連接,驗證其是否在白名單,心跳包EventHanler會檢測管道是否空閒,業務EventHandler會進行具體的業務處理,編解碼EventHandler會對當前連接傳輸的內容進行編碼解碼操作等等。

由於Netty是Reactor模型的具體實現,所以在編碼的時候,我們可以非常清楚明白的理解Reactor的具體使用方式,這裏暫時不講,後面會提到。

由於Doug Lea寫過一篇關於NIO的文章,整體總結的極好,所以這裏我們就結合他的文章來詳細分析一下Reactor模型的演化過程。

def74d0d-682d-4964-9e86-272af0ef6068

上圖模型爲單線程Reator模型,Reactor模型會利用給定的selectionKeys進行派發操作,派發到給定的handler,之後當有客戶端連接上來的時候,acceptor會進行accept接收操作,之後將接收到的連接和之前派發的handler進行組合並啓動。

cdd257a2-91b1-4007-a2e4-31fa4eb92c3d

上圖模型爲池化Reactor模型,此模型將讀操作和寫操作解耦了出來,當有數據過來的時候,將handler的系列操作扔到線程池中來進行,極大的提到了整體的吞吐量和處理速度。

88b720e2-a0d9-43ff-847f-769aa9351461

上圖模型爲多Reactor模型,此模型中,將原本單個Reactor一分爲二,分別爲mainReactor和subReactor,其中mainReactor主要進行客戶端連接方面的處理,客戶端accept後發送給subReactor進行後續處理處理。這種模型的好處就是整體職責更加明確,同時對於多CPU的機器,系統資源的利用更加高一些。

ead64d8f-4e8c-4249-bde6-c8d831c55794

從netty寫的server端,就可以看出,boss worker group對應的正是主副Reactor,之後ServerBootstrap進行Reactor的創建操作,裏面的group, channel, option等進行初始化操作,而設置的childHandler則是具體的業務操作,其底層的事件分發器則通過調用linux系統級接口epoll來實現連接並將其傳給Reactor。

石中劍Netty強悍的原理 (JNI)

netty之劍之所以鋒利,不僅僅因爲其純異步的編排模型,避免了各種阻塞式的操作,同時其內部各種設計精良的組件,終成一統。且不說讓人眼前一亮的緩衝池設計,讀寫標隨心而動,摒棄了繁冗複雜的邊界檢測,用起來着實舒服之極;原生的流控和高低水位設計,讓流速控制真的是隨心所欲,鑄就了一道相當堅固的護城河;齊全的粘包拆包處理方式,讓每一筆數據都能夠清晰明瞭;而高效的空閒檢測機制,則讓心跳包和斷線重連等設計方案變得如此俯拾即是

上層的設計如此優秀,其性能又怎能甘居下風。由於底層通訊方式完全是C語言編寫,然後利用JNI機制進行處理,所以整體的性能可以說是達到了原生C語言性能的強悍程度。說道JNI,這裏我覺得有必要詳細說一下,他是我們利用java直接調用c語言原生代碼的關鍵。

JNI,全稱爲Java Native Interface,翻譯過來就是java本地接口,他是java調用C語言的一套規範。具體來看看怎麼做的吧。

步驟一,先來寫一個簡單的java調用函數:

/**
 * @author shichaoyang
 * @Description: 數據同步器
 * @date 2020-10-14 19:41
 */
public class DataSynchronizer {
    /**
     * 加載本地底層C實現庫
     */
    static {
        System.loadLibrary("synchronizer");
    }
    /**
     * 底層數據同步方法
     */
    private native String syncData(String status);
    /**
     * 程序啓動,調用底層數據同步方法
     *
     * @param args
     */
    public static void main(String... args) {
        String rst = new DataSynchronizer().syncData("ProcessStep2");
        System.out.println("The execute result from C is : " + rst);
    }
}

可以看出,是一個非常簡單的java類,此類中,syncData方法前面帶了native修飾,代表此方法最終將會調用底層C語言實現。main方法是啓動類,將C語言執行的結果接收並打印出來。

然後,打開我們的linux環境,這裏由於我用的是linux mint,依次執行如下命令來設置環境:

執行apt install default-jdk 安裝java環境,安裝完畢。

通過update
-alternatives --list java 獲取java安裝路徑,這裏爲:/usr/lib/jvm/java-11-openjdk-amd64
設置java環境變量 export JAVA_HOME
=/usr/lib/jvm/java-11-openjdk-amd64
環境設置完畢之後,就可以開始進行下一步了。

步驟二,編譯

首先,進入到代碼DataSynchronizer.c所在的目錄,然後運行如下命令來編譯java源碼:

javac -h . DataSynchronizer.java

編譯完畢之後,可以看到當前目錄出現瞭如下幾個文件:

d9d88ea4-4cdf-4c19-99d4-cc047a3cf4c7

其中DataSynchronizer.h是生成的頭文件,這個文件儘量不要修改,整體內容如下:

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class DataSynchronizer */
#ifndef _Included_DataSynchronizer
#define _Included_DataSynchronizer
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     DataSynchronizer
 * Method:    syncData
 * Signature: (Ljava/lang/String;)Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_DataSynchronizer_syncData
  (JNIEnv *, jobject, jstring);
#ifdef __cplusplus
}
#endif
#endif

其中JNIEXPORT jstring JNICALL Java_DataSynchronizer_syncData方法,就是給我們生成的本地C語言方法,我們這裏只需要創建一個C語言文件,名稱爲DataSynchronizer.c,將此頭文件加載進來,實現此方法即可:

#include <jni.h>
#include <stdio.h>
#include "DataSynchronizer.h"
 
JNIEXPORT jstring JNICALL Java_DataSynchronizer_syncData(JNIEnv *env, jobject obj, jstring str) {
   // Step 1: Convert the JNI String (jstring) into C-String (char*)
   const char *inCStr = (*env)->GetStringUTFChars(env, str, NULL);
   if (NULL == inCStr) {
        return NULL;
    }
 
   // Step 2: Perform its intended operations
   printf("In C, the received string is: %s\n", inCStr);
   (*env)->ReleaseStringUTFChars(env, str, inCStr);  // release resources
 
   // Prompt user for a C-string
   char outCStr[128];
   printf("Enter a String: ");
   scanf("%s", outCStr);
 
   // Step 3: Convert the C-string (char*) into JNI String (jstring) and return
   return (*env)->NewStringUTF(env, outCStr);
}

其中需要注意的是,JNIEnv*變量,實際上指的是當前的JNI環境。而jobject變量則類似java中的this關鍵字。jstring則是c語言層面上的字符串,相當於java中的String。整體對應如下

4d17d166-8ef5-4614-ab84-07f1e89b8626

最後,我們來編譯一下:

gcc -fPIC -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/linux" -shared -o libsynchronizer.so DataSynchronizer.c

編譯完畢後,可以看到當前目錄下又多了一個libsynchronizer.so文件(這個文件類似windows上編譯後生成的.dll類庫文件):

8b2a789f-e13e-4826-8d85-cd191f994223

此時我們可以運行了,運行如下命令進行運行:

java -Djava.library.path=. DataSynchronizer

得到結果如下:

java -Djava.library.path=. DataSynchronizer
In C, the received string is: ProcessStep2
Enter a String: sdfsdf
The execute result from C is : sdfsdf

從這裏看到,我們正確的通過java jni技術,調用了C語言底層的邏輯,然後獲取到結果,打印了出來。在netty中,也是利用了jni的技術,然後通過調用底層的C語言邏輯實現,來實現高效的網絡通訊的。感興趣的同學可以扒拉下netty源碼,在transport-native-epoll模塊中,就可以見到具體的實現方法了。

d68985a5-1e23-46e1-ac28-2bae73cf13a3

IO多路複用模型

石中劍,之所以能蕩平英格蘭全境,自然有其最強悍的地方。相應的,Netty,則也是不遑多讓,之所以能夠被各大知名的組件所採用,自然也有其最強悍的地方,而本章節的IO多路複用模型,則是其強悍的理由之一。

在說IO多路複用模型之前,我們先來大致瞭解下Linux文件系統。在Linux系統中,不論是你的鼠標,鍵盤,還是打印機,甚至於連接到本機的socket client端,都是以文件描述符的形式存在於系統中,諸如此類,等等等等,所以可以這麼說,一切皆文件。來看一下系統定義的文件描述符說明:

ea6c09d8-965a-476f-a639-66e726b0bc92

從上面的列表可以看到,文件描述符0,1,2都已經被系統佔用了,當系統啓動的時候,這三個描述符就存在了。其中0代表標準輸入,1代表標準輸出,2代表錯誤輸出。當我們創建新的文件描述符的時候,就會在2的基礎上進行遞增。可以這麼說,文件描述符是爲了管理被打開的文件而創建的系統索引,他代表了文件的身份ID。對標windows的話,你可以認爲和句柄類似,這樣就更容易理解一些。

由於網上對linux文件這塊的原理描述的文章已經非常多了,所以這裏我不再做過多的贅述,感興趣的同學可以從Wikipedia翻閱一下。由於這塊內容比較複雜,不屬於本文普及的內容,建議讀者另行自研,這裏我非常推薦馬士兵老師將linux文件系統這塊,講解的真的非常好。

select模型

此模型是IO多路複用的最早期使用的模型之一,距今已經幾十年了,但是現在依舊有不少應用還在採用此種方式,可見其長生不老。首先來看下其具體的定義(來源於man二類文檔):

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout);

這裏解釋下其具體參數:

參數一:nfds,也即maxfd,最大的文件描述符遞增一。這裏之所以傳最大描述符,爲的就是在遍歷fd_set的時候,限定遍歷範圍。

參數二:readfds,可讀文件描述符集合。

參數三:writefds,可寫文件描述符集合。

參數四:errorfds,異常文件描述符集合。

參數五:timeout,超時時間。在這段時間內沒有檢測到描述符被觸發,則返回。

下面的宏處理,可以對fd_set集合(準確的說是bitmap,一個描述符有變更,則會在描述符對應的索引處置1)進行操作:

FD_CLR(inr fd,fd_set* set)      用來清除描述詞組set中相關fd 的位,即bitmap結構中索引值爲fd的值置爲0。

FD_ISSET(int fd,fd_set *set)   用來測試描述詞組set中相關fd 的位是否爲真,即bitmap結構中某一位是否爲1。

FD_SET(int fd,fd_set*set)   用來設置描述詞組set中相關fd的位,即將bitmap結構中某一位設置爲1,索引值爲fd。

FD_ZERO(fd_set *set)        用來清除描述詞組set的全部位,即將bitmap結構全部清零。

首先來看一段服務端採用了select模型的示例代碼:

//創建server端套接字,獲取文件描述符
    int listenfd = socket(PF_INET,SOCK_STREAM,0);
    if(listenfd < 0) return -1;
    
    //綁定服務器
    bind(listenfd,(struct sockaddr*)&address,sizeof(address));
    //監聽服務器
    listen(listenfd,5); 
    
    struct sockaddr_in client;
    socklen_t addr_len = sizeof(client);
    
    //接收客戶端連接
    int connfd = accept(listenfd,(struct sockaddr*)&client,&addr_len);
    
    //讀緩衝區
    char buff[1024]; 
    
    //讀文件操作符
    fd_set read_fds;  
    
    while(1)
    {
        memset(buff,0,sizeof(buff));
        
        //注意:每次調用select之前都要重新設置文件描述符connfd,因爲文件描述符表會在內核中被修改
        FD_ZERO(&read_fds);
        FD_SET(connfd,&read_fds);
        
        //注意:select會將用戶態中的文件描述符表放到內核中進行修改,內核修改完畢後再返回給用戶態,開銷較大
        ret = select(connfd+1,&read_fds,NULL,NULL,NULL);
        if(ret < 0)
        {
            printf("Fail to select!\n");
            return -1;
        }
        
        //檢測文件描述符表中相關請求是否可讀
        if(FD_ISSET(connfd, &read_fds))
        {
            ret = recv(connfd,buff,sizeof(buff)-1,0);
            printf("receive %d bytes from client: %s \n",ret,buff);
        }
    }

上面的代碼我加了比較詳細的註釋了,大家應該很容易看明白,說白了大概流程其實如下:

首先,創建socket套接字,創建完畢後,會獲取到此套接字的文件描述符。

然後,bind到指定的地址進行監聽listen。這樣,服務端就在特定的端口啓動起來並進行監聽了。

之後,利用開啓accept方法來監聽客戶端的連接請求。一旦有客戶端連接,則將獲取到當前客戶端連接的connection文件描述符。

雙方建立連接之後,就可以進行數據互傳了。需要注意的是,在循環開始的時候,務必每次都要重新設置當前connection的文件描述符,是因爲文件描描述符表在內核中被修改過,如果不重置,將會導致異常的情況。

重新設置文件描述符後,就可以利用select函數從文件描述符表中,來輪詢哪些文件描述符就緒了。此時系統會將用戶態的文件描述符表發送到內核態進行調整,即將準備就緒的文件描述符進行置位,然後再發送給用戶態的應用中來。

用戶通過FD_ISSET方法來輪詢文件描述符,如果數據可讀,則讀取數據即可。

舉個例子,假設此時連接上來了3個客戶端,connection的文件描述符分別爲 4,8,12,那麼其read_fds文件描述符表(bitmap結構)的大致結構爲 00010001000100000....0,由於read_fds文件描述符的長度爲1024位,所以最多允許1024個連接。

c1a99bfa-4601-4b04-8524-d6ef5a800119

而在select的時候,涉及到用戶態和內核態的轉換,所以整體轉換方式如下:

d5653bea-774a-405f-ba6f-c6036e8800c5

所以,綜合起來,select整體還是比較高效和穩定的,但是呈現出來的問題也不少,這些問題進一步限制了其性能發揮:

1. 文件描述符表爲bitmap結構,且有長度爲1024的限制。

2. fdset無法做到重用,每次循環必須重新創建。

3. 頻繁的用戶態和內核態拷貝,性能開銷較大。

4. 需要對文件描述符表進行遍歷,O(n)的輪詢時間複雜度。

poll模型

考慮到select模型的幾個限制,後來進行了改進,這也就是poll模型,既然是select模型的改進版,那麼肯定有其亮眼的地方,一起來看看吧。當然,這次我們依舊是先翻閱linux man二類文檔,因爲這是官方的文檔,對其有着最爲精準的定義。

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

其實,從運行機制上說來,poll所做的功能和select是基本上一樣的,都是等待並檢測一組文件描述符就緒,然後在進行後續的IO處理工作。只不過不同的是,select中,採用的是bitmap結構,長度限定在1024位的文件描述符表,而poll模型則採用的是pollfd結構的數組fds,也正是由於poll模型採用了數組結構,則不會有1024長度限制,使其能夠承受更高的併發。

pollfd結構內容如下:

struct pollfd {
    int   fd;         /* 文件描述符 */
    short events;     /* 關心的事件 */
    short revents;    /* 實際返回的事件 */
};

從上面的結構可以看出,fd很明顯就是指文件描述符,也就是當客戶端連接上來後,fd會將生成的文件描述符保存到這裏;而events則是指用戶想關注的事件;revents則是指實際返回的事件,是由系統內核填充並返回,如果當前的fd文件描述符有狀態變化,則revents的值就會有相應的變化。

events事件列表如下:

image

revents事件列表如下:

image

從列表中可以看出,revents是包含events的。接下來結合示例來看一下:

 //創建server端套接字,獲取文件描述符
    int listenfd = socket(PF_INET,SOCK_STREAM,0);
    if(listenfd < 0) return -1;
    
    //綁定服務器
    bind(listenfd,(struct sockaddr*)&address,sizeof(address));
    //監聽服務器
    listen(listenfd,5); 
    
    struct pollfd pollfds[1];
    socklen_t addr_len = sizeof(client);
    
    //接收客戶端連接
    int connfd = accept(listenfd,(struct sockaddr*)&client,&addr_len);
    
    //放入fd數組
    pollfds[0].fd = connfd;
    pollfds[0].events = POLLIN;
    
    //讀緩衝區
    char buff[1024]; 
    
    //讀文件操作符
    fd_set read_fds;  
    
    while(1)
    {
        memset(buff,0,sizeof(buff));
        
        /**
         ** SELECT模型專用
         ** 注意:每次調用select之前都要重新設置文件描述符connfd,因爲文件描述符表會在內核中被修改
         ** FD_ZERO(&read_fds);
         ** FD_SET(connfd,&read_fds);
        ** 注意:select會將用戶態中的文件描述符表放到內核中進行修改,內核修改完畢後再返回給用戶態,開銷較大
        ** ret = select(connfd+1,&read_fds,NULL,NULL,NULL);
        **/
        
        ret = poll(pollfds, 1, 1000);
        if(ret < 0)
        {
            printf("Fail to poll!\n");
            return -1;
        }
        
        /**
         ** SELECT模型專用
         ** 檢測文件描述符表中相關請求是否可讀
         ** if(FD_ISSET(connfd, &read_fds))
         ** {
         **   ret = recv(connfd,buff,sizeof(buff)-1,0);
         **   printf("receive %d bytes from client: %s \n",ret,buff);
         ** }
         **/
        //檢測文件描述符數組中相關請求
        if(pollfds[0].revents & POLLIN){
            pollfds[0].revents = 0;
            ret = recv(connfd,buff,sizeof(buff)-1,0);
            printf("receive %d bytes from client: %s \n",ret,buff);
        }
    }

由於源碼中,我做了比較詳細的註釋,同時將和select模型不一樣的地方都列了出來,這裏就不再詳細解釋了。總體說來,poll模型比select模型要好用一些,去掉了一些限制,但是仍然避免不了如下的問題:

1. 用戶態和內核態仍需要頻繁切換,因爲revents的賦值是在內核態進行的,然後再推送到用戶態,和select類似,整體開銷較大。

2. 仍需要遍歷數組,時間複雜度爲O(N)。

epoll模型

如果說select模型和poll模型是早期的產物,在性能上有諸多不盡人意之處,那麼自linux 2.6之後新增的epoll模型,則徹底解決了性能問題,一舉使得單機承受百萬併發的課題變得極爲容易。現在可以這麼說,只需要一些簡單的設置更改,然後配合上epoll的性能,實現單機百萬併發輕而易舉。同時,由於epoll整體的優化,使得之前的幾個比較耗費性能的問題不再成爲羈絆,所以也成爲了linux平臺上進行網絡通訊的首選模型。

講解之前,還是linux man文檔鎮樓:linux man epoll 4類文檔 linux man epoll 7類文檔,倆文檔結合着讀,會對epoll有個大概的瞭解。和之前提到的select和poll不同的是,此二者皆屬於系統調用函數,但是epoll則不然,他是存在於內核中的數據結構,可以通過epoll_create,epoll_ctl及epoll_wait三個函數結合來對此數據結構進行操控。

說道epoll_create函數,其作用是在內核中創建一個epoll數據結構實例,然後將返回此實例在系統中的文件描述符。此epoll數據結構的組成其實是一個鏈表結構,我們稱之爲interest list,裏面會註冊連接上來的client的文件描述符。

其簡化工作機制如下:

eb8ce520-4f28-4fb1-8c25-155d78339770

說道epoll_ctl函數,其作用則是對epoll實例進行增刪改查操作。有些類似我們常用的CRUD操作。這個函數操作的對象其實就是epoll數據結構,當有新的client連接上來的時候,他會將此client註冊到epoll中的interest list中,此操作通過附加EPOLL_CTL_ADD標記來實現;當已有的client掉線或者主動下線的時候,他會將下線的client從epoll的interest list中移除,此操作通過附加EPOLL_CTL_DEL標記來實現;當有client的文件描述符有變更的時候,他會將events中的對應的文件描述符進行更新,此操作通過附加EPOLL_CTL_MOD來實現;當interest list中有client已經準備好了,可以進行IO操作的時候,他會將這些clients拿出來,然後放到一個新的ready list裏面。

其簡化工作機制如下:

10076e45-e229-4789-bc36-74d03b694f4e

說道epoll_wait函數,其作用就是掃描ready list,處理準備就緒的client IO,其返回結果即爲準備好進行IO的client的個數。通過遍歷這些準備好的client,就可以輕鬆進行IO處理了。

上面這三個函數是epoll操作的基本函數,但是,想要徹底理解epoll,則需要先了解這三塊內容,即:inode,鏈表,紅黑樹。

在linux內核中,針對當前打開的文件,有一個open file table,裏面記錄的是所有打開的文件描述符信息;同時也有一個inode table,裏面則記錄的是底層的文件描述符信息。這裏假如文件描述符B fork了文件描述符A,雖然在open file table中,我們看新增了一個文件描述符B,但是實際上,在inode table中,A和B的底層是一模一樣的。這裏,將inode table中的內容理解爲windows中的文件屬性,會更加貼切和易懂。這樣存儲的好處就是,無論上層文件描述符怎麼變化,由於epoll監控的數據永遠是inode table的底層數據,那麼我就可以一直能夠監控到文件的各種變化信息,這也是epoll高效的基礎。更多詳細信息,請參閱這兩篇文章:Nonblocking IO & The method to epoll's madness.

簡化流程如下:

4fe2d808-f30e-4e5f-8915-abee8bbc4278

數據存儲這塊解決了,那麼針對連接上來的客戶端socket,該用什麼數據結構保存進來呢?這裏用到了紅黑樹,由於客戶端socket會有頻繁的新增和刪除操作,而紅黑樹這塊時間複雜度僅僅爲O(logN),還是挺高效的。有人會問爲啥不用哈希表呢?當大量的連接頻繁的進行接入或者斷開的時候,擴容或者其他行爲將會產生不少的rehash操作,而且還要考慮哈希衝突的情況。雖然查詢速度的確可以達到o(1),但是rehash或者哈希衝突是不可控的,所以基於這些考量,我認爲紅黑樹佔優一些。

客戶端socket怎麼管理這塊解決了,接下來,當有socket有數據需要進行讀寫事件處理的時候,系統會將已經就緒的socket添加到雙向鏈表中,然後通過epoll_wait方法檢測的時候,其實檢查的就是這個雙向鏈表,由於鏈表中都是就緒的數據,所以避免了針對整個客戶端socket列表進行遍歷的情況,使得整體效率大大提升。
整體的操作流程爲:

首先,利用epoll_create在內核中創建一個epoll對象。其實這個epoll對象,就是一個可以存儲客戶端連接的數據結構。

然後,客戶端socket連接上來,會通過epoll_ctl操作將結果添加到epoll對象的紅黑樹數據結構中。

然後,一旦有socket有事件發生,則會通過回調函數將其添加到ready list雙向鏈表中。

最後,epoll_wait會遍歷鏈表來處理已經準備好的socket,然後通過預先設置的水平觸發或者邊緣觸發來進行數據的感知操作。

從上面的細節可以看出,由於epoll內部監控的是底層的文件描述符信息,可以將變更的描述符直接加入到ready list,無需用戶將所有的描述符再進行傳入。同時由於epoll_wait掃描的是已經就緒的文件描述符,避免了很多無效的遍歷查詢,使得epoll的整體性能大大提升,可以說現在只要談論linux平臺的IO多路複用,epoll已經成爲了不二之選。

水平觸發和邊緣觸發

上面說到了epoll,主要講解了client端怎麼連進來,但是並未詳細的講解epoll_wait怎麼被喚醒的,這裏我將來詳細的講解一下。

水平觸發,意即Level Trigger,邊緣觸發,意即Edge Trigger,如果單從字面意思上理解,則不太容易,但是如果將硬件設計中的水平沿,上升沿,下降沿的概念引進來,則理解起來就容易多了。比如我們可以這樣認爲:

30fa46fc-7a2c-46a5-97cf-8de384403940

如果將上圖中的方塊看做是buffer的話,那麼理解起來則就更加容易了,比如針對水平觸發,buffer只要是一直有數據,則一直通知;而邊緣觸發,則buffer容量發生變化的時候,纔會通知。雖然可以這樣簡單的理解,但是實際上,其細節處理部分,比圖示中展現的更加精細,這裏來詳細的說一下。

邊緣觸發

針對讀操作,也就是當前fd處於EPOLLIN模式下,即可讀。此時意味着有新的數據到來,接收緩衝區可讀,以下buffer都指接收緩衝區:

1. buffer由空變爲非空,意即有數據進來的時候,此過程會觸發通知。

b3ecbe01-edeb-4c2a-ae81-8df7018bf85d

2. buffer原本有些數據,這時候又有新數據進來的時候,數據變多,此過程會觸發通知。

d7d61cfb-fa41-47d6-a1b3-0744c5940aa9

3. buffer中有數據,此時用戶對操作的fd註冊EPOLL_CTL_MOD事件的時候,會觸發通知。

301a9aaf-0589-43b7-9698-4b8435ee1899

針對寫操作,也就是當前fd處於EPOLLOUT模式下,即可寫。此時意味着緩衝區可以寫了,以下buffer都指發送緩衝區:

1. buffer滿了,這時候發送出去一些數據,數據變少,此過程會觸發通知。

a993c8c6-a3f6-4659-bc4f-4bd5ee9bcea1

2. buffer原本有些數據,這時候又發送出去一些數據,數據變少,此過程會觸發通知。

ce83c809-a35a-4307-be8e-b639d49b7ca0

這裏就是ET這種模式觸發的幾種情形,可以看出,基本上都是圍繞着接收緩衝區或者發送緩衝區的狀態變化來進行的。

晦澀難懂?不存在的,舉個栗子:

在服務端,我們開啓邊緣觸發模式,然後將buffer size設爲10個字節,來看看具體的表現形式。

服務端開啓,客戶端連接,發送單字符A到服務端,輸出結果如下:

-->ET Mode: it was triggered once
get 1 bytes of content: A
-->wait to read!

可以看到,由於buffer從空到非空,邊緣觸發通知產生,之後在epoll_wait處阻塞,繼續等待後續事件。

這裏我們變一下,輸入ABCDEFGHIJKLMNOPQ,可以看到,客戶端發送的字符長度超過了服務端buffer size,那麼輸出結果將是怎麼樣的呢?

-->ET Mode: it was triggered once
get 9 bytes of content: ABCDEFGHI
get 8 bytes of content: JKLMNOPQ
-->wait to read!

可以看到,這次發送,由於發送的長度大於buffer size,所以內容被折成兩段進行接收,由於用了邊緣觸發方式,buffer的情況是從空到非空,所以只會產生一次通知。

水平觸發

水平觸發則簡單多了,他包含了邊緣觸發的所有場景,簡而言之如下:

當接收緩衝區不爲空的時候,有數據可讀,則讀事件會一直觸發。

94c0440f-399e-4f83-a84a-4fbe4016c754

當發送緩衝區未滿的時候,可以繼續寫入數據,則寫事件一直會觸發。

c9e44e4a-5e29-46fc-9396-750daf667ecf

同樣的,爲了使表達更清晰,我們也來舉個栗子,按照上述入輸入方式來進行。

服務端開啓,客戶端連接併發送單字符A,可以看到服務端輸出情況如下:

-->LT Mode: it was triggered once!
get 1 bytes of content: A

這個輸出結果,毋庸置疑,由於buffer中有數據,所以水平模式觸發,輸出了結果。

服務端開啓,客戶端連接併發送ABCDEFGHIJKLMNOPQ,可以看到服務端輸出情況如下:

-->LT Mode: it was triggered once!
get 9 bytes of content: ABCDEFGHI
-->LT Mode: it was triggered once!
get 8 bytes of content: JKLMNOPQ

從結果中,可以看出,由於buffer中數據讀取完畢後,還有未讀完的數據,所以水平模式會一直觸發,這也是爲啥這裏水平模式被觸發了兩次的原因。

有了這兩個栗子的比對,不知道聰明的你,get到二者的區別了嗎?

在實際開發過程中,實際上LT更易用一些,畢竟系統幫助我們做了大部分校驗通知工作,之前提到的SELECT和POLL,默認採用的也都是這個。但是需要注意的是,當有成千上萬個客戶端連接上來開始進行數據發送,由於LT的特性,內核會頻繁的處理通知操作,導致其相對於ET來說,比較的耗費系統資源,所以,隨着客戶端的增多,其性能也就越差。

而邊緣觸發,由於監控的是FD的狀態變化,所以整體的系統通知並沒有那麼頻繁,高併發下整體的性能表現也要好很多。但是由於此模式下,用戶需要積極的處理好每一筆數據,帶來的維護代價也是相當大的,稍微不注意就有可能出錯。所以使用起來須要非常小心纔行。

至於二者如何抉擇,諸位就仁者見仁智者見智吧。

行文到這裏,關於epoll的講解基本上完畢了,大家從中是不是學到了很多幹貨呢? 由於從netty研究到linux epoll底層,其難度非常大,可以用曲高和寡來形容,所以在這塊探索的文章是比較少的,很多東西需要自己照着man文檔和源碼一點一點的琢磨(linux源碼詳見eventpoll.c等)。這裏我來糾正一下搜索引擎上,說epoll高性能是因爲利用mmap技術實現了用戶態和內核態的內存共享,所以性能好,我前期被這個觀點誤導了好久,後來下來了linux源碼,翻了一下,並沒有在epoll中翻到mmap的技術點,所以這個觀點是錯誤的。這些錯誤觀點的文章,國內不少,國外也不少,希望大家能審慎抉擇,避免被錯誤帶偏。

所以,epoll高性能的根本就是,其高效的文件描述符處理方式加上頗具特性邊的緣觸發處理模式,以極少的內核態和用戶態的切換,實現了真正意義上的高併發。

手寫epoll服務端

實踐是最好的老師,我們現在已經知道了epoll之劍怎麼嵌入到石頭中的,現在就讓我們不妨嘗試着拔一下看看。手寫epoll服務器,具體細節如下(非C語言coder,代碼有參考):

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <pthread.h>
#include <errno.h>
#include <stdbool.h>
#define MAX_EVENT_NUMBER 1024   //事件總數量
#define BUFFER_SIZE 10          //緩衝區大小,這裏爲10個字節
#define ENABLE_ET 0             //ET模式
/* 文件描述符設爲非阻塞狀態
 * 注意:這個設置很重要,否則體現不出高性能
 */
int SetNonblocking(int fd)
{
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}
/* 將文件描述符fd放入到內核中的epoll數據結構中並將fd設置爲EPOLLIN可讀,同時根據ET開關來決定使用水平觸發還是邊緣觸發模式 
 * 注意:默認爲水平觸發,或上EPOLLET則爲邊緣觸發
*/
void AddFd(int epoll_fd, int fd, bool enable_et)
{
    struct epoll_event event;  //爲當前fd設置事件
    event.data.fd = fd;        //指向當前fd
    event.events = EPOLLIN;    //使得fd可讀
    if(enable_et)
    {
        event.events |= EPOLLET; //設置爲邊緣觸發
    }
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event);  //將fd添加到內核中的epoll實例中
    SetNonblocking(fd);  //設爲非阻塞模式                      
}
/*  LT水平觸發 
 *  注意:水平觸發簡單易用,性能不高,適合低併發場合
 *        一旦緩衝區有數據,則會重複不停的進行通知,直至緩衝區數據讀寫完畢
 */
void lt_process(struct epoll_event* events, int number, int epoll_fd, int listen_fd)
{
    char buf[BUFFER_SIZE];
    int i;
    for(i = 0; i < number; i++) //已經就緒的事件,這些時間可讀或者可寫
    {
        int sockfd = events[i].data.fd; //獲取描述符
        if(sockfd == listen_fd)  //如果監聽類型的描述符,則代表有新的client接入,則將其添加到內核中的epoll結構中
        {
            struct sockaddr_in client_address;
            socklen_t client_addrlength = sizeof(client_address);
            int connfd = accept(listen_fd, (struct sockaddr*)&client_address, &client_addrlength); //創建連接並返回文件描述符(實際進行的三次握手過程)
            AddFd(epoll_fd, connfd, false);  //添加到epoll結構中並初始化爲LT模式
        }
        else if(events[i].events & EPOLLIN) //如果客戶端有數據過來
        {
            printf("-->LT Mode: it was triggered once!\n");
            memset(buf, 0, BUFFER_SIZE); 
            int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
            if(ret <= 0)  //讀取數據完畢後,關閉當前描述符
            {
                close(sockfd);
                continue;
            }
            printf("get %d bytes of content: %s\n", ret, buf);
        }
        else
        {
            printf("something unexpected happened!\n");
        }
    }
}
/*  ET Work mode features: efficient but potentially dangerous */
/*  ET邊緣觸發
 *  注意:邊緣觸發由於內核不會頻繁通知,所以高效,適合高併發場合,但是處理不當將會導致嚴重事故
          其通知機制和觸發方式參見之前講解,由於不會重複觸發,所以需要處理好緩衝區中的數據,避免髒讀髒寫或者數據丟失等
 */
void et_process(struct epoll_event* events, int number, int epoll_fd, int listen_fd)
{
    char buf[BUFFER_SIZE];
    int i;
    for(i = 0; i < number; i++)
    {
        int sockfd = events[i].data.fd;
        if(sockfd == listen_fd) //如果有新客戶端請求過來,將其添加到內核中的epoll結構中並默認置爲ET模式
        {
            struct sockaddr_in client_address;
            socklen_t client_addrlength = sizeof(client_address);
            int connfd = accept(listen_fd, (struct sockaddr*)&client_address, &client_addrlength);
            AddFd(epoll_fd, connfd, true); 
        }
        else if(events[i].events & EPOLLIN) //如果客戶端有數據過來
        {
            printf("-->ET Mode: it was triggered once\n");
            while(1) //循環等待
            {
                memset(buf, 0, BUFFER_SIZE);
                int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
                if(ret < 0)
                {
                    if(errno == EAGAIN || errno == EWOULDBLOCK) //通過EAGAIN檢測,確認數據讀取完畢
                    {
                        printf("-->wait to read!\n");
                        break;
                    }
                    close(sockfd);
                    break;
                }
                else if(ret == 0) //數據讀取完畢,關閉描述符
                {
                    close(sockfd);
                }
                else //數據未讀取完畢,繼續讀取
                {
                    printf("get %d bytes of content: %s\n", ret, buf);
                }
            }
        }
        else
        {
            printf("something unexpected happened!\n");
        }
    }
}
int main(int argc, char* argv[])
{
    const char* ip = "10.0.76.135";
    int port = 9999;
    
    //套接字設置這塊,參見https://www.gta.ufrj.br/ensino/eel878/sockets/sockaddr_inman.html
    int ret = -1;
    struct sockaddr_in address; 
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);
    int listen_fd = socket(PF_INET, SOCK_STREAM, 0);    //創建套接字並返回描述符
    if(listen_fd < 0)
    {
        printf("fail to create socket!\n");
        return -1;
    }
    ret = bind(listen_fd, (struct sockaddr*)&address, sizeof(address)); //綁定本機
    if(ret == -1)
    {
        printf("fail to bind socket!\n");
        return -1;
    }
    ret = listen(listen_fd, 5); //在端口上監聽
    if(ret == -1)
    {
        printf("fail to listen socket!\n");
        return -1;
    }
    struct epoll_event events[MAX_EVENT_NUMBER];
    int epoll_fd = epoll_create(5);  //在內核中創建epoll實例,flag爲5只是爲了分配空間用,實際可以不用帶
    if(epoll_fd == -1)
    {
        printf("fail to create epoll!\n");
        return -1;
    }
    AddFd(epoll_fd, listen_fd, true); //添加文件描述符到epoll對象中
    while(1)
    {
        int ret = epoll_wait(epoll_fd, events, MAX_EVENT_NUMBER, -1); //拿出就緒的文件描述符並進行處理
        if(ret < 0)
        {
            printf("epoll failure!\n");
            break;
        }
        if(ENABLE_ET) //ET處理方式
        {
            et_process(events, ret, epoll_fd, listen_fd);
        }
        else  //LT處理方式
        {
            lt_process(events, ret, epoll_fd, listen_fd);
        }
    }
    close(listen_fd); //退出監聽
    return 0;
}

詳細的註釋我都已經寫上去了,這就是整個epoll server端全部源碼了,僅僅只有200行左右,是不是很驚訝,接下來讓我們來測試下性能,看看能夠達到我們所說的單機百萬併發嗎?其實悄悄的給你說,netty底層的c語言實現,和這個是差不多的。

單機百萬併發實戰

在實際測試過程中,由於要實現高併發,那麼肯定得使用ET模式了。但是由於這塊內容更多的是linux配置的調整,且前人已經有了具體的文章了,所以這裏就不做過多的解釋了,詳細信息可以參閱這篇文章:單機服務器支持千萬級併發長連接的壓力測試 -- c1000k

這裏我們主要是利用vmware虛擬機一主三從,參數調優,來實現百萬併發。

此塊內容由於比較複雜,先暫時放一放,後續將會搭建環境並對此手寫server進行壓測。

參考資料

https://www3.ntu.edu.sg/home/ehchua/programming/java/JavaNativeInterface.html

https://copyconstruct.medium.com/the-method-to-epolls-madness-d9d2d6378642

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章