相信很多人知道石中劍這個典故,在此典故中,天命注定的亞瑟很容易的就拔出了這把石中劍,但是由于資歷不被其他人認可,所以他頗費了一番周折才成為了真正意義上的英格蘭全境之王,亞瑟王。
說道這把劍,劍身上銘刻著這樣一句話:ONLY THE KING CAN TAKE THE Sword FROM THE STONE。
雖然典故中的 the king 是指英明之主亞瑟王,但是在本章中,這個 king 就是讀者自己。
我們今天不僅要從百萬并發(fā)基石上拔出這把 epoll 之劍,也就是 Netty,而且要利用這把劍大殺四方,一如當年的亞瑟王憑借此劍統(tǒng)一了英格蘭全境一樣。
說到石中劍 Netty,我們知道他極其強悍的性能以及純異步模型,釋放出了極強的生產(chǎn)力,內(nèi)置的各種編解碼編排,心跳包檢測,粘包拆包處理等,高效且易于使用,以至于很多耳熟能詳?shù)慕M件都在使用,比如 Hadoop,Dubbo 等。
但是他是如何做到這些的呢?本章將會以庖丁解牛的方式,一步一步的來拔出此劍。
Netty 的異步模型
說起 Netty 的異步模型,我相信大多數(shù)人,只要是寫過服務(wù)端的話,都是耳熟能詳?shù)模琤ossGroup 和 workerGroup 被 ServerBootstrap 所驅(qū)動,用起來簡直是如虎添翼。
再加上各種配置化的 handler 加持,組裝起來也是行云流水,俯拾即是。但是,任何一個好的架構(gòu),都不是一蹴而就實現(xiàn)的,那她經(jīng)歷了怎樣的心路歷程呢?
①經(jīng)典的多線程模型
此模型中,服務(wù)端起來后,客戶端連接到服務(wù)端,服務(wù)端會為每個客戶端開啟一個線程來進行后續(xù)的讀寫操作。
客戶端少的時候,整體性能和功能還是可以的,但是如果客戶端非常多的時候,線程的創(chuàng)建將會導(dǎo)致內(nèi)存的急劇飆升從而導(dǎo)致服務(wù)端的性能下降,嚴重者會導(dǎo)致新客戶端連接不上來,更有甚者,服務(wù)器直接宕機。
此模型雖然簡單,但是由于其簡單粗暴,所以難堪大用,建議在寫服務(wù)端的時候,要徹底的避免此種寫法。
②經(jīng)典的 Reactor 模型
由于多線程模型難堪大用,所以更好的模型一直在研究之中,Reactor 模型,作為天選之子,也被引入了進來,由于其強大的基于事件處理的特性,使得其成為異步模型的不二之選。
Reactor 模型由于是基于事件處理的,所以一旦有事件被觸發(fā),將會派發(fā)到對應(yīng)的 event handler 中進行處理。
所以在此模型中,有兩個最重要的參與者,列舉如下:
- Reactor:主要用來將 IO 事件派發(fā)到相對應(yīng)的 handler 中,可以將其想象為打電話時候的分發(fā)總機,你先打電話到總機號碼,然后通過總機,你可以分撥到各個分機號碼。
- Handlers:主要用來處理 IO 事件相關(guān)的具體業(yè)務(wù),可以將其想象為撥通分機號碼后,實際上為你處理事件的員工。
上圖為 Reactor 模型的描述圖,具體來說一下:
Initiation Dispatcher 其實扮演的就是 Reactor 的角色,主要進行 Event Demultiplexer,即事件派發(fā)。
而其內(nèi)部一般都有一個 Acceptor,用于通過對系統(tǒng)資源的操縱來獲取資源句柄,然后交由 Reactor,通過 handle_events 方法派發(fā)至具體的 EventHandler 的。
Synchronous Event Demultiplexer 其實就是 Acceptor 的角色,此角色內(nèi)部通過調(diào)用系統(tǒng)的方法來進行資源操作。
比如說,假如客戶端連接上來,那么將會獲得當前連接,假如需要刪除文件,那么將會獲得當前待操作的文件句柄等等。
這些句柄實際上是要返回給 Reactor 的,然后經(jīng)由 Reactor 派發(fā)下放給具體的 EventHandler。
Event Handler 這里,其實就是具體的事件操作了。其內(nèi)部針對不同的業(yè)務(wù)邏輯,擁有不同的操作方法。
比如說,鑒權(quán) EventHandler 會檢測傳入的連接,驗證其是否在白名單,心跳包 EventHanler 會檢測管道是否空閑。
業(yè)務(wù) EventHandler 會進行具體的業(yè)務(wù)處理,編解碼 EventHandler 會對當前連接傳輸?shù)膬?nèi)容進行編碼解碼操作等等。
由于 Netty 是 Reactor 模型的具體實現(xiàn),所以在編碼的時候,我們可以非常清楚明白的理解 Reactor 的具體使用方式,這里暫時不講,后面會提到。
由于 Doug Lea 寫過一篇關(guān)于 NIO 的文章,整體總結(jié)的極好,所以這里我們就結(jié)合他的文章來詳細分析一下 Reactor 模型的演化過程。
上圖模型為單線程 Reator 模型,Reactor 模型會利用給定的 selectionKeys 進行派發(fā)操作,派發(fā)到給定的 handler。
之后當有客戶端連接上來的時候,acceptor 會進行 accept 接收操作,之后將接收到的連接和之前派發(fā)的 handler 進行組合并啟動。
上圖模型為池化 Reactor 模型,此模型將讀操作和寫操作解耦了出來,當有數(shù)據(jù)過來的時候,將 handler 的系列操作扔到線程池中來進行,極大的提到了整體的吞吐量和處理速度。
上圖模型為多 Reactor 模型,此模型中,將原本單個 Reactor 一分為二,分別為 mainReactor 和 subReactor。
其中 mainReactor 主要進行客戶端連接方面的處理,客戶端 accept 后發(fā)送給 subReactor 進行后續(xù)處理處理。
這種模型的好處就是整體職責(zé)更加明確,同時對于多 CPU 的機器,系統(tǒng)資源的利用更加高一些。
從 Netty 寫的 server 端,就可以看出,boss worker group 對應(yīng)的正是主副 Reactor。
之后 ServerBootstrap 進行 Reactor 的創(chuàng)建操作,里面的 group,channel,option 等進行初始化操作。
而設(shè)置的 childHandler 則是具體的業(yè)務(wù)操作,其底層的事件分發(fā)器則通過調(diào)用 linux 系統(tǒng)級接口 epoll 來實現(xiàn)連接并將其傳給 Reactor。
石中劍 Netty 強悍的原理(JNI)
Netty 之劍之所以鋒利,不僅僅因為其純異步的編排模型,避免了各種阻塞式的操作,同時其內(nèi)部各種設(shè)計精良的組件,終成一統(tǒng)。
且不說讓人眼前一亮的緩沖池設(shè)計,讀寫標隨心而動,摒棄了繁冗復(fù)雜的邊界檢測,用起來著實舒服之極。
原生的流控和高低水位設(shè)計,讓流速控制真的是隨心所欲,鑄就了一道相當堅固的護城河。
齊全的粘包拆包處理方式,讓每一筆數(shù)據(jù)都能夠清晰明了;而高效的空閑檢測機制,則讓心跳包和斷線重連等設(shè)計方案變得如此俯拾即是。
上層的設(shè)計如此優(yōu)秀,其性能又怎能甘居下風(fēng)。由于底層通訊方式完全是 C 語言編寫,然后利用 JNI 機制進行處理,所以整體的性能可以說是達到了原生 C 語言性能的強悍程度。
說道 JNI,這里我覺得有必要詳細說一下,他是我們利用 JAVA 直接調(diào)用 C 語言原生代碼的關(guān)鍵。
JNI,全稱為Java Native Interface,翻譯過來就是 Java 本地接口,他是 Java 調(diào)用 C 語言的一套規(guī)范。具體來看看怎么做的吧。
步驟一,先來寫一個簡單的 Java 調(diào)用函數(shù):
/**
* @author shichaoyang
* @Description: 數(shù)據(jù)同步器
* @date 2020-10-14 19:41
*/
public class DataSynchronizer {
/**
* 加載本地底層C實現(xiàn)庫
*/
static {
System.loadLibrary("synchronizer");
}
/**
* 底層數(shù)據(jù)同步方法
*/
private native String syncData(String status);
/**
* 程序啟動,調(diào)用底層數(shù)據(jù)同步方法
*
* @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 修飾,代表此方法最終將會調(diào)用底層 C 語言實現(xiàn)。main 方法是啟動類,將 C 語言執(zhí)行的結(jié)果接收并打印出來。
然后,打開我們的 Linux 環(huán)境,這里由于我用的是 linux mint,依次執(zhí)行如下命令來設(shè)置環(huán)境:
執(zhí)行apt install default-jdk 安裝java環(huán)境,安裝完畢。
通過update-alternatives --list java 獲取java安裝路徑,這里為:
/usr/lib/jvm/java-11-openjdk-amd64
設(shè)置java環(huán)境變量 export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64
環(huán)境設(shè)置完畢之后,就可以開始進行下一步了。
步驟二,編譯,首先,進入到代碼 DataSynchronizer.c 所在的目錄,然后運行如下命令來編譯 Java 源碼:
javac -h . DataSynchronizer.java
編譯完畢之后,可以看到當前目錄出現(xiàn)了如下幾個文件:
其中 DataSynchronizer.h 是生成的頭文件,這個文件盡量不要修改,整體內(nèi)容如下:
/* 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 語言方法,我們這里只需要創(chuàng)建一個 C 語言文件,名稱為 DataSynchronizer.c。
將此頭文件加載進來,實現(xiàn)此方法即可:
#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: %sn", 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 環(huán)境。而 jobject 變量則類似 Java 中的 this 關(guān)鍵字。
jstring 則是 C 語言層面上的字符串,相當于 Java 中的 String。整體對應(yīng)如下:
最后,我們來編譯一下:
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
得到結(jié)果如下:
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 技術(shù),調(diào)用了 C 語言底層的邏輯,然后獲取到結(jié)果,打印了出來。
在 Netty 中,也是利用了 jni 的技術(shù),然后通過調(diào)用底層的 C 語言邏輯實現(xiàn),來實現(xiàn)高效的網(wǎng)絡(luò)通訊的。
感興趣的同學(xué)可以扒拉下 Netty 源碼,在 transport-native-epoll 模塊中,就可以見到具體的實現(xiàn)方法了。
IO 多路復(fù)用模型
石中劍,之所以能蕩平英格蘭全境,自然有其最強悍的地方。
相應(yīng)的,Netty,則也是不遑多讓,之所以能夠被各大知名的組件所采用,自然也有其最強悍的地方,而本章節(jié)的 IO 多路復(fù)用模型,則是其強悍的理由之一。
再說 IO 多路復(fù)用模型之前,我們先來大致了解下 Linux 文件系統(tǒng)。
在 Linux 系統(tǒng)中,不論是你的鼠標,鍵盤,還是打印機,甚至于連接到本機的 socket client 端,都是以文件描述符的形式存在于系統(tǒng)中,諸如此類,等等等等。
所以可以這么說,一切皆文件。來看一下系統(tǒng)定義的文件描述符說明:
從上面的列表可以看到,文件描述符 0,1,2 都已經(jīng)被系統(tǒng)占用了,當系統(tǒng)啟動的時候,這三個描述符就存在了。
其中 0 代表標準輸入,1 代表標準輸出,2 代表錯誤輸出。當我們創(chuàng)建新的文件描述符的時候,就會在 2 的基礎(chǔ)上進行遞增。
可以這么說,文件描述符是為了管理被打開的文件而創(chuàng)建的系統(tǒng)索引,他代表了文件的身份 ID。對標 Windows 的話,你可以認為和句柄類似,這樣就更容易理解一些。
由于網(wǎng)上對 Linux 文件這塊的原理描述的文章已經(jīng)非常多了,所以這里我不再做過多的贅述,感興趣的同學(xué)可以從 Wikipedia 翻閱一下。
由于這塊內(nèi)容比較復(fù)雜,不屬于本文普及的內(nèi)容,建議讀者另行自研。
select 模型
此模型是 IO 多路復(fù)用的最早期使用的模型之一,距今已經(jīng)幾十年了,但是現(xiàn)在依舊有不少應(yīng)用還在采用此種方式,可見其長生不老。
首先來看下其具體的定義(來源于 man 二類文檔):
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout);
這里解釋下其具體參數(shù):
- 參數(shù)一:nfds,也即 maxfd,最大的文件描述符遞增一。這里之所以傳最大描述符,為的就是在遍歷 fd_set 的時候,限定遍歷范圍。
- 參數(shù)二:readfds,可讀文件描述符集合。
- 參數(shù)三:writefds,可寫文件描述符集合。
- 參數(shù)四:errorfds,異常文件描述符集合。
- 參數(shù)五:timeout,超時時間。在這段時間內(nèi)沒有檢測到描述符被觸發(fā),則返回。
下面的宏處理,可以對 fd_set 集合(準確地說是 bitmap,一個描述符有變更,則會在描述符對應(yīng)的索引處置 1)進行操作:
- FD_CLR(inr fd,fd_set* set) :用來清除描述詞組 set 中相關(guān) fd 的位,即 bitmap 結(jié)構(gòu)中索引值為 fd 的值置為 0。
- FD_ISSET(int fd,fd_set *set):用來測試描述詞組 set 中相關(guān) fd 的位是否為真,即 bitmap 結(jié)構(gòu)中某一位是否為 1。
- FD_SET(int fd,fd_set*set):用來設(shè)置描述詞組 set 中相關(guān) fd 的位,即將 bitmap 結(jié)構(gòu)中某一位設(shè)置為 1,索引值為 fd。
- FD_ZERO(fd_set *set):用來清除描述詞組 set 的全部位,即將 bitmap 結(jié)構(gòu)全部清零。
首先來看一段服務(wù)端采用了 select 模型的示例代碼:
//創(chuàng)建server端套接字,獲取文件描述符
int listenfd = socket(PF_INET,SOCK_STREAM,0);
if(listenfd < 0) return -1;
//綁定服務(wù)器
bind(listenfd,(struct sockaddr*)&address,sizeof(address));
//監(jiān)聽服務(wù)器
listen(listenfd,5);
struct sockaddr_in client;
socklen_t addr_len = sizeof(client);
//接收客戶端連接
int connfd = accept(listenfd,(struct sockaddr*)&client,&addr_len);
//讀緩沖區(qū)
char buff[1024];
//讀文件操作符
fd_set read_fds;
while(1)
{
memset(buff,0,sizeof(buff));
//注意:每次調(diào)用select之前都要重新設(shè)置文件描述符connfd,因為文件描述符表會在內(nèi)核中被修改
FD_ZERO(&read_fds);
FD_SET(connfd,&read_fds);
//注意:select會將用戶態(tài)中的文件描述符表放到內(nèi)核中進行修改,內(nèi)核修改完畢后再返回給用戶態(tài),開銷較大
ret = select(connfd+1,&read_fds,NULL,NULL,NULL);
if(ret < 0)
{
printf("Fail to select!n");
return -1;
}
//檢測文件描述符表中相關(guān)請求是否可讀
if(FD_ISSET(connfd, &read_fds))
{
ret = recv(connfd,buff,sizeof(buff)-1,0);
printf("receive %d bytes from client: %s n",ret,buff);
}
}
上面的代碼我加了比較詳細的注釋了,大家應(yīng)該很容易看明白,說白了大概流程其實如下:
- 首先,創(chuàng)建 socket 套接字,創(chuàng)建完畢后,會獲取到此套接字的文件描述符。
- 然后,bind 到指定的地址進行監(jiān)聽 listen。這樣,服務(wù)端就在特定的端口啟動起來并進行監(jiān)聽了。
- 之后,利用開啟 accept 方法來監(jiān)聽客戶端的連接請求。一旦有客戶端連接,則將獲取到當前客戶端連接的 connection 文件描述符。
雙方建立連接之后,就可以進行數(shù)據(jù)互傳了。需要注意的是,在循環(huán)開始的時候,務(wù)必每次都要重新設(shè)置當前 connection 的文件描述符,是因為文件描描述符表在內(nèi)核中被修改過,如果不重置,將會導(dǎo)致異常的情況。
重新設(shè)置文件描述符后,就可以利用 select 函數(shù)從文件描述符表中,來輪詢哪些文件描述符就緒了。
此時系統(tǒng)會將用戶態(tài)的文件描述符表發(fā)送到內(nèi)核態(tài)進行調(diào)整,即將準備就緒的文件描述符進行置位,然后再發(fā)送給用戶態(tài)的應(yīng)用中來。
用戶通過 FD_ISSET 方法來輪詢文件描述符,如果數(shù)據(jù)可讀,則讀取數(shù)據(jù)即可。
舉個例子,假設(shè)此時連接上來了 3 個客戶端,connection 的文件描述符分別為 4,8,12。
那么其 read_fds 文件描述符表(bitmap 結(jié)構(gòu))的大致結(jié)構(gòu)為 00010001000100000....0。
由于 read_fds 文件描述符的長度為 1024 位,所以最多允許 1024 個連接。
而在 select 的時候,涉及到用戶態(tài)和內(nèi)核態(tài)的轉(zhuǎn)換,所以整體轉(zhuǎn)換方式如下:
所以,綜合起來,select 整體還是比較高效和穩(wěn)定的,但是呈現(xiàn)出來的問題也不少。
這些問題進一步限制了其性能發(fā)揮:
- 文件描述符表為 bitmap 結(jié)構(gòu),且有長度為 1024 的限制。
- fdset 無法做到重用,每次循環(huán)必須重新創(chuàng)建。
- 頻繁的用戶態(tài)和內(nèi)核態(tài)拷貝,性能開銷較大。
- 需要對文件描述符表進行遍歷,O(n) 的輪詢時間復(fù)雜度。
poll 模型
考慮到 select 模型的幾個限制,后來進行了改進,這也就是 poll 模型,既然是 select 模型的改進版,那么肯定有其亮眼的地方,一起來看看吧。
當然,這次我們依舊是先翻閱 linux man 二類文檔,因為這是官方的文檔,對其有著最為精準的定義。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
其實,從運行機制上說來,poll 所做的功能和 select 是基本上一樣的,都是等待并檢測一組文件描述符就緒,然后在進行后續(xù)的 IO 處理工作。
只不過不同的是,select 中,采用的是 bitmap 結(jié)構(gòu),長度限定在 1024 位的文件描述符表,而 poll 模型則采用的是 pollfd 結(jié)構(gòu)的數(shù)組 fds。
也正是由于 poll 模型采用了數(shù)組結(jié)構(gòu),則不會有 1024 長度限制,使其能夠承受更高的并發(fā)。
pollfd 結(jié)構(gòu)內(nèi)容如下:
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 關(guān)心的事件 */
short revents; /* 實際返回的事件 */
};
從上面的結(jié)構(gòu)可以看出,fd 很明顯就是指文件描述符,也就是當客戶端連接上來后,fd 會將生成的文件描述符保存到這里。
而 events 則是指用戶想關(guān)注的事件;revents 則是指實際返回的事件,是由系統(tǒng)內(nèi)核填充并返回,如果當前的 fd 文件描述符有狀態(tài)變化,則 revents 的值就會有相應(yīng)的變化。
events 事件列表如下:
revents 事件列表如下:
從列表中可以看出,revents 是包含 events 的。接下來結(jié)合示例來看一下:
//創(chuàng)建server端套接字,獲取文件描述符
int listenfd = socket(PF_INET,SOCK_STREAM,0);
if(listenfd < 0) return -1;
//綁定服務(wù)器
bind(listenfd,(struct sockaddr*)&address,sizeof(address));
//監(jiān)聽服務(wù)器
listen(listenfd,5);
struct pollfd pollfds[1];
socklen_t addr_len = sizeof(client);
//接收客戶端連接
int connfd = accept(listenfd,(struct sockaddr*)&client,&addr_len);
//放入fd數(shù)組
pollfds[0].fd = connfd;
pollfds[0].events = POLLIN;
//讀緩沖區(qū)
char buff[1024];
//讀文件操作符
fd_set read_fds;
while(1)
{
memset(buff,0,sizeof(buff));
/**
** SELECT模型專用
** 注意:每次調(diào)用select之前都要重新設(shè)置文件描述符connfd,因為文件描述符表會在內(nèi)核中被修改
** FD_ZERO(&read_fds);
** FD_SET(connfd,&read_fds);
** 注意:select會將用戶態(tài)中的文件描述符表放到內(nèi)核中進行修改,內(nèi)核修改完畢后再返回給用戶態(tài),開銷較大
** 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模型專用
** 檢測文件描述符表中相關(guān)請求是否可讀
** if(FD_ISSET(connfd, &read_fds))
** {
** ret = recv(connfd,buff,sizeof(buff)-1,0);
** printf("receive %d bytes from client: %s n",ret,buff);
** }
**/
//檢測文件描述符數(shù)組中相關(guān)請求
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 模型要好用一些,去掉了一些限制,但是仍然避免不了如下的問題:
- 用戶態(tài)和內(nèi)核態(tài)仍需要頻繁切換,因為 revents 的賦值是在內(nèi)核態(tài)進行的,然后再推送到用戶態(tài),和 select 類似,整體開銷較大。
- 仍需要遍歷數(shù)組,時間復(fù)雜度為 O(N)。
epoll 模型
如果說 select 模型和 poll 模型是早期的產(chǎn)物,在性能上有諸多不盡人意之處,那么自 Linux 2.6 之后新增的 epoll 模型,則徹底解決了性能問題,一舉使得單機承受百萬并發(fā)的課題變得極為容易。
現(xiàn)在可以這么說,只需要一些簡單的設(shè)置更改,然后配合上 epoll 的性能,實現(xiàn)單機百萬并發(fā)輕而易舉。
同時,由于 epoll 整體的優(yōu)化,使得之前的幾個比較耗費性能的問題不再成為羈絆,所以也成為了 Linux 平臺上進行網(wǎng)絡(luò)通訊的首選模型。
講解之前,還是 linux man 文檔鎮(zhèn)樓:linux man epoll 4 類文檔 linux man epoll 7 類文檔,倆文檔結(jié)合著讀,會對 epoll 有個大概的了解。
和之前提到的 select 和 poll 不同的是,此二者皆屬于系統(tǒng)調(diào)用函數(shù),但是 epoll 則不然,他是存在于內(nèi)核中的數(shù)據(jù)結(jié)構(gòu)。
可以通過 epoll_create,epoll_ctl 及 epoll_wait 三個函數(shù)結(jié)合來對此數(shù)據(jù)結(jié)構(gòu)進行操控。
說到 epoll_create 函數(shù),其作用是在內(nèi)核中創(chuàng)建一個 epoll 數(shù)據(jù)結(jié)構(gòu)實例,然后將返回此實例在系統(tǒng)中的文件描述符。
此 epoll 數(shù)據(jù)結(jié)構(gòu)的組成其實是一個鏈表結(jié)構(gòu),我們稱之為 interest list,里面會注冊連接上來的 client 的文件描述符。
其簡化工作機制如下:
說道 epoll_ctl 函數(shù),其作用則是對 epoll 實例進行增刪改查操作。有些類似我們常用的 CRUD 操作。
這個函數(shù)操作的對象其實就是 epoll 數(shù)據(jù)結(jié)構(gòu),當有新的 client 連接上來的時候,他會將此 client 注冊到 epoll 中的 interest list 中,此操作通過附加 EPOLL_CTL_ADD 標記來實現(xiàn)。
當已有的 client 掉線或者主動下線的時候,他會將下線的 client從epoll 的 interest list 中移除,此操作通過附加 EPOLL_CTL_DEL 標記來實現(xiàn)。
當有 client 的文件描述符有變更的時候,他會將 events 中的對應(yīng)的文件描述符進行更新,此操作通過附加 EPOLL_CTL_MOD 來實現(xiàn)。
當 interest list 中有 client 已經(jīng)準備好了,可以進行 IO 操作的時候,他會將這些 clients 拿出來,然后放到一個新的 ready list 里面。
其簡化工作機制如下:
說道 epoll_wait 函數(shù),其作用就是掃描 ready list,處理準備就緒的 client IO,其返回結(jié)果即為準備好進行 IO 的 client 的個數(shù)。通過遍歷這些準備好的 client,就可以輕松進行 IO 處理了。
上面這三個函數(shù)是 epoll 操作的基本函數(shù),但是,想要徹底理解 epoll,則需要先了解這三塊內(nèi)容,即:inode,鏈表,紅黑樹。
在 Linux 內(nèi)核中,針對當前打開的文件,有一個 open file table,里面記錄的是所有打開的文件描述符信息;同時也有一個 inode table,里面則記錄的是底層的文件描述符信息。
這里假如文件描述符 B fork 了文件描述符 A,雖然在 open file table 中,我們看新增了一個文件描述符 B,但是實際上,在 inode table 中,A 和 B 的底層是一模一樣的。
這里,將 inode table 中的內(nèi)容理解為 Windows 中的文件屬性,會更加貼切和易懂。
這樣存儲的好處就是,無論上層文件描述符怎么變化,由于 epoll 監(jiān)控的數(shù)據(jù)永遠是 inode table 的底層數(shù)據(jù),那么我就可以一直能夠監(jiān)控到文件的各種變化信息,這也是 epoll 高效的基礎(chǔ)。
簡化流程如下:
數(shù)據(jù)存儲這塊解決了,那么針對連接上來的客戶端 socket,該用什么數(shù)據(jù)結(jié)構(gòu)保存進來呢?
這里用到了紅黑樹,由于客戶端 socket 會有頻繁的新增和刪除操作,而紅黑樹這塊時間復(fù)雜度僅僅為 O(logN),還是挺高效的。
有人會問為啥不用哈希表呢?當大量的連接頻繁的進行接入或者斷開的時候,擴容或者其他行為將會產(chǎn)生不少的 rehash 操作,而且還要考慮哈希沖突的情況。
雖然查詢速度的確可以達到 o(1),但是 rehash 或者哈希沖突是不可控的,所以基于這些考量,我認為紅黑樹占優(yōu)一些。
客戶端 socket 怎么管理這塊解決了,接下來,當有 socket 有數(shù)據(jù)需要進行讀寫事件處理的時候,系統(tǒng)會將已經(jīng)就緒的 socket 添加到雙向鏈表中,然后通過 epoll_wait 方法檢測的時候。
其實檢查的就是這個雙向鏈表,由于鏈表中都是就緒的數(shù)據(jù),所以避免了針對整個客戶端 socket 列表進行遍歷的情況,使得整體效率大大提升。
整體的操作流程為:
- 首先,利用 epoll_create 在內(nèi)核中創(chuàng)建一個 epoll 對象。其實這個 epoll 對象,就是一個可以存儲客戶端連接的數(shù)據(jù)結(jié)構(gòu)。
- 然后,客戶端 socket 連接上來,會通過 epoll_ctl 操作將結(jié)果添加到 epoll 對象的紅黑樹數(shù)據(jù)結(jié)構(gòu)中。
- 然后,一旦有 socket 有事件發(fā)生,則會通過回調(diào)函數(shù)將其添加到 ready list 雙向鏈表中。
- 最后,epoll_wait 會遍歷鏈表來處理已經(jīng)準備好的 socket,然后通過預(yù)先設(shè)置的水平觸發(fā)或者邊緣觸發(fā)來進行數(shù)據(jù)的感知操作。
從上面的細節(jié)可以看出,由于 epoll 內(nèi)部監(jiān)控的是底層的文件描述符信息,可以將變更的描述符直接加入到 ready list,無需用戶將所有的描述符再進行傳入。
同時由于 epoll_wait 掃描的是已經(jīng)就緒的文件描述符,避免了很多無效的遍歷查詢,使得 epoll 的整體性能大大提升,可以說現(xiàn)在只要談?wù)?Linux 平臺的 IO 多路復(fù)用,epoll 已經(jīng)成為了不二之選。
水平觸發(fā)和邊緣觸發(fā)
上面說到了 epoll,主要講解了 client 端怎么連進來,但是并未詳細的講解 epoll_wait 怎么被喚醒的,這里我將來詳細的講解一下。
水平觸發(fā),意即 Level Trigger,邊緣觸發(fā),意即 Edge Trigger,如果單從字面意思上理解,則不太容易,但是如果將硬件設(shè)計中的水平沿,上升沿,下降沿的概念引進來,則理解起來就容易多了。
比如我們可以這樣認為:
如果將上圖中的方塊看做是 buffer 的話,那么理解起來則就更加容易了,比如針對水平觸發(fā),buffer 只要是一直有數(shù)據(jù),則一直通知;而邊緣觸發(fā),則 buffer 容量發(fā)生變化的時候,才會通知。
雖然可以這樣簡單的理解,但是實際上,其細節(jié)處理部分,比圖示中展現(xiàn)的更加精細,這里來詳細的說一下。
①邊緣觸發(fā)
針對讀操作,也就是當前 fd 處于 EPOLLIN 模式下,即可讀。此時意味著有新的數(shù)據(jù)到來,接收緩沖區(qū)可讀,以下 buffer 都指接收緩沖區(qū):
buffer 由空變?yōu)榉强眨饧从袛?shù)據(jù)進來的時候,此過程會觸發(fā)通知:
buffer 原本有些數(shù)據(jù),這時候又有新數(shù)據(jù)進來的時候,數(shù)據(jù)變多,此過程會觸發(fā)通知:
buffer 中有數(shù)據(jù),此時用戶對操作的 fd 注冊 EPOLL_CTL_MOD 事件的時候,會觸發(fā)通知:
針對寫操作,也就是當前 fd 處于 EPOLLOUT 模式下,即可寫。此時意味著緩沖區(qū)可以寫了,以下 buffer 都指發(fā)送緩沖區(qū):
buffer 滿了,這時候發(fā)送出去一些數(shù)據(jù),數(shù)據(jù)變少,此過程會觸發(fā)通知:
buffer 原本有些數(shù)據(jù),這時候又發(fā)送出去一些數(shù)據(jù),數(shù)據(jù)變少,此過程會觸發(fā)通知:
這里就是 ET 這種模式觸發(fā)的幾種情形,可以看出,基本上都是圍繞著接收緩沖區(qū)或者發(fā)送緩沖區(qū)的狀態(tài)變化來進行的。
晦澀難懂?不存在的,舉個栗子:
在服務(wù)端,我們開啟邊緣觸發(fā)模式,然后將 buffer size 設(shè)為 10 個字節(jié),來看看具體的表現(xiàn)形式。
服務(wù)端開啟,客戶端連接,發(fā)送單字符 A 到服務(wù)端,輸出結(jié)果如下:
-->ET Mode: it was triggered once
get 1 bytes of content: A
-->wait to read!
可以看到,由于 buffer 從空到非空,邊緣觸發(fā)通知產(chǎn)生,之后在 epoll_wait 處阻塞,繼續(xù)等待后續(xù)事件。
這里我們變一下,輸入 ABCDEFGHIJKLMNOPQ,可以看到,客戶端發(fā)送的字符長度超過了服務(wù)端 buffer size,那么輸出結(jié)果將是怎么樣的呢?
-->ET Mode: it was triggered once
get 9 bytes of content: ABCDEFGHI
get 8 bytes of content: JKLMNOPQ
-->wait to read!
可以看到,這次發(fā)送,由于發(fā)送的長度大于 buffer size,所以內(nèi)容被折成兩段進行接收,由于用了邊緣觸發(fā)方式,buffer 的情況是從空到非空,所以只會產(chǎn)生一次通知。
②水平觸發(fā)
水平觸發(fā)則簡單多了,他包含了邊緣觸發(fā)的所有場景,簡而言之如下:
當接收緩沖區(qū)不為空的時候,有數(shù)據(jù)可讀,則讀事件會一直觸發(fā):
當發(fā)送緩沖區(qū)未滿的時候,可以繼續(xù)寫入數(shù)據(jù),則寫事件一直會觸發(fā):
同樣的,為了使表達更清晰,我們也來舉個栗子,按照上述入輸入方式來進行。
服務(wù)端開啟,客戶端連接并發(fā)送單字符 A,可以看到服務(wù)端輸出情況如下:
-->LT Mode: it was triggered once!
get 1 bytes of content: A
這個輸出結(jié)果,毋庸置疑,由于 buffer 中有數(shù)據(jù),所以水平模式觸發(fā),輸出了結(jié)果。
服務(wù)端開啟,客戶端連接并發(fā)送 ABCDEFGHIJKLMNOPQ,可以看到服務(wù)端輸出情況如下:
-->LT Mode: it was triggered once!
get 9 bytes of content: ABCDEFGHI
-->LT Mode: it was triggered once!
get 8 bytes of content: JKLMNOPQ
從結(jié)果中,可以看出,由于 buffer 中數(shù)據(jù)讀取完畢后,還有未讀完的數(shù)據(jù),所以水平模式會一直觸發(fā),這也是為啥這里水平模式被觸發(fā)了兩次的原因。
有了這兩個栗子的比對,不知道聰明的你,get 到二者的區(qū)別了嗎?
在實際開發(fā)過程中,實際上 LT 更易用一些,畢竟系統(tǒng)幫助我們做了大部分校驗通知工作,之前提到的 SELECT 和 POLL,默認采用的也都是這個。
但是需要注意的是,當有成千上萬個客戶端連接上來開始進行數(shù)據(jù)發(fā)送,由于 LT 的特性,內(nèi)核會頻繁的處理通知操作,導(dǎo)致其相對于 ET 來說,比較的耗費系統(tǒng)資源,所以,隨著客戶端的增多,其性能也就越差。
而邊緣觸發(fā),由于監(jiān)控的是 FD 的狀態(tài)變化,所以整體的系統(tǒng)通知并沒有那么頻繁,高并發(fā)下整體的性能表現(xiàn)也要好很多。
但是由于此模式下,用戶需要積極的處理好每一筆數(shù)據(jù),帶來的維護代價也是相當大的,稍微不注意就有可能出錯。所以使用起來需要非常小心才行。
至于二者如何抉擇,諸位就仁者見仁智者見智吧。
行文到這里,關(guān)于 epoll 的講解基本上完畢了,大家從中是不是學(xué)到了很多干貨呢?
由于從 Netty 研究到 linux epoll 底層,其難度非常大,可以用曲高和寡來形容,所以在這塊探索的文章是比較少的,很多東西需要自己照著 man 文檔和源碼一點一點地琢磨(linux 源碼詳見 eventpoll.c 等)。
這里我來糾正一下搜索引擎上,說 epoll 高性能是因為利用 mmap 技術(shù)實現(xiàn)了用戶態(tài)和內(nèi)核態(tài)的內(nèi)存共享,所以性能好。
我前期被這個觀點誤導(dǎo)了好久,后來下來了 Linux 源碼,翻了一下,并沒有在 epoll 中翻到 mmap 的技術(shù)點,所以這個觀點是錯誤的。
這些錯誤觀點的文章,國內(nèi)不少,國外也不少,希望大家能審慎抉擇,避免被錯誤帶偏。
所以,epoll 高性能的根本就是,其高效的文件描述符處理方式加上頗具特性邊的緣觸發(fā)處理模式,以極少的內(nèi)核態(tài)和用戶態(tài)的切換,實現(xiàn)了真正意義上的高并發(fā)。
手寫 epoll 服務(wù)端
實踐是最好的老師,我們現(xiàn)在已經(jīng)知道了 epoll 之劍怎么嵌入到石頭中的,現(xiàn)在就讓我們不妨嘗試著拔一下看看。
手寫 epoll 服務(wù)器,具體細節(jié)如下(非 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 //事件總數(shù)量
#define BUFFER_SIZE 10 //緩沖區(qū)大小,這里為10個字節(jié)
#define ENABLE_ET 0 //ET模式
/* 文件描述符設(shè)為非阻塞狀態(tài)
* 注意:這個設(shè)置很重要,否則體現(xiàn)不出高性能
*/
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放入到內(nèi)核中的epoll數(shù)據(jù)結(jié)構(gòu)中并將fd設(shè)置為EPOLLIN可讀,同時根據(jù)ET開關(guān)來決定使用水平觸發(fā)還是邊緣觸發(fā)模式
* 注意:默認為水平觸發(fā),或上EPOLLET則為邊緣觸發(fā)
*/
void AddFd(int epoll_fd, int fd, bool enable_et)
{
struct epoll_event event; //為當前fd設(shè)置事件
event.data.fd = fd; //指向當前fd
event.events = EPOLLIN; //使得fd可讀
if(enable_et)
{
event.events |= EPOLLET; //設(shè)置為邊緣觸發(fā)
}
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event); //將fd添加到內(nèi)核中的epoll實例中
SetNonblocking(fd); //設(shè)為非阻塞模式
}
/* LT水平觸發(fā)
* 注意:水平觸發(fā)簡單易用,性能不高,適合低并發(fā)場合
* 一旦緩沖區(qū)有數(shù)據(jù),則會重復(fù)不停的進行通知,直至緩沖區(qū)數(shù)據(jù)讀寫完畢
*/
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++) //已經(jīng)就緒的事件,這些時間可讀或者可寫
{
int sockfd = events[i].data.fd; //獲取描述符
if(sockfd == listen_fd) //如果監(jiān)聽類型的描述符,則代表有新的client接入,則將其添加到內(nèi)核中的epoll結(jié)構(gòu)中
{
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(listen_fd, (struct sockaddr*)&client_address, &client_addrlength); //創(chuàng)建連接并返回文件描述符(實際進行的三次握手過程)
AddFd(epoll_fd, connfd, false); //添加到epoll結(jié)構(gòu)中并初始化為LT模式
}
else if(events[i].events & EPOLLIN) //如果客戶端有數(shù)據(jù)過來
{
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) //讀取數(shù)據(jù)完畢后,關(guān)閉當前描述符
{
close(sockfd);
continue;
}
printf("get %d bytes of content: %sn", ret, buf);
}
else
{
printf("something unexpected hAppened!n");
}
}
}
/* ET Work mode features: efficient but potentially dangerous */
/* ET邊緣觸發(fā)
* 注意:邊緣觸發(fā)由于內(nèi)核不會頻繁通知,所以高效,適合高并發(fā)場合,但是處理不當將會導(dǎo)致嚴重事故
其通知機制和觸發(fā)方式參見之前講解,由于不會重復(fù)觸發(fā),所以需要處理好緩沖區(qū)中的數(shù)據(jù),避免臟讀臟寫或者數(shù)據(jù)丟失等
*/
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) //如果有新客戶端請求過來,將其添加到內(nèi)核中的epoll結(jié)構(gòu)中并默認置為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) //如果客戶端有數(shù)據(jù)過來
{
printf("-->ET Mode: it was triggered oncen");
while(1) //循環(huán)等待
{
memset(buf, 0, BUFFER_SIZE);
int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
if(ret < 0)
{
if(errno == EAGAIN || errno == EWOULDBLOCK) //通過EAGAIN檢測,確認數(shù)據(jù)讀取完畢
{
printf("-->wait to read!n");
break;
}
close(sockfd);
break;
}
else if(ret == 0) //數(shù)據(jù)讀取完畢,關(guān)閉描述符
{
close(sockfd);
}
else //數(shù)據(jù)未讀取完畢,繼續(xù)讀取
{
printf("get %d bytes of content: %sn", ret, buf);
}
}
}
else
{
printf("something unexpected happened!n");
}
}
}
int main(int argc, char* argv[])
{
const char* ip = "10.0.76.135";
int port = 9999;
//套接字設(shè)置這塊,參見
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); //創(chuàng)建套接字并返回描述符
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); //在端口上監(jiān)聽
if(ret == -1)
{
printf("fail to listen socket!n");
return -1;
}
struct epoll_event events[MAX_EVENT_NUMBER];
int epoll_fd = epoll_create(5); //在內(nèi)核中創(chuàng)建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); //退出監(jiān)聽
return 0;
}
詳細的注釋我都已經(jīng)寫上去了,這就是整個 epoll server 端全部源碼了,僅僅只有 200 行左右,是不是很驚訝。
接下來讓我們來測試下性能,看看能夠達到我們所說的單機百萬并發(fā)嗎?其實悄悄地給你說,Netty 底層的 C 語言實現(xiàn),和這個是差不多的。
單機百萬并發(fā)實戰(zhàn)
在實際測試過程中,由于要實現(xiàn)高并發(fā),那么肯定得使用 ET 模式了。
但是由于這塊內(nèi)容更多的是 Linux 配置的調(diào)整,且前人已經(jīng)有了具體的文章了,所以這里就不做過多的解釋了。
這里我們主要是利用 VMware 虛擬機一主三從,參數(shù)調(diào)優(yōu),來實現(xiàn)百萬并發(fā)。
此塊內(nèi)容由于比較復(fù)雜,先暫時放一放,后續(xù)將會搭建環(huán)境并對此手寫 server 進行壓測。
參考資料:
- https://www3.ntu.edu.sg/home/ehchua/programming/java/JavaNativeInterface.html
- https://copyconstruct.medium.com/the-method-to-epolls-madness-d9d2d6378642
作者:程序詩人
編輯:陶家龍
出處:
cnblogs.com/scy251147/p/14763761.html