當前位置:首頁 > IT技術 > 其他 > 正文

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

1. 背景

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

2. 進程和線程

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

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

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

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

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

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

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

4.1 BIO

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

image.png

4.1.1 啟動SocketServerBIO服務端

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

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

啟動BIO服務端:
image.png

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

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

image.png

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

image.png

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

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

image.png

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

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

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

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

此時服務端進程5735新增了一個socket:
image.png

通過netstat -antp查看該socket詳細信息:

image.png

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

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

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

image.png

服務端接受數(shù)據并打印:
image.png

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

4.1.4 BIO總結

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

BIO.png

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

4.2 非阻塞NIO

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

image.png

4.2.1 啟動服務端

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

image.png

創(chuàng)建了ID號為29050的進程:
image.png

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

socket_file.29051文件為主線程的系統(tǒng)調用日志??梢园l(fā)現(xiàn)NIO Socket系統(tǒng)調用中,對ServerSocket設置了NONBLOCK,即非阻塞(而BIO中,默認就是阻塞的):

image.png

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

image.png

4.2.2 客戶端連接

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

此時并沒有創(chuàng)建新線程:
image.png

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

image.png

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

image.png

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

socket_file.29051主線程系統(tǒng)調用日志中接受了該數(shù)據。可以看到上面的read返回-1,表示read沒有讀取到數(shù)據,這表明read是是非阻塞的:
image.png

4.2.4 NIO總結

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

4.3 C10K問題

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

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

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

4.4 多路復用NIO

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

4.4.1 select

Select是初期的多路復用器實現(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位機器的位圖類型默認占1024位,64位的機器默認占2048位。以32位機器為例,每一位的下標表示一個文件描述符,例如1011就表示0,1,4號文件描述符??梢钥吹?,位圖通過1024位就可以表示一個0~1023的數(shù)組,非常節(jié)省空間。但是1024位的位圖表示數(shù)組的范圍只有0~1023,如果要監(jiān)控文件描述符超過1024,應該用Poll實現(xiàn)的多路復用器。

select方法中的位圖舉例:

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

select多路復用的流程如下:

select多路復用NIO 2.png

4.4.2 poll

poll將輸入參數(shù)從位圖改成數(shù)組,如下所示:

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

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

poll多路復用NIO 1.png

4.4.3 select和poll總結

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

4.4.4 epoll

上述select和poll的缺點是,內核要以O(n)的時間復雜度遍歷文件描述符,當客戶端連接越多,集合越大,消耗的CPU資源比較高,epoll就解決了這個問題。在內核版本>=2.6則,具體的SelectorProvider為EPollSelectorProvider,否則為默認的PollSelectorProvider??梢妔elect和poll已經過時了,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);  
// 等待事件的產生,收集發(fā)生事件的連接,類似于select()調用。
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中,最后調用epoll_wait等待數(shù)據。
  • 當執(zhí)行 epoll_create 時 ,系統(tǒng)會在內核cache創(chuàng)建一個紅黑樹和就緒鏈表。
  • 當執(zhí)行epoll_ctl放入socket時 ,epoll會檢測上面的紅黑樹是否存在這個socket,存在的話就立即返回,不存在就添加。然后給內核中斷處理程序注冊一個回調函數(shù),告訴內核,如果這個socket句柄的中斷到了,就把它放到準備就緒list鏈表里。如果網卡有數(shù)據到達,向cpu發(fā)出中斷信號,cpu響應中斷,中斷程序就會執(zhí)行前面的回調函數(shù)。紅黑樹是自平衡的二叉排序樹,適合頻繁插入和刪除的場景。增刪查一般時間復雜度是 O(logn)。
  • epoll_wait就只檢查就緒鏈表,如果鏈表不為空,就返回就緒鏈表中就緒的socket,否則就等待。只將有事件發(fā)生的 Socket 集合傳遞給應用程序,不需要像 select/poll 那樣輪詢掃描整個集合(包含有和無事件的 Socket ),大大提高了檢測的效率。

上述流程如下所示:

epoll多路復用NIO.png

觸發(fā)事件

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

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

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

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

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

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

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

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

同理,LT模式可以處理阻塞和非阻塞套接字,而ET模式只支持非阻塞套接字,因為如果是阻塞的,沒有數(shù)據可讀寫時,進程會阻塞在讀寫函數(shù)那里,程序就沒辦法繼續(xù)往下執(zhí)行了。

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

編寫多路復用服務端代碼:

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

運行代碼:

服務端運行,并將系統(tǒng)調用日志記錄到socket_file文件中:

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

客戶端連接服務端:

nc localhost 8890

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

image.png

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

image.png

系統(tǒng)調用過程分析:

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

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

設置serversocket為非阻塞:
image.png

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

image.png

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

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

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

將9號socket注冊到多路復用器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總結

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

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

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