當(dāng)前位置:首頁(yè) > IT技術(shù) > 其他 > 正文

深入理解NIO多路復(fù)用
2022-05-31 17:11:28

1. 背景

最近準(zhǔn)備學(xué)習(xí)netty,發(fā)現(xiàn)自己對(duì)NIO相關(guān)的理論知之甚少。本文對(duì)NIO中多路復(fù)用的來(lái)龍去脈經(jīng)過(guò)了詳細(xì)的分析,通過(guò)實(shí)戰(zhàn)抓取系統(tǒng)調(diào)用日志,加深對(duì)底層理論的理解。在此基礎(chǔ)上,梳理了BIO/NIO相關(guān)的流程圖,加強(qiáng)對(duì)NIO的整體流程理解。

2. 進(jìn)程和線程

CPU每經(jīng)過(guò)一個(gè)時(shí)間片,隨機(jī)調(diào)度并執(zhí)行一個(gè)線程。這些線程共用了進(jìn)程的堆、常量區(qū)、方法區(qū)。每個(gè)線程只占用內(nèi)存中的少量?jī)?nèi)存空間,用于存放個(gè)字的棧和程序計(jì)數(shù)器。線程和進(jìn)程的資源關(guān)系如下圖:

進(jìn)程和線程共享區(qū)域.png

3. 內(nèi)核態(tài)和用戶態(tài)

機(jī)器啟動(dòng)時(shí),linux首先加載內(nèi)核代碼,啟動(dòng)內(nèi)核進(jìn)程。創(chuàng)建全局描述符表,用于記錄內(nèi)核的內(nèi)存區(qū)域和用戶程序的內(nèi)存區(qū)域。內(nèi)核用于控制各種硬件,非常敏感,不允許用戶程序直接調(diào)用內(nèi)核代碼,訪問(wèn)內(nèi)核的內(nèi)存區(qū)域。用戶程序必須通過(guò)linux系統(tǒng)調(diào)用,經(jīng)過(guò)linux的驗(yàn)證后,CPU切換到內(nèi)核線程,代替用戶程序執(zhí)行對(duì)應(yīng)功能。
系統(tǒng)調(diào)用過(guò)程如下:

系統(tǒng)調(diào)用過(guò)程.png
具體步驟為:

  1. 用于進(jìn)程代碼內(nèi)執(zhí)行read()方法。read其實(shí)就是要執(zhí)行系統(tǒng)調(diào)用了,在CPU的eax寄存器中保存對(duì)應(yīng)的系統(tǒng)調(diào)用號(hào)3。
  2. CPU執(zhí)行系統(tǒng)中斷指令0x80,查詢中斷描述符表,該指令表示系統(tǒng)調(diào)用。
  3. CPU查詢系統(tǒng)調(diào)用表,發(fā)現(xiàn)eax寄存器中的3表示讀取指令,切換內(nèi)核線程執(zhí)行讀取操作,最后將結(jié)果返回給用戶程序。

4. 系統(tǒng)調(diào)用實(shí)戰(zhàn)

4.1 BIO

通過(guò)Java編寫SocketServer服務(wù)端代碼,Socket服務(wù)端以BIO阻塞的方式接受客戶端連接,并阻塞地接受客戶端數(shù)據(jù),打印數(shù)據(jù),打印完,維持線程不退出。

image.png

4.1.1 啟動(dòng)SocketServerBIO服務(wù)端

啟動(dòng)SocketServerBIO程序,并通過(guò)strace命令跟蹤系統(tǒng)調(diào)用的執(zhí)行。-ff -o socket_file參數(shù)表示將系統(tǒng)調(diào)用的執(zhí)行結(jié)果打印到socket_file文件中,將進(jìn)程/線程的跟蹤結(jié)果輸出到相應(yīng)的socket_file.pid上:

strace -ff -o socket_file /home/hadoop/jdk8/bin/java SocketServerBIO

啟動(dòng)BIO服務(wù)端:
image.png

