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)系如下圖:
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)用過程如下:
具體步驟為:
- 用于進(jìn)程代碼內(nèi)執(zhí)行read()方法。read其實(shí)就是要執(zhí)行系統(tǒng)調(diào)用了,在CPU的eax寄存器中保存對應(yīng)的系統(tǒng)調(diào)用號3。
- CPU執(zhí)行系統(tǒng)中斷指令
0x80
,查詢中斷描述符表,該指令表示系統(tǒng)調(diào)用。 - 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ù),打印完,維持線程不退出。
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ù)端:
創(chuàng)建了一個5735的進(jìn)程號:
通過/proc/5735/fd
可以看到該進(jìn)程創(chuàng)建了兩個Socket,分別是IPv4的Socket和IPv6的Socket(不重要,不討論):
通過nestat -antp
命令可以查看socket詳細(xì)信息。即SocketServerBIO進(jìn)程正在監(jiān)聽8888端口:
查看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)用過程:
通過socket_file.5736
文件可以看到,主線程創(chuàng)建文件描述符分別是4(不重要,不討論)和5:
服務(wù)端創(chuàng)建socket,對應(yīng)文件描述符為5:
綁定8888端口,并監(jiān)聽該端口:
4.1.2 客戶端發(fā)起連接
客戶端向8888端口發(fā)起連接請求nc localhost 8888
可以看到服務(wù)端接受到了來自客戶端36204端口的socket連接請求:
此時服務(wù)端進(jìn)程5735新增了一個socket:
通過netstat -antp
查看該socket詳細(xì)信息:
新增兩個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:
接受socket請求后,程序代碼里面創(chuàng)建子線程需要通過系統(tǒng)調(diào)用clone()方法,生成的子線程ID號為7356:
同時,strace
命令也創(chuàng)建了7356線程對應(yīng)的系統(tǒng)調(diào)用跟蹤信息文件socket_file.7356:
4.1.3 客戶端發(fā)送數(shù)據(jù)
服務(wù)端接受數(shù)據(jù)并打?。?br/>
查看socket_file.7356文件,發(fā)現(xiàn)該線程接受了hello world
的消息,并等待下一次數(shù)據(jù)傳輸:
4.1.4 BIO總結(jié)
由于BIO的accept方法是阻塞的,因此單線程阻塞時,如果已經(jīng)建立的連接發(fā)送數(shù)據(jù)到服務(wù)端,這時服務(wù)端由于阻塞不能處理該數(shù)據(jù),因此BIO模式下,服務(wù)器性能非常差。這時只有為每個建立的socket創(chuàng)建處理數(shù)據(jù)的子線程。線程模型如下:
它的缺點(diǎn)就是創(chuàng)建子線程浪費(fèi)資源,可以通過NIO方式避免創(chuàng)建為每個連接創(chuàng)建子線程。
4.2 非阻塞NIO
通過Java編寫SocketServer服務(wù)端代碼,Socket服務(wù)端以NIO非阻塞的方式接受客戶端連接,并非阻塞地接受客戶端的數(shù)據(jù),打印數(shù)據(jù)。全程只有一個主線程工作。
4.2.1 啟動服務(wù)端
依然使用strace -ff -o socket_file /home/hadoop/jdk8/bin/java SocketServerNIO
命令啟動NIO服務(wù)端:
創(chuàng)建了ID號為29050的進(jìn)程:
strace
系統(tǒng)調(diào)用跟蹤到socket_file文件中,如下所示:
socket_file.29051文件為主線程的系統(tǒng)調(diào)用日志。可以發(fā)現(xiàn)NIO Socket系統(tǒng)調(diào)用中,對ServerSocket設(shè)置了NONBLOCK,即非阻塞(而BIO中,默認(rèn)就是阻塞的):
此時可以看到accept系統(tǒng)調(diào)用持續(xù)進(jìn)行調(diào)用,-1表示沒有連接:
4.2.2 客戶端連接
服務(wù)端接受客戶端請求,并建立連接
此時并沒有創(chuàng)建新線程:
socket_file.29051主線程系統(tǒng)調(diào)用日志創(chuàng)建了新的socket連接,用于與客戶端通信,文件描述符ID為6,并設(shè)置NONBLOCK非阻塞:
4.2.3 客戶端發(fā)送數(shù)據(jù)
服務(wù)端打印了該數(shù)據(jù):
socket_file.29051主線程系統(tǒng)調(diào)用日志中接受了該數(shù)據(jù)??梢钥吹缴厦娴膔ead返回-1,表示read沒有讀取到數(shù)據(jù),這表明read是是非阻塞的:
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ù)用的流程如下:
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ù)用的流程如下:
4.4.3 select和poll總結(jié)
- 每次調(diào)用select和poll都是一次性傳入所有要監(jiān)控的文件描述符,只發(fā)生一次系統(tǒng)調(diào)用。
- 在內(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)用。
- 內(nèi)核將發(fā)生狀態(tài)變化的文件描述符拷貝到內(nèi)核空間。
- 用戶遍歷狀態(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 ),大大提高了檢測的效率。
上述流程如下所示:
觸發(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)處理即可。
運(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ù):
服務(wù)端接受數(shù)據(jù):
系統(tǒng)調(diào)用過程分析:
創(chuàng)建5號serversocket:
綁定端口并監(jiān)聽:
設(shè)置serversocket為非阻塞:
創(chuàng)建epoll文件描述符,epoll文件描述符用于注冊用戶態(tài)的socket:
將serversocket注冊到epoll文件描述符中,阻塞等待新連接到來:
socketserver接受新連接,創(chuàng)建新9號socket:
設(shè)置新的9號socket連接為非阻塞:
將9號socket注冊到多路復(fù)用器epoll的8號文件描述符中(EPOLLIN就是LT,改成EPOLLIN | EPOLLET就是ET):
監(jiān)控到9號文件描述符有讀取事件并處理:
監(jiān)控epoll文件描述符,監(jiān)控紅黑樹中有沒有socket狀態(tài)變化:
4.4.4 epoll總結(jié)
內(nèi)核事件通知socket狀態(tài)變化,而不是主動遍歷所有注冊的socket,節(jié)省了cpu資源。
本文摘自 :https://blog.51cto.com/u