Redis網(wǎng)絡(luò)模型有多強?
如果面試官問我:Redis為什么這么快?
我肯定會說:因為Redis是內(nèi)存數(shù)據(jù)庫!如果不是直接把數(shù)據(jù)放在內(nèi)存里,甭管怎么優(yōu)化數(shù)據(jù)結(jié)構(gòu)、設(shè)計怎樣的網(wǎng)絡(luò)I/O模型,都不可能達到如今這般的執(zhí)行效率。
但是這么回答多半會讓我直接回去等通知了。。。因為面試官想聽到的就是數(shù)據(jù)結(jié)構(gòu)和網(wǎng)絡(luò)模型方面的回答,雖然這兩者只是在內(nèi)存基礎(chǔ)上的錦上添花。
說這些并非為了強調(diào)網(wǎng)絡(luò)模型并不重要,恰恰相反,它是Redis實現(xiàn)高吞吐量的重要底層支撐,是“高性能”的重要原因,卻不是“快”的直接理由。
本文將從BIO開始介紹,經(jīng)過NIO、多路復(fù)用,最終說回Redis的Reactor模型,力求詳盡。本文與其他文章的不同點主要在于:
1、不會介紹同步阻塞I/O、同步非阻塞I/O、異步阻塞I/O、異步非阻塞I/O等概念,這些術(shù)語只是對底層原理的一些概念總結(jié)而已,我覺得沒有用。底層原理搞懂了,這些概念根本不重要,我希望讀完本文之后,各位能夠不再糾結(jié)這些概念。
2、不會只拿生活中例子來說明問題。之前看過特別多的文章,這些文章舉的“燒水”、“取快遞”的例子真的是深入淺出,但是看懂這些例子會讓我們有一種我們真的懂了的錯覺。尤其對于網(wǎng)絡(luò)I/O模型而言,很難找到生活中非常貼切的例子,這種例子不過是已經(jīng)懂了的人高屋建瓴,對外輸出的一種形式,但是對于一知半解的讀者而言卻猶如鈍刀殺人。
牛皮已經(jīng)吹出去了,正文開始。
1. 一次I/O到底經(jīng)歷了什么
我們都知道,網(wǎng)絡(luò)I/O是通過Socket實現(xiàn)的,在說明網(wǎng)絡(luò)I/O之前,我們先來回顧(了解)一下本地I/O的流程。
舉一個非常簡單的例子,下面的代碼實現(xiàn)了文件的拷貝,將file1.txt的數(shù)據(jù)拷貝到file2.txt中:
這個I/O操作在底層到底經(jīng)歷了什么呢?下圖給出了說明:
本地I/O示意圖
大致可以概括為如下幾個過程:
- in.read(buf)?執(zhí)行時,程序向內(nèi)核發(fā)起 read()系統(tǒng)調(diào)用;
- 操作系統(tǒng)發(fā)生上下文切換,由用戶態(tài)(User mode)切換到內(nèi)核態(tài)(Kernel mode),把數(shù)據(jù)讀取到內(nèi)核緩沖區(qū) (buffer)中;
- 內(nèi)核把數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間,同時由內(nèi)核態(tài)轉(zhuǎn)為用戶態(tài);
- 繼續(xù)執(zhí)行out.write(buf);
再次發(fā)生上下文切換,將數(shù)據(jù)從用戶空間buffer拷貝到內(nèi)核空間buffer中,由內(nèi)核把數(shù)據(jù)寫入文件。
之所以先拿本地I/O舉個例子,是因為我想說明I/O模型并非僅僅針對網(wǎng)絡(luò)IO(雖然網(wǎng)絡(luò)I/O最常被我們拿來舉例),本地I/O同樣受到I/O模型的約束。比如在這個例子中,本地I/O用的就是典型的BIO,至于什么是BIO,稍安勿躁,接著往下看。
除此之外,通過本地I/O,我還想向各位說明下面幾件事情:
- 我們編寫的程序本身并不能對文件進行讀寫操作,這個步驟必須依賴于操作系統(tǒng),換個詞兒就是「內(nèi)核」;
- 一個看似簡單的I/O操作卻在底層引發(fā)了多次的用戶空間和內(nèi)核空間的切換,并且數(shù)據(jù)在內(nèi)核空間和用戶空間之間拷貝來拷貝去。
不同于本地I/O是從本地的文件中讀取數(shù)據(jù),網(wǎng)絡(luò)I/O是通過網(wǎng)卡讀取網(wǎng)絡(luò)中的數(shù)據(jù),網(wǎng)絡(luò)I/O需要借助Socket來完成,所以接下來我們重新認識一下Socket。
2. 什么是Socket
這部分在一定程度上是我的強迫癥作祟,我關(guān)于文章對知識點講解的完備性上對自己近乎苛刻。我覺得把Socket講明白對接下來的講解是一件很重要的事情,看過我之前的文章的讀者或許能意識到,我盡量避免把前置知識直接以鏈接的形式展示出來,我認為會割裂整篇文章的閱讀體驗。
不割裂的結(jié)果就是文章可能顯得很啰嗦,好像一件事情非得從盤古開天辟地開始講起。因此,如果各位覺得對這個知識點有足夠的把握,就直接略過好了~
我們所做的任何需要和遠程設(shè)備進行交互的操作,并非是操作軟件本身進行的數(shù)據(jù)通信。舉個例子就是我們用瀏覽器刷B站視頻的時候,并非是瀏覽器自身向B站請求視頻數(shù)據(jù)的,而是必須委托操作系統(tǒng)內(nèi)核中的協(xié)議棧。
網(wǎng)絡(luò)I/O
協(xié)議棧就是下邊這些書的代碼實現(xiàn),里邊包含了TCP/IP及其他各種網(wǎng)絡(luò)實現(xiàn)細節(jié),這樣解釋應(yīng)該好理解吧。
協(xié)議棧就是計算機網(wǎng)絡(luò)的實現(xiàn)
而Socket庫就是操作系統(tǒng)提供給我們的,用于調(diào)用協(xié)議棧網(wǎng)絡(luò)功能的一堆程序組件的集合,也就是我們平時聽過的操作系統(tǒng)庫函數(shù),Socket庫和協(xié)議棧的關(guān)系如下圖所示。
Socket庫和協(xié)議棧的關(guān)系
用戶進程向操作系統(tǒng)內(nèi)核的協(xié)議棧發(fā)出委托時,需要按照指定的順序來調(diào)用 Socket 庫中的程序組件。
本文的所有案例都以TCP協(xié)議為例進行講解。
大家可以把數(shù)據(jù)收發(fā)想象成在兩臺計算機之間創(chuàng)建了一條數(shù)據(jù)通道,計算機通過這條通道進行數(shù)據(jù)收發(fā)的雙向操作,當(dāng)然,這條通道是邏輯上的,并非實際存在。
TCP連接有邏輯通道
數(shù)據(jù)通過管道流動這個比較好理解,但是問題在于這條管道雖然只是邏輯上存在,但是這個“邏輯”也不是光用腦袋想想就會出現(xiàn)的。就好比我們手機打電話,你總得先把號碼撥出去呀。
對應(yīng)到網(wǎng)絡(luò)I/O中,就意味著雙方必須創(chuàng)建各自的數(shù)據(jù)出入口,然后將兩個數(shù)據(jù)出入口像連接水管一樣接通,這個數(shù)據(jù)出入口就是上圖中的套接字,就是大名鼎鼎的socket。
- 客戶端和服務(wù)端之間的通信可以被概括為如下4個步驟:
- 服務(wù)端創(chuàng)建socket,等待客戶端連接(創(chuàng)建socket階段);
- 客戶端創(chuàng)建socket,連接到服務(wù)端(連接階段);
- 收發(fā)數(shù)據(jù)(通信階段);
斷開管道并刪除socket(斷開連接)。
每一步都是通過特定語言的API調(diào)用Socket庫,Socket庫委托協(xié)議棧進行操作的。socket就是調(diào)用Socket庫中程序組件之后的產(chǎn)成品,比如Java中的ServerSocket,本質(zhì)上還是調(diào)用操作系統(tǒng)的Socket庫,因此下文的代碼實例雖然采用Java語言,但是希望各位讀者注意:只有語法上抽象與具體的區(qū)別,socket的操作邏輯是完全一致的。
但是,我還是得花點口舌啰嗦一下這幾個步驟的一些細節(jié),為了不至于太枯燥,接下來將這4個步驟和BIO一起講解。
3. 阻塞I/O(Blocking I/O,BIO)
我們先從比較簡單的客戶端開始談起。
3.1 客戶端的socket流程
上面展示了一段非常簡單的Java BIO的客戶端代碼,相信你們一定不會感到陌生,接下來我們一點點分析客戶端的socket操作究竟做了什么。
雖然只是簡單的一行語句,但是其中包含了兩個步驟,分別是創(chuàng)建套接字、建立連接,等價于下面兩行偽代碼:
注意:
文中會出現(xiàn)多個關(guān)于*ocket的術(shù)語,比如Socket庫,就是操作系統(tǒng)提供的庫函數(shù);socket組件就是Socket庫中和socket相關(guān)的程序的統(tǒng)稱;socket()函數(shù)以及socket(或稱:套接字)就是接下來要講的內(nèi)容,我會盡量在描述過程中不產(chǎn)生混淆,大家注意根據(jù)上下文進行辨析。
3.1.1 何為socket?
上文已經(jīng)說了,邏輯管道存在的前提是需要各自先創(chuàng)建socket(就好比你打電話之前得先有手機),然后將兩個socket進行關(guān)聯(lián)??蛻舳藙?chuàng)建socket非常簡單,只需要調(diào)用Socket庫中的socket組件的socket()函數(shù)就可以了。
客戶端代碼調(diào)用socket()?函數(shù)向協(xié)議棧申請創(chuàng)建socket,協(xié)議棧會根據(jù)你的參數(shù)來決定socket是IPv4?還是IPv6?,是TCP?還是UDP。除此之外呢?
基本的臟活累活都是協(xié)議棧完成的,協(xié)議棧想傳遞消息總得知道目的IP和端口吧,要是你用的是TCP?協(xié)議,你甚至還得記錄每個包的發(fā)送時間以及每個包是否收到回復(fù),否則TCP的超時重傳就不會正常工作。。。等等。。。
因此,協(xié)議棧會申請一塊內(nèi)存空間,在其中存放諸如此類的各種控制信息,協(xié)議棧就是根據(jù)這些控制信息來工作的,這些控制信息我們就可以理解為是socket的實體。怎么樣,是不是之前感覺虛無縹緲的socket突然鮮活了起來?
我們看一個更鮮活的例子,我在本級上執(zhí)行netstat -anop命令,得到的每一行信息我們就可以理解為是一個socket,我們重點看一下下圖中標注的兩條。
這兩條都是redis-server?的socket信息,第1條表示redis-server?服務(wù)正在IP為127.0.0.1?,端口為6379?的主機上等待遠程客戶端連接,因為Foreign address為0.0.0.0:*?,表示通信還未開始,IP無法確定,因此State為LISTEN?狀態(tài);第2條表示redis-server?服務(wù)已經(jīng)建立了與IP為127.0.0.1?的客戶端之間的連接,且客戶端使用49968?的端口號,目前該socket的狀態(tài)為ESTABLISHED。
協(xié)議棧創(chuàng)建完socket之后,會返回一個描述符給應(yīng)用程序。描述符用來識別不同的socket,可以將描述符理解成某個socket的編號,就好比你去洗澡的時候,前臺會發(fā)給你一個手牌,原理差不多。
之后對socket進行的任何操作,只要我們出示自己的手牌,啊呸,描述符,協(xié)議棧就能知道我們想通過哪個socket進行數(shù)據(jù)收發(fā)了。
描述符就是socket的號碼牌
至于為什么不直接返回socket的內(nèi)存地址以及其他細節(jié),可以參考我之前寫的文章《2>&1到底是什么意思》
3.1.2 何為連接?
socket剛創(chuàng)建的時候,里邊沒啥有用的信息,別說自己即將通信的對象長啥樣了,就是叫啥,現(xiàn)在在哪兒也不知道,更別提協(xié)議棧,自然是啥也知道!
因此,第1件事情就是應(yīng)用程序需要把服務(wù)器的IP地址和端口號告訴協(xié)議棧,有了街道和門牌號,接下來協(xié)議棧就可以去找服務(wù)器了。
對于服務(wù)器也是一樣的情況,服務(wù)器也有自己的socket,在接收到客戶端的信息的同時,服務(wù)器也得知道客戶端的IP和端口號?啊,要不然只能單線聯(lián)系了。因此對客戶端做的第1件事情就有了要求,必須把客戶端自己的IP?以及端口號告知服務(wù)器,然后兩者就可以愉快的聊天了。
這就是3次握手。
一句話概括連接的含義:連接實際上是通信的雙方交換控制信息,并將必要的控制信息保存在各自的socket中的過程。
連接過后,每個socket就被4個信息唯一標識,通常我們稱為四元組:
socket四元組
趁熱打鐵,我們趕緊再說一說服務(wù)器端創(chuàng)建socket以及接受連接的過程。
3.2 服務(wù)端的socket流程
上面一段是非常簡單的Java BIO的服務(wù)端代碼,代碼的含義就是:
- 創(chuàng)建socket;
- 將socket設(shè)置為等待連接狀態(tài);
- 接受客戶端連接;
- 收發(fā)數(shù)據(jù)。
這些步驟調(diào)用的底層代碼的偽代碼如下:
3.2.1 創(chuàng)建socket
創(chuàng)建socket這一步和客戶端沒啥區(qū)別,不同的是這個socket我們稱之為等待連接socket(或監(jiān)聽socket)。
3.2.2 綁定端口號
bind()?函數(shù)會將端口號寫入上一步生成的監(jiān)聽socket中,這樣一來,監(jiān)聽socket就完整保存了服務(wù)端的IP和端口號。
3.2.3 listen()的真正作用
很多小伙伴一定會對這個listen()?有疑問,監(jiān)聽socket都已經(jīng)創(chuàng)建完了,端口也已經(jīng)綁定完了,為什么還要多調(diào)用一個listen()呢?
我們剛說過監(jiān)聽socket和客戶端創(chuàng)建的socket沒什么區(qū)別,問題就出在這個沒什么區(qū)別上。
socket被創(chuàng)建出來的時候都默認是一個主動socket,也就說,內(nèi)核會認為這個socket之后某個時候會調(diào)用connect()?主動向別的設(shè)備發(fā)起連接。這個默認對客戶端socket來說很合理,但是監(jiān)聽socket可不行,它只能等著客戶端連接自己,因此我們需要調(diào)用listen()將監(jiān)聽socket從主動設(shè)置為被動,明確告訴內(nèi)核:你要接受指向這個監(jiān)聽socket的連接請求!
此外,listen()的第2個參數(shù)也大有來頭!監(jiān)聽socket真正接受的應(yīng)該是已經(jīng)完整完成3次握手的客戶端,那么還沒完成的怎么辦?總得找個地方放著吧。于是內(nèi)核為每一個監(jiān)聽socket都維護了兩個隊列:
- 半連接隊列(未完成連接的隊列)
這里存放著暫未徹底完成3次握手的socket(為了防止半連接,這里存放的其實是占用內(nèi)存極小的request _sock,但是我們直接理解成socket就行了),這些socket的狀態(tài)稱為SYN_RCVD。
- 已完成連接隊列
每個已完成TCP3次握手的客戶端連接對應(yīng)的socket就放在這里,這些socket的狀態(tài)為ESTABLISHED。
文字太多了,有點干,上個圖!
listen與3次握手
解釋一下動圖中的內(nèi)容:
- 客戶端調(diào)用connect()?函數(shù),開始3次握手,首先發(fā)送一個SYN X?的報文(X是個數(shù)字,下同);
- 服務(wù)端收到來自客戶端的SYN?,然后在監(jiān)聽socket對應(yīng)的半連接隊列中創(chuàng)建一個新的socket,然后對客戶端發(fā)回響應(yīng)SYN Y?,捎帶手對客戶端的報文給個ACK;
- 直到客戶端完成第3次握手,剛才新創(chuàng)建的socket就會被轉(zhuǎn)移到已連接隊列;
- 當(dāng)進程調(diào)用accept()時,會將已連接隊列頭部的socket返回;如果已連接隊列為空,那么進程將被睡眠,直到已連接隊列中有新的socket,進程才會被喚醒,將這個socket返回。
第4步就是阻塞的本質(zhì)啊,朋友們!
3.3 答疑時間
Q1.隊列中的對象是socket嗎?
呃。。。乖,咱就把它當(dāng)成socket就好了,這樣容易理解,其實具體里邊存放的數(shù)據(jù)結(jié)構(gòu)是啥,我也很想知道,等我寫完這篇文章,我研究完了告訴你。
Q2.accept()這個函數(shù)你還沒講是啥意思呢?
accept()?函數(shù)是由服務(wù)端調(diào)用的,用于從已連接隊列中返回一個socket描述符;如果socket為阻塞式的,那么如果已連接隊列為空,accept()進程就會被睡眠。BIO恰好就是這個樣子。
Q3.accept()為什么不直接把監(jiān)聽socket返回呢?
因為在隊列中的socket經(jīng)過3次握手過程的控制信息交換,socket的4元組的信息已經(jīng)完整了,用做socket完全沒問題。
監(jiān)聽socket就像一個客服,我們給客服打電話,然后客服找到解決問題的人,幫助我們和解決問題的人建立聯(lián)系,如果直接把監(jiān)聽socket返回,而不使用連接socket,就沒有socket繼續(xù)等待連接了。
哦對了,accept()返回的socket也有個名字,叫連接socket。
3.4 BIO究竟阻塞在哪里
拿Server端的BIO來說明這個問題,阻塞在了serverSocket.accept()?以及bufferedReader.readLine()這兩個地方。有什么辦法可以證明阻塞嗎?
簡單的很!你在serverSocket.accept();? 的下一行打個斷點,然后debug模式運行BIOServerSocket?,在沒有客戶端連接的情況下,這個斷點絕不會觸發(fā)!同樣,在bufferedReader.readLine();下一行打個斷點,在已連接的客戶端發(fā)送數(shù)據(jù)之前,這個斷點絕不會觸發(fā)!
readLine()?的阻塞還帶來一個非常嚴重的問題,如果已經(jīng)連接的客戶端一直不發(fā)送消息,readLine()?進程就會一直阻塞(處于睡眠狀態(tài)),結(jié)果就是代碼不會再次運行到accept()?,這個ServerSocket沒辦法接受新的客戶端連接。
解決這個問題的核心就是別讓代碼卡在readLine()?就可以了,我們可以使用新的線程來readLine()?,這樣代碼就不會阻塞在readLine()上了。
3.5 改造BIO
改造之后的BIO長這樣,這下子服務(wù)端就可以隨時接受客戶端的連接了,至于啥時候能read到客戶端的數(shù)據(jù),那就讓線程去處理這個事情吧。
事情的順利進展不禁讓我們飄飄然,我們居然是使用高階的多線程技術(shù)解決了BIO的阻塞問題,雖然目前每個客戶端都需要一個單獨的線程來處理,但accept()?總歸不會被readLine()卡死了。
BIO改造之后
所以我們改造完之后的程序是不是就是非阻塞IO了呢?
想多了。。。我們只是用了點奇技淫巧罷了,改造完的代碼在系統(tǒng)調(diào)用層面該阻塞的地方還是阻塞,說白了,Java提供的API完全受限于操作系統(tǒng)提供的系統(tǒng)調(diào)用,在Java語言級別沒能力改變底層BIO的事實!
3.6 掀開BIO的遮羞布
接下來帶大家看一下改造之后的BIO代碼在底層都調(diào)用了哪一些系統(tǒng)調(diào)用,讓我們在底層上對上文的內(nèi)容加深一下理解。
給大家打個氣,接下來的內(nèi)容其實非常好理解,大家跟著文章一步步地走,一定能看得懂,如果自己動手操作一遍,那就更好了。
對了,我下來使用的JDK版本是JDK8。
strace是Linux上的一個程序,該程序可以追蹤并記錄參數(shù)后邊運行的進程對內(nèi)核進行了哪些系統(tǒng)調(diào)用。
其中:
- -o:
將系統(tǒng)調(diào)用的追蹤信息輸出到out?文件中,不加這個參數(shù),默認會輸出到標準錯誤stderr。
- -ff
如果指定了-o?選項,strace?會追蹤和程序相關(guān)的每一個進程的系統(tǒng)調(diào)用,并將信息輸出到以進程id為后綴的out文件中。舉個例子,比如BIOServerSocketWithThread程序運行過程中有一個ID為30792的進程,那么該進程的系統(tǒng)調(diào)用日志會輸出到out.30792這個文件中。
我們運行strace命令之后,生成了很多個out文件。
這么多進程怎么知道哪個是我們需要追蹤的呢?我就挑了一個容量最大的文件進行查看,也就是out.30792,事實上,這個文件也恰好是我們需要的,截取一下里邊的內(nèi)容給大家看一下。
可以看到圖中的有非常多的行,說明我們寫的這么幾行代碼其實默默調(diào)用了非常多的系統(tǒng)調(diào)用,拋開細枝末節(jié),看一下上圖中我重點標注的系統(tǒng)調(diào)用,是不是就是上文中我解釋過的函數(shù)?我再詳細解釋一下每一步,大家聯(lián)系上文,會對BIO的底層理解的更加通透。
- 生成監(jiān)聽socket,并返回socket描述符7?,接下來對socket進行操作的函數(shù)都會有一個參數(shù)為7;
- 將8099?端口綁定到監(jiān)聽socket,bind?的第一個參數(shù)就是7,說明就是對監(jiān)聽socket進行的操作;
- listen()將監(jiān)聽socket(參數(shù)為7)設(shè)置為被動接受連接的socket,并且將隊列的長度設(shè)置為50;
實際上就是System.out.println("啟動服務(wù):監(jiān)聽端口:8099");?這一句的系統(tǒng)調(diào)用,只不過中文被編碼了,所以我特意把:8099圈出來證明一下;
額外說兩點:
其一:可以看到,這么一句簡單的打印輸出在底層實際調(diào)用了兩次write系統(tǒng)調(diào)用,這就是為什么不推薦在生產(chǎn)環(huán)境下使用打印語句的原因,多少會影響系統(tǒng)性能;
其二:write()?的第一個參數(shù)為1?,也是文件描述符,表示的是標準輸出stdout?,關(guān)于標準輸入、標準輸出、標準錯誤和文件描述符之間的關(guān)系可以參見《2>&1到底是什么意思》。
系統(tǒng)調(diào)用阻塞在了poll()?函數(shù),怎么看出來的阻塞?out文件的每一行運行完畢都會有一個 = 返回值?,而poll()?目前沒有返回值,因此阻塞了。實際上poll()?系統(tǒng)調(diào)用對應(yīng)的Java語句就是serverSocket.accept();。
不對???為什么底層調(diào)用的不是accept()?而是poll()?poll()?應(yīng)該是多路復(fù)用才是啊。在JDK4之前,底層確實直接調(diào)用的是accept()?,但是之后的JDK對這一步進行了優(yōu)化,除了調(diào)用accept()?,還加上了poll()。poll()?的細節(jié)我們下文再說,這里可以起碼證明了poll()函數(shù)依然是阻塞的,所以整個BIO的阻塞邏輯沒有改變。
接下來我們起一個客戶端對程序發(fā)起連接,直接用Linux上的nc程序即可,比較簡單:
發(fā)起連接之后(但并未主動發(fā)送信息),out.30792的內(nèi)容發(fā)生了變化:
poll()?函數(shù)結(jié)束阻塞,程序接著調(diào)用accept()?函數(shù)返回一個連接socket,該socket的描述符為8;
就是System.out.println("客戶端:" + socket.getPort());的底層調(diào)用;
底層使用clone()?創(chuàng)造了一個新進程去處理連接socket,該進程的pid為31168,因此JDK8的線程在底層其實就是輕量級進程;
回到poll()函數(shù)繼續(xù)阻塞等待新客戶端連接。
由于創(chuàng)建了一個新的進程,因此在目錄下對多出一個out.31168的文件,我們看一下該文件的內(nèi)容:
發(fā)現(xiàn)子進程阻塞在了recvfrom()?這個系統(tǒng)調(diào)用上,對應(yīng)的Java源碼就是bufferedReader.readLine();,直到客戶端主動給服務(wù)端發(fā)送消息,阻塞才會結(jié)束。
3.7 BIO總結(jié)
到此為止,我們就通過底層的系統(tǒng)調(diào)用證明了BIO在accept()?以及readLine()上的阻塞。最后用一張圖來結(jié)束BIO之旅。
BIO模型
BIO之所以是BIO,是因為系統(tǒng)底層調(diào)用是阻塞的,上圖中的進程調(diào)用recv,其系統(tǒng)調(diào)用直到數(shù)據(jù)包準備好并且被復(fù)制到應(yīng)用程序的緩沖區(qū)或者發(fā)生錯誤為止才會返回,在此整個期間,進程是被阻塞的,啥也干不了。
4. 非阻塞I/O(NonBlocking I/O)
上文花了太多的筆墨描述BIO,接下來的非阻塞IO我們只抓主要矛盾,其余參考BIO即可。
如果你看過其他介紹非阻塞IO的文章,下面這個圖片你多少會有點眼熟。
NIO模型
非阻塞IO指的是進程發(fā)起系統(tǒng)調(diào)用之后,內(nèi)核不會將進程投入睡眠,而是會立即返回一個結(jié)果,這個結(jié)果可能恰好是我們需要的數(shù)據(jù),又或者是某些錯誤。
你可能會想,這種非阻塞帶來的輪詢有什么用呢?大多數(shù)都是空輪詢,白白浪費CPU而已,還不如讓進程休眠來的合適。
4.1 Java的非阻塞實現(xiàn)
這個問題暫且擱置一下,我們先看Java在語法層面是如何提供非阻塞功能的,細節(jié)慢慢聊。
Java提供了新的API,ServerSocketChannel?以及SocketChannel?,相當(dāng)于BIO中的ServerSocket和Socket。此外,通過下面兩行的配置,將監(jiān)聽socket和連接socket設(shè)置為非阻塞。
我們上文強調(diào)過,Java自身并沒有將socket設(shè)置為非阻塞的本事,一定是在某個時間點上,操作系統(tǒng)內(nèi)核提供了這個功能,才使得Java設(shè)計出了新的API來提供非阻塞功能。
之所以需要上面兩行代碼的顯式設(shè)置,也恰好說明了內(nèi)核是默認將socket設(shè)置為阻塞狀態(tài)的,需要非阻塞,就得額外調(diào)用其他系統(tǒng)調(diào)用。我們通過man?命令查看一下socket()這個方法(截圖的中間省略了一部分內(nèi)容):
我們可以看到socket()?函數(shù)提供了SOCK_NONBLOCK?這個類型,可以通過fcntl()這個方法將socket從默認的阻塞修改為非阻塞,不管是對監(jiān)聽socket還是連接socket都是一樣的。
4.2 Java的非阻塞解釋
現(xiàn)在解釋上面提到的問題:這種非阻塞帶來的輪詢有什么用?觀察一下上面的代碼就可以發(fā)現(xiàn),我們?nèi)讨皇褂昧?個main線程就解決了所有客戶端的連接以及所有客戶端的讀寫操作。
serverSocketChannel.accept();會立即返回調(diào)用結(jié)果。
返回的結(jié)果如果是一個SocketChannel?對象(系統(tǒng)調(diào)用底層就是個socket描述符),說明有客戶端連接,這個SocketChannel?就表示了這個連接;然后利用socketChannel.configureBlocking(false);?將這個連接socket設(shè)置為非阻塞。這個設(shè)置非常重要,設(shè)置之后對連接socket所有的讀寫操作都變成了非阻塞,因此接下來的client.read(byteBuffer);?并不會阻塞while循環(huán),導(dǎo)致新的客戶端無法連接。再之后將該連接socket加入到channelList隊列中。
如果返回的結(jié)果為空(底層系統(tǒng)調(diào)用返回了錯誤),就說明現(xiàn)在還沒有新的客戶端要連接監(jiān)聽socket,因此程序繼續(xù)向下執(zhí)行,遍歷channelList?隊列中的所有連接socket,對連接socket進行讀操作。而讀操作也是非阻塞的,會理解返回一個整數(shù),表示讀到的字節(jié)數(shù),如果>0,則繼續(xù)進行下一步的邏輯處理;否則繼續(xù)遍歷下一個連接socket。
下面給出一張accept()返回一個連接socket情況下的動圖,希望對大家理解整個流程有幫助。
非阻塞IO
4.3 掀開非阻塞IO的底褲
我將上面的程序在CentOS下再次用strace程序追蹤一下,具體步驟不再贅述,下面是out日志文件的內(nèi)容(我忽略了絕大多數(shù)沒用的)。
非阻塞IO的系統(tǒng)調(diào)用分析
4.4 非阻塞IO總結(jié)
NIO模型
再放一遍這個圖,有一個細節(jié)需要大家注意,系統(tǒng)調(diào)用向內(nèi)核要數(shù)據(jù)時,內(nèi)核的動作分成兩步:
等待數(shù)據(jù)(從網(wǎng)卡緩沖區(qū)拷貝到內(nèi)核緩沖區(qū))
拷貝數(shù)據(jù)(數(shù)據(jù)從內(nèi)核緩沖區(qū)拷貝到用戶空間)
只有在第1步時,系統(tǒng)調(diào)用是非阻塞的,第2步進程依然需要等待這個拷貝過程,然后才能返回,這一步是阻塞的。
非阻塞IO模型僅用一個線程就能處理所有操作,對比BIO的一個客戶端需要一個線程而言進步還是巨大的。但是他的致命問題在于會不停地進行系統(tǒng)調(diào)用,不停的進行accept()?,不停地對連接socket進行read()操作,即使大部分時間都是白忙活。要知道,系統(tǒng)調(diào)用涉及到用戶空間和內(nèi)核空間的多次轉(zhuǎn)換,會嚴重影響整體性能。
所以,一個自然而言的想法就是,能不能別讓進程瞎輪詢。
比如有人告訴進程監(jiān)聽socket是不是被連接了,有的話進程再執(zhí)行accept()?;比如有人告訴進程哪些連接socket有數(shù)據(jù)從客戶端發(fā)送過來了,然后進程只對有數(shù)據(jù)的連接socket進行read()。
這個方案就是I/O多路復(fù)用。