創(chuàng)建了一個(gè)5735的進(jìn)程號(hào):
image.png

通過(guò)/proc/5735/fd可以看到該進(jìn)程創(chuàng)建了兩個(gè)Socket,分別是IPv4的Socket和IPv6的Socket(不重要,不討論):

image.png

通過(guò)nestat -antp命令可以查看socket詳細(xì)信息。即SocketServerBIO進(jìn)程正在監(jiān)聽(tīng)8888端口:

image.png

查看strace輸出文件,它以進(jìn)程號(hào)5735開(kāi)始,socket_file.5735記錄進(jìn)程的系統(tǒng)調(diào)用過(guò)程,socket_file.5736為主線程的系統(tǒng)調(diào)用過(guò)程,后面的socket_file記錄的是子線程的系統(tǒng)調(diào)用過(guò)程:
image.png

通過(guò)socket_file.5736文件可以看到,主線程創(chuàng)建文件描述符分別是4(不重要,不討論)和5:

image.png

服務(wù)端創(chuàng)建socket,對(duì)應(yīng)文件描述符為5:
image.png
綁定8888端口,并監(jiān)聽(tīng)該端口:
image.png

4.1.2 客戶端發(fā)起連接

客戶端向8888端口發(fā)起連接請(qǐng)求
nc localhost 8888

可以看到服務(wù)端接受到了來(lái)自客戶端36204端口的socket連接請(qǐng)求:
image.png

此時(shí)服務(wù)端進(jìn)程5735新增了一個(gè)socket:
image.png

通過(guò)netstat -antp查看該socket詳細(xì)信息:

image.png

新增兩個(gè)socket連接,其實(shí)是一個(gè)意思,分別表示服務(wù)端創(chuàng)建了一個(gè)socket用于接受本機(jī)的36204端口的客戶端的請(qǐng)求;客戶端創(chuàng)建socket,使用36204端口向服務(wù)端8888端口發(fā)送請(qǐng)求。

通過(guò)socket_file.5736文件可以看到,服務(wù)端接受了36204端口的客戶端請(qǐng)求,創(chuàng)建socket,并創(chuàng)建6號(hào)文件描述符,指向該socket:
image.png
接受socket請(qǐng)求后,程序代碼里面創(chuàng)建子線程需要通過(guò)系統(tǒng)調(diào)用clone()方法,生成的子線程ID號(hào)為7356:
image.png
同時(shí),strace命令也創(chuàng)建了7356線程對(duì)應(yīng)的系統(tǒng)調(diào)用跟蹤信息文件socket_file.7356:
image.png

4.1.3 客戶端發(fā)送數(shù)據(jù)

image.png

服務(wù)端接受數(shù)據(jù)并打?。?br/>image.png

查看socket_file.7356文件,發(fā)現(xiàn)該線程接受了hello world的消息,并等待下一次數(shù)據(jù)傳輸:
image.png

4.1.4 BIO總結(jié)

由于BIO的accept方法是阻塞的,因此單線程阻塞時(shí),如果已經(jīng)建立的連接發(fā)送數(shù)據(jù)到服務(wù)端,這時(shí)服務(wù)端由于阻塞不能處理該數(shù)據(jù),因此BIO模式下,服務(wù)器性能非常差。這時(shí)只有為每個(gè)建立的socket創(chuàng)建處理數(shù)據(jù)的子線程。線程模型如下:

BIO.png

它的缺點(diǎn)就是創(chuàng)建子線程浪費(fèi)資源,可以通過(guò)NIO方式避免創(chuàng)建為每個(gè)連接創(chuàng)建子線程。

4.2 非阻塞NIO

通過(guò)Java編寫SocketServer服務(wù)端代碼,Socket服務(wù)端以NIO非阻塞的方式接受客戶端連接,并非阻塞地接受客戶端的數(shù)據(jù),打印數(shù)據(jù)。全程只有一個(gè)主線程工作。

image.png

