相信很多人知道石中劍這個典故,在此典故中,天命注定的亞瑟很容易的就拔出了這把石中劍,但是由于資歷不被其他人認可,所以他頗費了一番周折才成為了真正意義上的英格蘭全境之王,亞瑟王。
說道這把劍,劍身上銘刻著這樣一句話: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: 主要用來將 IO 事件派發到相對應的 handler 中,可以將其想象為打電話時候的分發總機,你先打電話到總機號碼,然后通過總機,你可以分撥到各個分機號碼。
- Handlers: 主要用來處理 IO 事件相關的具體業務,可以將其想象為撥通分機號碼后,實際上為你處理事件的員工。
上圖為 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。
基于 Spring Boot + MyBatis Plus + Vue & Element 實現的后臺管理系統 + 用戶小程序,支持 RBAC 動態權限、多租戶、數據權限、工作流、三方登錄、支付、短信、商城等功能
石中劍 Netty 強悍的原理(JNI)
Netty 之劍之所以鋒利,不僅僅因為其純異步的編排模型,避免了各種阻塞式的操作,同時其內部各種設計精良的組件,終成一統。
且不說讓人眼前一亮的緩沖池設計,讀寫標隨心而動,摒棄了繁冗復雜的邊界檢測,用起來著實舒服之極。
原生的流控和高低水位設計,讓流速控制真的是隨心所欲,鑄就了一道相當堅固的護城河。
齊全的粘包拆包處理方式,讓每一筆數據都能夠清晰明了;而高效的空閑檢測機制,則讓心跳包和斷線重連等設計方案變得如此俯拾即是。
上層的設計如此優秀,其性能又怎能甘居下風。由于底層通訊方式完全是 C 語言編寫,然后利用 JNI 機制進行處理,所以整體的性能可以說是達到了原生 C 語言性能的強悍程度。
說道 JNI,這里我覺得有必要詳細說一下,他是我們利用 Java 直接調用 C 語言原生代碼的關鍵。
JNI,全稱為Java Native Interface,翻譯過來就是 Java 本地接口,他是 Java 調用 C 語言的一套規范。具體來看看怎么做的吧。
步驟一,先來寫一個簡單的 Java 調用函數:
/**
*@authorshichaoyang
*@Description:數據同步器
*@date2020-10-1419:41
*/
publicclassDataSynchronizer{
/**
*加載本地底層C實現庫
*/
static{
System.loadLibrary("synchronizer");
}
/**
*底層數據同步方法
*/
privatenativeStringsyncData(Stringstatus);
/**
*程序啟動,調用底層數據同步方法
*
*@paramargs
*/
publicstaticvoidmain(String...args){
Stringrst=newDataSynchronizer().syncData("ProcessStep2");
System.out.println("TheexecuteresultfromCis:"+rst);
}
}
可以看出,是一個非常簡單的 Java 類,此類中,syncData 方法前面帶了 native 修飾,代表此方法最終將會調用底層 C 語言實現。main 方法是啟動類,將 C 語言執行的結果接收并打印出來。
然后,打開我們的 Linux 環境,這里由于我用的是 linux mint,依次執行如下命令來設置環境:
執行aptinstalldefault-jdk 安裝java環境,安裝完畢。
通過update-alternatives --list java 獲取java安裝路徑,這里為:/usr/lib/jvm/java-11-openjdk-amd64
設置java環境變量exportJAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64
環境設置完畢之后,就可以開始進行下一步了。
步驟二,編譯,首先,進入到代碼 DataSynchronizer.c 所在的目錄,然后運行如下命令來編譯 Java 源碼:
javac-h.DataSynchronizer.java
編譯完畢之后,可以看到當前目錄出現了如下幾個文件:
其中 DataSynchronizer.h 是生成的頭文件,這個文件盡量不要修改,整體內容如下:
/*DONOTEDITTHISFILE-itismachinegenerated*/
#include
/*HeaderforclassDataSynchronizer*/
#ifndef_Included_DataSynchronizer
#define_Included_DataSynchronizer
#ifdef__cplusplus
extern"C"{
#endif
/*
*Class:DataSynchronizer
*Method:syncData
*Signature:(Ljava/lang/String;)Ljava/lang/String;
*/
JNIEXPORTjstringJNICALLJava_DataSynchronizer_syncData
(JNIEnv*,jobject,jstring);
#ifdef__cplusplus
}
#endif
#endif
其中 JNIEXPORT jstring JNICALL Java_DataSynchronizer_syncData 方法,就是給我們生成的本地 C 語言方法,我們這里只需要創建一個 C 語言文件,名稱為 DataSynchronizer.c。
將此頭文件加載進來,實現此方法即可:
#include
#include
#include"DataSynchronizer.h"
JNIEXPORTjstringJNICALLJava_DataSynchronizer_syncData(JNIEnv*env,jobjectobj,jstringstr){
//Step1:ConverttheJNIString(jstring)intoC-String(char*)
constchar*inCStr=(*env)->GetStringUTFChars(env,str,NULL);
if(NULL==inCStr){
returnNULL;
}
//Step2:Performitsintendedoperations
printf("InC,thereceivedstringis:%s
",inCStr);
(*env)->ReleaseStringUTFChars(env,str,inCStr);//releaseresources
//PromptuserforaC-string
charoutCStr[128];
printf("EnteraString:");
scanf("%s",outCStr);
//Step3:ConverttheC-string(char*)intoJNIString(jstring)andreturn
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-olibsynchronizer.soDataSynchronizer.c
編譯完畢后,可以看到當前目錄下又多了一個 libsynchronizer.so 文件(這個文件類似 Windows 上編譯后生成的 .dll 類庫文件):
此時我們可以運行了,運行如下命令進行運行:
java-Djava.library.path=.DataSynchronizer
得到結果如下:
java-Djava.library.path=.DataSynchronizer
InC,thereceivedstringis:ProcessStep2
EnteraString:sdfsdf
TheexecuteresultfromCis:sdfsdf
從這里看到,我們正確的通過 java jni 技術,調用了 C 語言底層的邏輯,然后獲取到結果,打印了出來。
在 Netty 中,也是利用了 jni 的技術,然后通過調用底層的 C 語言邏輯實現,來實現高效的網絡通訊的。
感興趣的同學可以扒拉下 Netty 源碼,在 transport-native-epoll 模塊中,就可以見到具體的實現方法了。
基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 實現的后臺管理系統 + 用戶小程序,支持 RBAC 動態權限、多租戶、數據權限、工作流、三方登錄、支付、短信、商城等功能
IO 多路復用模型
石中劍,之所以能蕩平英格蘭全境,自然有其最強悍的地方。
相應的,Netty,則也是不遑多讓,之所以能夠被各大知名的組件所采用,自然也有其最強悍的地方,而本章節的 IO 多路復用模型,則是其強悍的理由之一。
在說 IO 多路復用模型之前,我們先來大致了解下 Linux 文件系統。
在 Linux 系統中,不論是你的鼠標,鍵盤,還是打印機,甚至于連接到本機的 socket client 端,都是以文件描述符的形式存在于系統中,諸如此類,等等等等。
所以可以這么說,一切皆文件。來看一下系統定義的文件描述符說明:
從上面的列表可以看到,文件描述符 0,1,2 都已經被系統占用了,當系統啟動的時候,這三個描述符就存在了。
其中 0 代表標準輸入,1 代表標準輸出,2 代表錯誤輸出。當我們創建新的文件描述符的時候,就會在 2 的基礎上進行遞增。
可以這么說,文件描述符是為了管理被打開的文件而創建的系統索引,他代表了文件的身份 ID。對標 Windows 的話,你可以認為和句柄類似,這樣就更容易理解一些。
由于網上對 Linux 文件這塊的原理描述的文章已經非常多了,所以這里我不再做過多的贅述,感興趣的同學可以從 Wikipedia 翻閱一下。
由于這塊內容比較復雜,不屬于本文普及的內容,建議讀者另行自研。
select 模型
此模型是 IO 多路復用的最早期使用的模型之一,距今已經幾十年了,但是現在依舊有不少應用還在采用此種方式,可見其長生不老。
首先來看下其具體的定義(來源于 man 二類文檔):
intselect(intnfds,fd_set*readfds,fd_set*writefds,fd_set*errorfds,structtimeval*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端套接字,獲取文件描述符
intlistenfd=socket(PF_INET,SOCK_STREAM,0);
if(listenfd0)return-1;
//綁定服務器
bind(listenfd,(structsockaddr*)&address,sizeof(address));
//監聽服務器
listen(listenfd,5);
structsockaddr_inclient;
socklen_taddr_len=sizeof(client);
//接收客戶端連接
intconnfd=accept(listenfd,(structsockaddr*)&client,&addr_len);
//讀緩沖區
charbuff[1024];
//讀文件操作符
fd_setread_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(ret0)
{
printf("Failtoselect!
");
return-1;
}
//檢測文件描述符表中相關請求是否可讀
if(FD_ISSET(connfd,&read_fds))
{
ret=recv(connfd,buff,sizeof(buff)-1,0);
printf("receive%dbytesfromclient:%s
",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 個連接。
而在 select 的時候,涉及到用戶態和內核態的轉換,所以整體轉換方式如下:
所以,綜合起來,select 整體還是比較高效和穩定的,但是呈現出來的問題也不少。
這些問題進一步限制了其性能發揮:
- 文件描述符表為 bitmap 結構,且有長度為 1024 的限制。
- fdset 無法做到重用,每次循環必須重新創建。
- 頻繁的用戶態和內核態拷貝,性能開銷較大。
- 需要對文件描述符表進行遍歷,O(n) 的輪詢時間復雜度。
poll 模型
考慮到 select 模型的幾個限制,后來進行了改進,這也就是 poll 模型,既然是 select 模型的改進版,那么肯定有其亮眼的地方,一起來看看吧。
當然,這次我們依舊是先翻閱 linux man 二類文檔,因為這是官方的文檔,對其有著最為精準的定義。
intpoll(structpollfd*fds,nfds_tnfds,inttimeout);
其實,從運行機制上說來,poll 所做的功能和 select 是基本上一樣的,都是等待并檢測一組文件描述符就緒,然后在進行后續的 IO 處理工作。
只不過不同的是,select 中,采用的是 bitmap 結構,長度限定在 1024 位的文件描述符表,而 poll 模型則采用的是 pollfd 結構的數組 fds。
也正是由于 poll 模型采用了數組結構,則不會有 1024 長度限制,使其能夠承受更高的并發。
pollfd 結構內容如下:
structpollfd{
intfd;/*文件描述符*/
shortevents;/*關心的事件*/
shortrevents;/*實際返回的事件*/
};
從上面的結構可以看出,fd 很明顯就是指文件描述符,也就是當客戶端連接上來后,fd 會將生成的文件描述符保存到這里。
而 events 則是指用戶想關注的事件;revents 則是指實際返回的事件,是由系統內核填充并返回,如果當前的 fd 文件描述符有狀態變化,則 revents 的值就會有相應的變化。
events 事件列表如下:
revents 事件列表如下:
從列表中可以看出,revents 是包含 events 的。接下來結合示例來看一下:
//創建server端套接字,獲取文件描述符
intlistenfd=socket(PF_INET,SOCK_STREAM,0);
if(listenfd0)return-1;
//綁定服務器
bind(listenfd,(structsockaddr*)&address,sizeof(address));
//監聽服務器
listen(listenfd,5);
structpollfdpollfds[1];
socklen_taddr_len=sizeof(client);
//接收客戶端連接
intconnfd=accept(listenfd,(structsockaddr*)&client,&addr_len);
//放入fd數組
pollfds[0].fd=connfd;
pollfds[0].events=POLLIN;
//讀緩沖區
charbuff[1024];
//讀文件操作符
fd_setread_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(ret0)
{
printf("Failtopoll!
");
return-1;
}
/**
**SELECT模型專用
**檢測文件描述符表中相關請求是否可讀
**if(FD_ISSET(connfd,&read_fds))
**{
**ret=recv(connfd,buff,sizeof(buff)-1,0);
**printf("receive%dbytesfromclient:%s
",ret,buff);
**}
**/
//檢測文件描述符數組中相關請求
if(pollfds[0].revents&POLLIN){
pollfds[0].revents=0;
ret=recv(connfd,buff,sizeof(buff)-1,0);
printf("receive%dbytesfromclient:%s
",ret,buff);
}
}
由于源碼中,我做了比較詳細的注釋,同時將和 select 模型不一樣的地方都列了出來,這里就不再詳細解釋了。
總體說來,poll 模型比 select 模型要好用一些,去掉了一些限制,但是仍然避免不了如下的問題:
- 用戶態和內核態仍需要頻繁切換,因為 revents 的賦值是在內核態進行的,然后再推送到用戶態,和 select 類似,整體開銷較大。
- 仍需要遍歷數組,時間復雜度為 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 的文件描述符。
其簡化工作機制如下:
說道 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_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,如果單從字面意思上理解,則不太容易,但是如果將硬件設計中的水平沿,上升沿,下降沿的概念引進來,則理解起來就容易多了。
比如我們可以這樣認為:
如果將上圖中的方塊看做是 buffer 的話,那么理解起來則就更加容易了,比如針對水平觸發,buffer 只要是一直有數據,則一直通知;而邊緣觸發,則 buffer 容量發生變化的時候,才會通知。
雖然可以這樣簡單的理解,但是實際上,其細節處理部分,比圖示中展現的更加精細,這里來詳細的說一下。
①邊緣觸發
針對讀操作,也就是當前 fd 處于 EPOLLIN 模式下,即可讀。此時意味著有新的數據到來,接收緩沖區可讀,以下 buffer 都指接收緩沖區:
buffer 由空變為非空,意即有數據進來的時候,此過程會觸發通知:
buffer 原本有些數據,這時候又有新數據進來的時候,數據變多,此過程會觸發通知:
buffer 中有數據,此時用戶對操作的 fd 注冊 EPOLL_CTL_MOD 事件的時候,會觸發通知:
針對寫操作,也就是當前 fd 處于 EPOLLOUT 模式下,即可寫。此時意味著緩沖區可以寫了,以下 buffer 都指發送緩沖區:
buffer 滿了,這時候發送出去一些數據,數據變少,此過程會觸發通知:
buffer 原本有些數據,這時候又發送出去一些數據,數據變少,此過程會觸發通知:
這里就是 ET 這種模式觸發的幾種情形,可以看出,基本上都是圍繞著接收緩沖區或者發送緩沖區的狀態變化來進行的。
晦澀難懂?不存在的,舉個栗子:
在服務端,我們開啟邊緣觸發模式,然后將 buffer size 設為 10 個字節,來看看具體的表現形式。
服務端開啟,客戶端連接,發送單字符 A 到服務端,輸出結果如下:
-->ETMode:itwastriggeredonce
get1bytesofcontent:A
-->waittoread!
可以看到,由于 buffer 從空到非空,邊緣觸發通知產生,之后在 epoll_wait 處阻塞,繼續等待后續事件。
這里我們變一下,輸入 ABCDEFGHIJKLMNOPQ,可以看到,客戶端發送的字符長度超過了服務端 buffer size,那么輸出結果將是怎么樣的呢?
-->ETMode:itwastriggeredonce
get9bytesofcontent:ABCDEFGHI
get8bytesofcontent:JKLMNOPQ
-->waittoread!
可以看到,這次發送,由于發送的長度大于 buffer size,所以內容被折成兩段進行接收,由于用了邊緣觸發方式,buffer 的情況是從空到非空,所以只會產生一次通知。
②水平觸發
水平觸發則簡單多了,他包含了邊緣觸發的所有場景,簡而言之如下:
當接收緩沖區不為空的時候,有數據可讀,則讀事件會一直觸發:
當發送緩沖區未滿的時候,可以繼續寫入數據,則寫事件一直會觸發:
同樣的,為了使表達更清晰,我們也來舉個栗子,按照上述入輸入方式來進行。
服務端開啟,客戶端連接并發送單字符 A,可以看到服務端輸出情況如下:
-->LTMode:itwastriggeredonce!
get1bytesofcontent:A
這個輸出結果,毋庸置疑,由于 buffer 中有數據,所以水平模式觸發,輸出了結果。
服務端開啟,客戶端連接并發送 ABCDEFGHIJKLMNOPQ,可以看到服務端輸出情況如下:
-->LTMode:itwastriggeredonce!
get9bytesofcontent:ABCDEFGHI
-->LTMode:itwastriggeredonce!
get8bytesofcontent: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
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#defineMAX_EVENT_NUMBER1024//事件總數量
#defineBUFFER_SIZE10//緩沖區大小,這里為10個字節
#defineENABLE_ET0//ET模式
/*文件描述符設為非阻塞狀態
*注意:這個設置很重要,否則體現不出高性能
*/
intSetNonblocking(intfd)
{
intold_option=fcntl(fd,F_GETFL);
intnew_option=old_option|O_NONBLOCK;
fcntl(fd,F_SETFL,new_option);
returnold_option;
}
/*將文件描述符fd放入到內核中的epoll數據結構中并將fd設置為EPOLLIN可讀,同時根據ET開關來決定使用水平觸發還是邊緣觸發模式
*注意:默認為水平觸發,或上EPOLLET則為邊緣觸發
*/
voidAddFd(intepoll_fd,intfd,boolenable_et)
{
structepoll_eventevent;//為當前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水平觸發
*注意:水平觸發簡單易用,性能不高,適合低并發場合
*一旦緩沖區有數據,則會重復不停的進行通知,直至緩沖區數據讀寫完畢
*/
voidlt_process(structepoll_event*events,intnumber,intepoll_fd,intlisten_fd)
{
charbuf[BUFFER_SIZE];
inti;
for(i=0;i//已經就緒的事件,這些時間可讀或者可寫
{
intsockfd=events[i].data.fd;//獲取描述符
if(sockfd==listen_fd)//如果監聽類型的描述符,則代表有新的client接入,則將其添加到內核中的epoll結構中
{
structsockaddr_inclient_address;
socklen_tclient_addrlength=sizeof(client_address);
intconnfd=accept(listen_fd,(structsockaddr*)&client_address,&client_addrlength);//創建連接并返回文件描述符(實際進行的三次握手過程)
AddFd(epoll_fd,connfd,false);//添加到epoll結構中并初始化為LT模式
}
elseif(events[i].events&EPOLLIN)//如果客戶端有數據過來
{
printf("-->LTMode:itwastriggeredonce!
");
memset(buf,0,BUFFER_SIZE);
intret=recv(sockfd,buf,BUFFER_SIZE-1,0);
if(ret<=?0)//讀取數據完畢后,關閉當前描述符
{
close(sockfd);
continue;
}
printf("get%dbytesofcontent:%s
",ret,buf);
}
else
{
printf("somethingunexpectedhappened!
");
}
}
}
/*ETWorkmodefeatures:efficientbutpotentiallydangerous*/
/*ET邊緣觸發
*注意:邊緣觸發由于內核不會頻繁通知,所以高效,適合高并發場合,但是處理不當將會導致嚴重事故
其通知機制和觸發方式參見之前講解,由于不會重復觸發,所以需要處理好緩沖區中的數據,避免臟讀臟寫或者數據丟失等
*/
voidet_process(structepoll_event*events,intnumber,intepoll_fd,intlisten_fd)
{
charbuf[BUFFER_SIZE];
inti;
for(i=0;iintsockfd=events[i].data.fd;
if(sockfd==listen_fd)//如果有新客戶端請求過來,將其添加到內核中的epoll結構中并默認置為ET模式
{
structsockaddr_inclient_address;
socklen_tclient_addrlength=sizeof(client_address);
intconnfd=accept(listen_fd,(structsockaddr*)&client_address,&client_addrlength);
AddFd(epoll_fd,connfd,true);
}
elseif(events[i].events&EPOLLIN)//如果客戶端有數據過來
{
printf("-->ETMode:itwastriggeredonce
");
while(1)//循環等待
{
memset(buf,0,BUFFER_SIZE);
intret=recv(sockfd,buf,BUFFER_SIZE-1,0);
if(ret0)
{
if(errno==EAGAIN||errno==EWOULDBLOCK)//通過EAGAIN檢測,確認數據讀取完畢
{
printf("-->waittoread!
");
break;
}
close(sockfd);
break;
}
elseif(ret==0)//數據讀取完畢,關閉描述符
{
close(sockfd);
}
else//數據未讀取完畢,繼續讀取
{
printf("get%dbytesofcontent:%s
",ret,buf);
}
}
}
else
{
printf("somethingunexpectedhappened!
");
}
}
}
intmain(intargc,char*argv[])
{
constchar*ip="10.0.76.135";
intport=9999;
//套接字設置這塊,參見https://www.gta.ufrj.br/ensino/eel878/sockets/sockaddr_inman.html
intret=-1;
structsockaddr_inaddress;
bzero(&address,sizeof(address));
address.sin_family=AF_INET;
inet_pton(AF_INET,ip,&address.sin_addr);
address.sin_port=htons(port);
intlisten_fd=socket(PF_INET,SOCK_STREAM,0);//創建套接字并返回描述符
if(listen_fd0)
{
printf("failtocreatesocket!
");
return-1;
}
ret=bind(listen_fd,(structsockaddr*)&address,sizeof(address));//綁定本機
if(ret==-1)
{
printf("failtobindsocket!
");
return-1;
}
ret=listen(listen_fd,5);//在端口上監聽
if(ret==-1)
{
printf("failtolistensocket!
");
return-1;
}
structepoll_eventevents[MAX_EVENT_NUMBER];
intepoll_fd=epoll_create(5);//在內核中創建epoll實例,flag為5只是為了分配空間用,實際可以不用帶
if(epoll_fd==-1)
{
printf("failtocreateepoll!
");
return-1;
}
AddFd(epoll_fd,listen_fd,true);//添加文件描述符到epoll對象中
while(1)
{
intret=epoll_wait(epoll_fd,events,MAX_EVENT_NUMBER,-1);//拿出就緒的文件描述符并進行處理
if(ret0)
{
printf("epollfailure!
");
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);//退出監聽
return0;
}
詳細的注釋我都已經寫上去了,這就是整個 epoll server 端全部源碼了,僅僅只有 200 行左右,是不是很驚訝。
接下來讓我們來測試下性能,看看能夠達到我們所說的單機百萬并發嗎?其實悄悄的給你說,Netty 底層的 C 語言實現,和這個是差不多的。
單機百萬并發實戰
在實際測試過程中,由于要實現高并發,那么肯定得使用 ET 模式了。
但是由于這塊內容更多的是 Linux 配置的調整,且前人已經有了具體的文章了,所以這里就不做過多的解釋了。
這里我們主要是利用 VMware 虛擬機一主三從,參數調優,來實現百萬并發。
此塊內容由于比較復雜,先暫時放一放,后續將會搭建環境并對此手寫 server 進行壓測。
審核編輯 :李倩
-
多線程
+關注
關注
0文章
278瀏覽量
20075 -
模型
+關注
關注
1文章
3313瀏覽量
49232 -
服務端
+關注
關注
0文章
66瀏覽量
7058
原文標題:Netty如何做到單機百萬并發?
文章出處:【微信號:芋道源碼,微信公眾號:芋道源碼】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論