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

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

1. 背景

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

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

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

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

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

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

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

  1. 用于進(jìn)程代碼內(nèi)執(zhí)行read()方法。read其實(shí)就是要執(zhí)行系統(tǒng)調(diào)用了,在CPU的eax寄存器中保存對應(yīng)的系統(tǒng)調(dià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

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

image.png

4.1.1 啟動SocketServerBIO服務(wù)端

啟動SocketServerBIO程序,并通過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

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

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

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

image.png

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

image.png

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

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

image.png

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

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

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

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

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

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

image.png

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

通過socket_file.5736文件可以看到,服務(wù)端接受了36204端口的客戶端請求,創(chuàng)建socket,并創(chuàng)建6號文件描述符,指向該socket:
image.png
接受socket請求后,程序代碼里面創(chuàng)建子線程需要通過系統(tǒng)調(diào)用clone()方法,生成的子線程ID號為7356:
image.png
同時,strace命令也創(chuàng)建了7356線程對應(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方法是阻塞的,因此單線程阻塞時,如果已經(jīng)建立的連接發(fā)送數(shù)據(jù)到服務(wù)端,這時服務(wù)端由于阻塞不能處理該數(shù)據(jù),因此BIO模式下,服務(wù)器性能非常差。這時只有為每個建立的socket創(chuàng)建處理數(shù)據(jù)的子線程。線程模型如下:

BIO.png

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

4.2 非阻塞NIO

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

image.png

4.2.1 啟動服務(wù)端

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

image.png

創(chuàng)建了ID號為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)用中,對ServerSocket設(shè)置了NONBLOCK,即非阻塞(而BIO中,默認(rèn)就是阻塞的):

image.png

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

image.png

4.2.2 客戶端連接

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

此時并沒有創(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沒有讀取到數(shù)據(jù),這表明read是是非阻塞的:
image.png

4.2.4 NIO總結(jié)

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

4.3 C10K問題

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

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

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

4.4 多路復(fù)用NIO

在4.3節(jié)非阻塞IO的代碼中可以發(fā)現(xiàn),每次在用戶態(tài)都會遍歷所有socket,事件復(fù)雜度為O(n),可以通過多路復(fù)用NIO代碼,在操作系統(tǒng)內(nèi)核中,讓多路復(fù)用器遍歷所有socket,返回發(fā)生了狀態(tài)變化的m個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兩個文件描述符,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)表示一個文件描述符,例如1011就表示0,1,4號文件描述符??梢钥吹剑粓D通過1024位就可以表示一個0~1023的數(shù)組,非常節(jié)省空間。但是1024位的位圖表示數(shù)組的范圍只有0~1023,如果要監(jiān)控文件描述符超過1024,應(yīng)該用Poll實(shí)現(xiàn)的多路復(fù)用器。

select方法中的位圖舉例:

  • 當(dāng)select函數(shù)readfds參數(shù)為1001 0101時,是用戶想告訴內(nèi)核,需要監(jiān)視文件描述符等于0,2,4,7的文件的讀事件的狀態(tài)。
  • 當(dāng)select函數(shù)writefds參數(shù)為1000 0001時,是用戶想告訴內(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)程通過O(n)的時間復(fù)雜度遍歷文件描述符的狀態(tài)。比較浪費(fèi)CPU,不過這比用戶態(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)時間復(fù)雜度遍歷這些socket。

4.4.4 epoll

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

epoll原理

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

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

上述流程如下所示:

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

觸發(fā)事件

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

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

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

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

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

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

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

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

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

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

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

通過Java編寫SocketServer服務(wù)端代碼,創(chuàng)建多路復(fù)用器,將所有socket注冊到多路復(fù)用器中,多路復(fù)用器負(fù)責(zé)監(jiān)聽所有socket狀態(tài)變化,主線程通過獲取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)用過程分析:

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

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

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

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

image.png

將serversocket注冊到epoll文件描述符中,阻塞等待新連接到來:
image.png

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

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

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

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

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

4.4.4 epoll總結(jié)

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

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

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