4.2.1 啟動(dòng)服務(wù)端

依然使用strace -ff -o socket_file /home/hadoop/jdk8/bin/java SocketServerNIO命令啟動(dòng)NIO服務(wù)端:

image.png

創(chuàng)建了ID號(hào)為29050的進(jìn)程:
image.png

strace系統(tǒng)調(diào)用跟蹤到socket_file文件中,如下所示:
image.png

socket_file.29051文件為主線程的系統(tǒng)調(diào)用日志。可以發(fā)現(xiàn)NIO Socket系統(tǒng)調(diào)用中,對(duì)ServerSocket設(shè)置了NONBLOCK,即非阻塞(而B(niǎo)IO中,默認(rèn)就是阻塞的):

image.png

此時(shí)可以看到accept系統(tǒng)調(diào)用持續(xù)進(jìn)行調(diào)用,-1表示沒(méi)有連接:

image.png

4.2.2 客戶端連接

image.png
服務(wù)端接受客戶端請(qǐng)求,并建立連接
image.png

此時(shí)并沒(méi)有創(chuàng)建新線程:
image.png

socket_file.29051主線程系統(tǒng)調(diào)用日志創(chuàng)建了新的socket連接,用于與客戶端通信,文件描述符ID為6,并設(shè)置NONBLOCK非阻塞:

image.png

4.2.3 客戶端發(fā)送數(shù)據(jù)

image.png

服務(wù)端打印了該數(shù)據(jù):
image.png

socket_file.29051主線程系統(tǒng)調(diào)用日志中接受了該數(shù)據(jù)??梢钥吹缴厦娴膔ead返回-1,表示read沒(méi)有讀取到數(shù)據(jù),這表明read是是非阻塞的:
image.png

4.2.4 NIO總結(jié)

通過(guò)設(shè)置NONBLOCK非阻塞,避免為每個(gè)socket創(chuàng)建對(duì)應(yīng)的線程。

4.3 C10K問(wèn)題

隨著互聯(lián)網(wǎng)的普及,應(yīng)用的用戶群體幾何倍增長(zhǎng),此時(shí)服務(wù)器性能問(wèn)題就出現(xiàn)。最初的服務(wù)器是基于進(jìn)程/線程模型。

  • 對(duì)于BIO,新到來(lái)一個(gè)TCP連接,就需要分配一個(gè)線程。假如有C10K,就需要?jiǎng)?chuàng)建1W個(gè)線程,可想而知單機(jī)是無(wú)法承受的。因此優(yōu)化BIO為NIO。
  • 對(duì)于NIO,只有一個(gè)線程。如果有C10K個(gè)連接,每次就需要進(jìn)行1w次循環(huán)遍歷,處理每個(gè)連接的數(shù)據(jù),每次遍歷都是一次系統(tǒng)調(diào)用。其實(shí)這種O(n)次數(shù)據(jù)的處理可以優(yōu)化成O(1),O(1)表示固定次數(shù)的遍歷。

C10K全稱為10000 clients,即服務(wù)端處理1w個(gè)客戶端連接時(shí),如何處理這么多連接,避免服務(wù)器出現(xiàn)性能問(wèn)題。

4.4 多路復(fù)用NIO

在4.3節(jié)非阻塞IO的代碼中可以發(fā)現(xiàn),每次在用戶態(tài)都會(huì)遍歷所有socket,事件復(fù)雜度為O(n),可以通過(guò)多路復(fù)用NIO代碼,在操作系統(tǒng)內(nèi)核中,讓多路復(fù)用器遍歷所有socket,返回發(fā)生了狀態(tài)變化的m個(gè)socket,用戶程序每次只需要執(zhí)行m次遍歷即可。這樣將遍歷次數(shù)從n次優(yōu)化成為固定的m次。即將事件復(fù)雜度從O(n)優(yōu)化成為O(1)。下面看看多路復(fù)用器實(shí)現(xiàn)的發(fā)展歷程。

4.4.1 select

