說道本章標題,相信很多人知道我在暗喻石中劍這個典故,在此典故中,天命註定的亞瑟很容易的就拔出了這把石中劍,但是由於資歷不被其他人認可,所以他頗費了一番周折才成爲了真正意義上的英格蘭全境之王,亞瑟王。說道這把劍,劍身上銘刻着這樣一句話: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. 經典的多線程模型
此模型中,服務端起來後,客戶端連接到服務端,服務端會爲每個客戶端開啓一個線程來進行後續的讀寫操作。客戶端少的時候,整體性能和功能還是可以的,但是如果客戶端非常多的時候,線程的創建將會導致內存的急劇飆升從而導致服務端的性能下降,嚴重者會導致新客戶端連接不上來,更有甚者,服務器直接宕機。此模型雖然簡單,但是由於其簡單粗暴,所以難堪大用,建議在寫服務端的時候,要徹底的避免此種寫法。
2. 經典的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。
石中劍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翻閱一下。由於這塊內容比較複雜,不屬於本文普及的內容,建議讀者另行自研,這裏我非常推薦馬士兵老師將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個連接。
而在select的時候,涉及到用戶態和內核態的轉換,所以整體轉換方式如下:
所以,綜合起來,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事件列表如下:
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模型要好用一些,去掉了一些限制,但是仍然避免不了如下的問題:
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的文件描述符。
其簡化工作機制如下:
說道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高效的基礎。更多詳細信息,請參閱這兩篇文章:Nonblocking IO & The method to epoll's madness.
簡化流程如下:
數據存儲這塊解決了,那麼針對連接上來的客戶端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都指接收緩衝區:
1. buffer由空變爲非空,意即有數據進來的時候,此過程會觸發通知。
2. buffer原本有些數據,這時候又有新數據進來的時候,數據變多,此過程會觸發通知。
3. buffer中有數據,此時用戶對操作的fd註冊EPOLL_CTL_MOD事件的時候,會觸發通知。
針對寫操作,也就是當前fd處於EPOLLOUT模式下,即可寫。此時意味着緩衝區可以寫了,以下buffer都指發送緩衝區:
1. buffer滿了,這時候發送出去一些數據,數據變少,此過程會觸發通知。
2. 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配置的調整,且前人已經有了具體的文章了,所以這裏就不做過多的解釋了,詳細信息可以參閱這篇文章:單機服務器支持千萬級併發長連接的壓力測試 -- c1000k
這裏我們主要是利用vmware虛擬機一主三從,參數調優,來實現百萬併發。
此塊內容由於比較複雜,先暫時放一放,後續將會搭建環境並對此手寫server進行壓測。
參考資料
https://www3.ntu.edu.sg/home/ehchua/programming/java/JavaNativeInterface.html
https://copyconstruct.medium.com/the-method-to-epolls-madness-d9d2d6378642