Netty 如何做到單機百萬併發?

今天給大家分享一篇萬字長文《微言 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 加持,組裝起來也是行雲流水,俯拾即是。但是,任何一個好的架構,都不是一蹴而就實現的,那她經歷了怎樣的心路歷程呢?

①經典的多線程模型

此模型中,服務端起來後,客戶端連接到服務端,服務端會爲每個客戶端開啓一個線程來進行後續的讀寫操作。

客戶端少的時候,整體性能和功能還是可以的,但是如果客戶端非常多的時候,線程的創建將會導致內存的急劇飆升從而導致服務端的性能下降,嚴重者會導致新客戶端連接不上來,更有甚者,服務器直接宕機。

此模型雖然簡單,但是由於其簡單粗暴,所以難堪大用,建議在寫服務端的時候,要徹底的避免此種寫法。

②經典的 Reactor 模型

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

Reactor 模型由於是基於事件處理的,所以一旦有事件被觸發,將會派發到對應的 event handler 中進行處理。

所以在此模型中,有兩個最重要的參與者,列舉如下:

上圖爲 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 模型的演化過程。

上圖模型爲單線程 Reator 模型,Reactor 模型會利用給定的 selectionKeys 進行派發操作,派發到給定的 handler。

之後當有客戶端連接上來的時候,acceptor 會進行 accept 接收操作,之後將接收到的連接和之前派發的 handler 進行組合並啓動。

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

上圖模型爲多 Reactor 模型,此模型中,將原本單個 Reactor 一分爲二,分別爲 mainReactor 和 subReactor。

其中 mainReactor 主要進行客戶端連接方面的處理,客戶端 accept 後發送給 subReactor 進行後續處理處理。

這種模型的好處就是整體職責更加明確,同時對於多 CPU 的機器,系統資源的利用更加高一些。

從 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

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

其中 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。整體對應如下:

最後,我們來編譯一下:

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

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

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

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 模塊中,就可以見到具體的實現方法了。

IO 多路複用模型

石中劍,之所以能蕩平英格蘭全境,自然有其最強悍的地方。

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

在說 IO 多路複用模型之前,我們先來大致瞭解下 Linux 文件系統。

在 Linux 系統中,不論是你的鼠標,鍵盤,還是打印機,甚至於連接到本機的 socket client 端,都是以文件描述符的形式存在於系統中,諸如此類,等等等等。

所以可以這麼說,一切皆文件。來看一下系統定義的文件描述符說明:

從上面的列表可以看到,文件描述符 0,1,2 都已經被系統佔用了,當系統啓動的時候,這三個描述符就存在了。

其中 0 代表標準輸入,1 代表標準輸出,2 代表錯誤輸出。當我們創建新的文件描述符的時候,就會在 2 的基礎上進行遞增。

可以這麼說,文件描述符是爲了管理被打開的文件而創建的系統索引,他代表了文件的身份 ID。對標 Windows 的話,你可以認爲和句柄類似,這樣就更容易理解一些。

由於網上對 Linux 文件這塊的原理描述的文章已經非常多了,所以這裏我不再做過多的贅述,感興趣的同學可以從 Wikipedia 翻閱一下。

由於這塊內容比較複雜,不屬於本文普及的內容,建議讀者另行自研。

select 模型

此模型是 IO 多路複用的最早期使用的模型之一,距今已經幾十年了,但是現在依舊有不少應用還在採用此種方式,可見其長生不老。

首先來看下其具體的定義(來源於 man 二類文檔):

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

這裏解釋下其具體參數:

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

首先來看一段服務端採用了 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);
        }
    }

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

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

重新設置文件描述符後,就可以利用 select 函數從文件描述符表中,來輪詢哪些文件描述符就緒了。

此時系統會將用戶態的文件描述符表發送到內核態進行調整,即將準備就緒的文件描述符進行置位,然後再發送給用戶態的應用中來。

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

舉個例子,假設此時連接上來了 3 個客戶端,connection 的文件描述符分別爲 4,8,12。

那麼其 read_fds 文件描述符表(bitmap 結構)的大致結構爲  00010001000100000....0。

由於 read_fds 文件描述符的長度爲 1024 位,所以最多允許 1024 個連接。

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

所以,綜合起來,select 整體還是比較高效和穩定的,但是呈現出來的問題也不少。

這些問題進一步限制了其性能發揮:

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 事件列表如下:

revents 事件列表如下:

從列表中可以看出,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 模型要好用一些,去掉了一些限制,但是仍然避免不了如下的問題:

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 的文件描述符。

其簡化工作機制如下:

說道 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 裏面。

其簡化工作機制如下:

說道 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 高效的基礎。

簡化流程如下:

數據存儲這塊解決了,那麼針對連接上來的客戶端 socket,該用什麼數據結構保存進來呢?

這裏用到了紅黑樹,由於客戶端 socket 會有頻繁的新增和刪除操作,而紅黑樹這塊時間複雜度僅僅爲 O(logN),還是挺高效的。

有人會問爲啥不用哈希表呢?當大量的連接頻繁的進行接入或者斷開的時候,擴容或者其他行爲將會產生不少的 rehash 操作,而且還要考慮哈希衝突的情況。

雖然查詢速度的確可以達到 o(1),但是 rehash 或者哈希衝突是不可控的,所以基於這些考量,我認爲紅黑樹佔優一些。

客戶端 socket 怎麼管理這塊解決了,接下來,當有 socket 有數據需要進行讀寫事件處理的時候,系統會將已經就緒的 socket 添加到雙向鏈表中,然後通過 epoll_wait 方法檢測的時候。

其實檢查的就是這個雙向鏈表,由於鏈表中都是就緒的數據,所以避免了針對整個客戶端 socket 列表進行遍歷的情況,使得整體效率大大提升。

整體的操作流程爲:

從上面的細節可以看出,由於 epoll 內部監控的是底層的文件描述符信息,可以將變更的描述符直接加入到 ready list,無需用戶將所有的描述符再進行傳入。

同時由於 epoll_wait 掃描的是已經就緒的文件描述符,避免了很多無效的遍歷查詢,使得 epoll 的整體性能大大提升,可以說現在只要談論 Linux 平臺的 IO 多路複用,epoll 已經成爲了不二之選。

水平觸發和邊緣觸發

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

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

比如我們可以這樣認爲:

如果將上圖中的方塊看做是 buffer 的話,那麼理解起來則就更加容易了,比如針對水平觸發,buffer 只要是一直有數據,則一直通知;而邊緣觸發,則 buffer 容量發生變化的時候,纔會通知。

雖然可以這樣簡單的理解,但是實際上,其細節處理部分,比圖示中展現的更加精細,這裏來詳細的說一下。

①邊緣觸發

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

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

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

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

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

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

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

這裏就是 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 的情況是從空到非空,所以只會產生一次通知。

②水平觸發

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

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

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

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

服務端開啓,客戶端連接併發送單字符 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 配置的調整,且前人已經有了具體的文章了,所以這裏就不做過多的解釋了。

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

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

參考資料:

作者:程序詩人

編輯:陶家龍

出處:cnblogs.com/scy251147/p/14763761.html

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