Select是初期的多路復(fù)用器實(shí)現(xiàn)。它們的接口如下:

int select (int maxfdp, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

參數(shù)解釋:

  • maxfdp:表示要檢查文件描述符的范圍,它的值為最大的文件描述+1。例如,檢查4和17兩個(gè)文件描述符,maxfdp大小就是18。
  • readfds:檢查readfds包含的文件描述符中,哪些文件描述符可讀。
  • writefds:檢查writefds包含的文件描述符中,哪些文件描述符可寫。
  • exceptfds:檢查exceptfds包含的文件描述符中,哪些文件描述符有異常要處理。

fd_set位圖

傳入select方法的參數(shù)類型為fd_set,它是位圖類型。32位機(jī)器的位圖類型默認(rèn)占1024位,64位的機(jī)器默認(rèn)占2048位。以32位機(jī)器為例,每一位的下標(biāo)表示一個(gè)文件描述符,例如1011就表示0,1,4號(hào)文件描述符。可以看到,位圖通過(guò)1024位就可以表示一個(gè)0~1023的數(shù)組,非常節(jié)省空間。但是1024位的位圖表示數(shù)組的范圍只有0~1023,如果要監(jiān)控文件描述符超過(guò)1024,應(yīng)該用Poll實(shí)現(xiàn)的多路復(fù)用器。

select方法中的位圖舉例:

  • 當(dāng)select函數(shù)readfds參數(shù)為1001 0101時(shí),是用戶想告訴內(nèi)核,需要監(jiān)視文件描述符等于0,2,4,7的文件的讀事件的狀態(tài)。
  • 當(dāng)select函數(shù)writefds參數(shù)為1000 0001時(shí),是用戶想告訴內(nèi)核,需要監(jiān)視文件描述符等于0,7的文件的寫事件的狀態(tài)。

select多路復(fù)用的流程如下:

select多路復(fù)用NIO 2.png

4.4.2 poll

poll將輸入?yún)?shù)從位圖改成數(shù)組,如下所示:

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

這意味著雖然數(shù)組占用的空間更大,使用poll能夠監(jiān)控的文件描述符不存在上限。select多路復(fù)用的流程如下:

poll多路復(fù)用NIO 1.png

4.4.3 select和poll總結(jié)

  1. 每次調(diào)用select和poll都是一次性傳入所有要監(jiān)控的文件描述符,只發(fā)生一次系統(tǒng)調(diào)用。
  2. 在內(nèi)核態(tài),內(nèi)核進(jìn)程通過(guò)O(n)的時(shí)間復(fù)雜度遍歷文件描述符的狀態(tài)。比較浪費(fèi)CPU,不過(guò)這比用戶態(tài)O(n)遍歷要好,因?yàn)橛脩魬B(tài)每次遍歷還要進(jìn)行系統(tǒng)調(diào)用。
  3. 內(nèi)核將發(fā)生狀態(tài)變化的文件描述符拷貝到內(nèi)核空間。
  4. 用戶遍歷狀態(tài)變化后的socket,為固定大小m,用戶態(tài)以O(shè)(1)時(shí)間復(fù)雜度遍歷這些socket。

4.4.4 epoll

上述select和poll的缺點(diǎn)是,內(nèi)核要以O(shè)(n)的時(shí)間復(fù)雜度遍歷文件描述符,當(dāng)客戶端連接越多,集合越大,消耗的CPU資源比較高,epoll就解決了這個(gè)問(wèn)題。在內(nèi)核版本>=2.6則,具體的SelectorProvider為EPollSelectorProvider,否則為默認(rèn)的PollSelectorProvider??梢?jiàn)select和poll已經(jīng)過(guò)時(shí)了,epoll才是主流。

epoll原理

完成epoll操作一共有三個(gè)步驟,即三個(gè)函數(shù)互相配合:

//建立一個(gè)epoll對(duì)象(在epoll文件系統(tǒng)中給這個(gè)句柄分配資源);
int epoll_create(int size);  
//向epoll對(duì)象中添加連接的套接字;
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  
// 等待事件的產(chǎn)生,收集發(fā)生事件的連接,類似于select()調(diào)用。
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);  
  • 先用epoll_create創(chuàng)建一個(gè)epoll對(duì)象epfd,再通過(guò)epoll_ctl將需要監(jiān)視的socket添加到epfd中,最后調(diào)用epoll_wait等待數(shù)據(jù)。
  • 當(dāng)執(zhí)行 epoll_create 時(shí) ,系統(tǒng)會(huì)在內(nèi)核cache創(chuàng)建一個(gè)紅黑樹(shù)和就緒鏈表。
  • 當(dāng)執(zhí)行epoll_ctl放入socket時(shí) ,epoll會(huì)檢測(cè)上面的紅黑樹(shù)是否存在這個(gè)socket,存在的話就立即返回,不存在就添加。然后給內(nèi)核中斷處理程序注冊(cè)一個(gè)回調(diào)函數(shù),告訴內(nèi)核,如果這個(gè)socket句柄的中斷到了,就把它放到準(zhǔn)備就緒list鏈表里。如果網(wǎng)卡有數(shù)據(jù)到達(dá),向cpu發(fā)出中斷信號(hào),cpu響應(yīng)中斷,中斷程序就會(huì)執(zhí)行前面的回調(diào)函數(shù)。紅黑樹(shù)是自平衡的二叉排序樹(shù),適合頻繁插入和刪除的場(chǎng)景。增刪查一般時(shí)間復(fù)雜度是 O(logn)。
  • epoll_wait就只檢查就緒鏈表,如果鏈表不為空,就返回就緒鏈表中就緒的socket,否則就等待。只將有事件發(fā)生的 Socket 集合傳遞給應(yīng)用程序,不需要像 select/poll 那樣輪詢掃描整個(gè)集合(包含有和無(wú)事件的 Socket ),大大提高了檢測(cè)的效率。

上述流程如下所示:

epoll多路復(fù)用NIO.png

觸發(fā)事件

epoll有兩種工作模式:LT(level-triggered,水平觸發(fā))模式和ET(edge-triggered,邊緣觸發(fā))模式。

水平觸發(fā)(level-trggered):處于某個(gè)狀態(tài)時(shí)一直觸發(fā)。

  • 只要文件描述符關(guān)聯(lián)的讀內(nèi)核緩沖區(qū)非空,有數(shù)據(jù)可以讀取,就一直從epoll_wait中蘇醒并發(fā)出可讀信號(hào)進(jìn)行通知。
  • 只要文件描述符關(guān)聯(lián)的內(nèi)核寫緩沖區(qū)不滿,有空間可以寫入,就一直從epoll_wait中蘇醒發(fā)出可寫信號(hào)進(jìn)行通知。

邊緣觸發(fā)(edge-triggered):在狀態(tài)轉(zhuǎn)換的邊緣觸發(fā)一次。

  • 當(dāng)文件描述符關(guān)聯(lián)的讀內(nèi)核緩沖區(qū)由空轉(zhuǎn)化為非空的時(shí)候,則從epoll_wait中蘇醒發(fā)出可讀信號(hào)進(jìn)行通知。
  • 當(dāng)文件描述符關(guān)聯(lián)的寫內(nèi)核緩沖區(qū)由滿轉(zhuǎn)化為不滿的時(shí)候,則從epoll_wait中蘇醒發(fā)出可寫信號(hào)進(jìn)行通知。

簡(jiǎn)單的說(shuō),ET模式在可讀和可寫時(shí)僅僅通知一次,而LT模式則會(huì)在條件滿足可讀和可寫時(shí)一直通知。比如,某個(gè)socket的內(nèi)核緩沖區(qū)中從沒(méi)有數(shù)據(jù)變成了有2k數(shù)據(jù),此時(shí)ET模式和LT模式都會(huì)進(jìn)行通知,隨后應(yīng)用程序可以讀取其中的數(shù)據(jù),假設(shè)只讀取了1k,緩沖區(qū)中還剩1k,此時(shí)緩沖區(qū)還是可讀的,如果再次檢查,那么ET模式則不會(huì)通知,而LT模式則會(huì)再次通知。

ET模式的性能比LT模式更好,因?yàn)槿绻到y(tǒng)中有大量你不需要讀寫的就緒文件描述符,使用LT模式之后每次epoll_wait它們都會(huì)返回,這樣會(huì)大大降低處理程序檢索自己關(guān)心的就緒文件描述符的效率!而如果使用ET模式,則在不會(huì)進(jìn)行第二次通知,系統(tǒng)不會(huì)充斥大量你不關(guān)心的就緒文件描述符。

所以,使用ET模式時(shí)需要一次性的把緩沖區(qū)的數(shù)據(jù)讀完為止,也就是一直讀,直到讀到EGAIN(EGAIN說(shuō)明緩沖區(qū)已經(jīng)空了)為止,否則可能出現(xiàn)讀取數(shù)據(jù)不完整的問(wèn)題。

同理,LT模式可以處理阻塞和非阻塞套接字,而ET模式只支持非阻塞套接字,因?yàn)槿绻亲枞?,沒(méi)有數(shù)據(jù)可讀寫時(shí),進(jìn)程會(huì)阻塞在讀寫函數(shù)那里,程序就沒(méi)辦法繼續(xù)往下執(zhí)行了。

默認(rèn)情況下,select、poll都只支持LT模式,epoll采用 LT模式工作,可以設(shè)置為ET模式。

編寫多路復(fù)用服務(wù)端代碼:

通過(guò)Java編寫SocketServer服務(wù)端代碼,創(chuàng)建多路復(fù)用器,將所有socket注冊(cè)到多路復(fù)用器中,多路復(fù)用器負(fù)責(zé)監(jiān)聽(tīng)所有socket狀態(tài)變化,主線程通過(guò)獲取socket狀態(tài),進(jìn)行相應(yīng)處理即可。
image.png

運(yùn)行代碼:

服務(wù)端運(yùn)行,并將系統(tǒng)調(diào)用日志記錄到socket_file文件中:

strace -ff -o socket_file /home/hadoop/jdk8/bin/java SocketServerNIOEpoll

客戶端連接服務(wù)端:

nc localhost 8890

客戶端發(fā)送數(shù)據(jù):

image.png

服務(wù)端接受數(shù)據(jù):

image.png

系統(tǒng)調(diào)用過(guò)程分析:

創(chuàng)建5號(hào)serversocket:
image.png

綁定端口并監(jiān)聽(tīng):
image.png

設(shè)置serversocket為非阻塞:
image.png

創(chuàng)建epoll文件描述符,epoll文件描述符用于注冊(cè)用戶態(tài)的socket:

image.png

將serversocket注冊(cè)到epoll文件描述符中,阻塞等待新連接到來(lái):
image.png

socketserver接受新連接,創(chuàng)建新9號(hào)socket:
image.png

設(shè)置新的9號(hào)socket連接為非阻塞:
image.png

將9號(hào)socket注冊(cè)到多路復(fù)用器epoll的8號(hào)文件描述符中(EPOLLIN就是LT,改成EPOLLIN | EPOLLET就是ET):
image.png

監(jiān)控到9號(hào)文件描述符有讀取事件并處理:
image.png

監(jiān)控epoll文件描述符,監(jiān)控紅黑樹(shù)中有沒(méi)有socket狀態(tài)變化:
image.png

4.4.4 epoll總結(jié)

內(nèi)核事件通知socket狀態(tài)變化,而不是主動(dòng)遍歷所有注冊(cè)的socket,節(jié)省了cpu資源。

本文摘自 :https://blog.51cto.com/u

開(kāi)通會(huì)員,享受整站包年服務(wù)立即開(kāi)通 >