新聞中心
令人頭大的 IO
說(shuō)起網(wǎng)絡(luò) IO 相關(guān)的開(kāi)發(fā),很多人都頭大,包括我自己,寫(xiě)了幾年的代碼,對(duì) IO 相關(guān)的術(shù)語(yǔ)說(shuō)起來(lái)也是頭頭是道,什么 NIO、IO 多路復(fù)用等術(shù)語(yǔ)一個(gè)接一個(gè)。但是也就自己知道,這些概念一團(tuán)亂,網(wǎng)上各種各樣的文章也沒(méi)一個(gè)權(quán)威易懂的,并且很多文章說(shuō)起 IO 就扯上 Java 的 NIO 包,專(zhuān)注的大多是如何使用(術(shù))而不是 IO 的本質(zhì)(道)。所以寫(xiě)這篇文章來(lái)從 socket 編程的痛點(diǎn),轉(zhuǎn)到 NIO 的解決方案,再到多路復(fù)用器的發(fā)展來(lái)一起梳理網(wǎng)絡(luò)IO 模型。

成都創(chuàng)新互聯(lián)專(zhuān)業(yè)為企業(yè)提供三穗網(wǎng)站建設(shè)、三穗做網(wǎng)站、三穗網(wǎng)站設(shè)計(jì)、三穗網(wǎng)站制作等企業(yè)網(wǎng)站建設(shè)、網(wǎng)頁(yè)設(shè)計(jì)與制作、三穗企業(yè)網(wǎng)站模板建站服務(wù),10多年三穗做網(wǎng)站經(jīng)驗(yàn),不只是建網(wǎng)站,更提供有價(jià)值的思路和整體網(wǎng)絡(luò)服務(wù)。
從 Socket 編程說(shuō)起
做業(yè)務(wù)開(kāi)發(fā)的同學(xué),常常面對(duì)的是 Spring Boot 這些框架幫我們搭建好的 Server 框架,但是如果往下去看框架幫我們實(shí)現(xiàn)的代碼最終會(huì)看到 Socket 相關(guān)的源碼, Socket 相關(guān)的代碼實(shí)際上就是 TCP 網(wǎng)絡(luò)編程。
目前主流的 HTTP 框架,比如 Golang 原生的 HTTP net/http,都是基于 TCP 編程實(shí)現(xiàn)的,按照 HTTP 協(xié)議約定,解析 TCP 傳輸流過(guò)來(lái)的數(shù)據(jù),最終將傳輸數(shù)據(jù)轉(zhuǎn)換為一個(gè) Http Request Model 交給我們業(yè)務(wù)的 Handler 邏輯處理。
例如,Golang 的 原生 Http 框架 net/http 為例就有這么些代碼片段:
l, err = sl.listenTCP(ctx, la) // 監(jiān)聽(tīng)連接請(qǐng)求
rw, e := l.Accept() // 創(chuàng)建連接
go c.serve(connCtx) // 調(diào)用新的協(xié)程處理請(qǐng)求邏輯
w, err := c.readRequest(ctx) // 讀取請(qǐng)求
serverHandler{c.server}.ServeHTTP(w, w.req) // 執(zhí)行業(yè)務(wù)邏輯,并返回結(jié)果
Socket 編程的過(guò)程
- 服務(wù)端需要先綁定(Bind)并監(jiān)聽(tīng)(Listen)一個(gè)端口,這個(gè)時(shí)候會(huì)有一個(gè)歡迎套接字(welcomeSocket)
- welcomeSocket 調(diào)用 Accept 方法,接受客戶(hù)端的請(qǐng)求,如果沒(méi)有請(qǐng)求那么會(huì)阻塞住
- 客戶(hù)端請(qǐng)求指定端口,welcomeSocket 從阻塞中返回一個(gè)已連接套接字(connectionSocket)用于專(zhuān)門(mén)處理這個(gè)客戶(hù)端請(qǐng)求
- 客戶(hù)端往請(qǐng)求套接字寫(xiě)入數(shù)據(jù)(Stream)
- 服務(wù)端從已連接套接字可以持續(xù)讀到數(shù)據(jù),TCP 底層保證數(shù)據(jù)的順序性
- 服務(wù)端可以往已連接套接字寫(xiě)入數(shù)據(jù),客戶(hù)端從請(qǐng)求的套接字中可以讀到數(shù)據(jù)
- 客戶(hù)端關(guān)閉連接,服務(wù)端也可以主動(dòng)關(guān)閉連接
如果用代碼手寫(xiě) Socket 服務(wù)端,用 Java 實(shí)現(xiàn)是這樣的:
public class Server {
public static void main(String[] args) throws Exception {
String clientSentence;
String capitalizedSentence;
ServerSocket welcomeSocket = new ServerSocket(6789);
while (true) {
Socket connectionSocket = welcomeSocket.accept(); // 當(dāng)沒(méi)請(qǐng)求會(huì)阻塞住
System.out.println("connection build succ!");
BufferedReader inFromClient = new BufferedReader(new InputStreamReader(connectionSocket.getInputStream()));
DataOutputStream outToClient = new DataOutputStream(connectionSocket.getOutputStream());
clientSentence = inFromClient.readLine(); // 連接上但是客戶(hù)端還沒(méi)寫(xiě)入數(shù)據(jù)會(huì)阻塞住
System.out.println("read succ!");
capitalizedSentence = clientSentence.toUpperCase() + '\n';
outToClient.writeBytes(capitalizedSentence);
System.out.println("write succ!");
}
}
}我們可以用 Telnet 連接上去嘗試下,但是很快我們會(huì)發(fā)現(xiàn)兩個(gè)問(wèn)題:
- Accept 是阻塞的,如果一個(gè)客戶(hù)端網(wǎng)絡(luò)比較差,三次握手時(shí)間長(zhǎng)整個(gè)服務(wù)端就卡住了。
- Read 是阻塞的,如果客戶(hù)端連接上了,但是遲遲不發(fā)數(shù)據(jù)(比如我們 telnet 上,但是不寫(xiě))整個(gè)服務(wù)端就卡住了。
優(yōu)化思路:多線程處理,避免 read 阻塞
對(duì)于 Read 是阻塞的問(wèn)題,我們開(kāi)線程來(lái)處理,這樣當(dāng)一個(gè)請(qǐng)求連接上遲遲不寫(xiě)數(shù)據(jù)也不會(huì)影響到其他連接的處理了。當(dāng)然這里得考慮到量級(jí),如果量級(jí)太大的話(huà)需要改成線程池避免線程過(guò)多。
public class Server {
public static void main(String[] args) throws Exception {
ServerSocket welcomeSocket = new ServerSocket(6789);
while (true) {
Socket connectionSocket = welcomeSocket.accept(); // 當(dāng)沒(méi)請(qǐng)求會(huì)阻塞住
System.out.println("connection build succ!");
new Thread(new Runnable() {
@Override
public void run() {
try {
String clientSentence;
String capitalizedSentence;
BufferedReader inFromClient = new BufferedReader(new InputStreamReader(connectionSocket.getInputStream()));
DataOutputStream outToClient = new DataOutputStream(connectionSocket.getOutputStream());
clientSentence = inFromClient.readLine(); // 連接上但是客戶(hù)端還沒(méi)寫(xiě)入數(shù)據(jù)會(huì)阻塞住
System.out.println("read succ!");
capitalizedSentence = clientSentence.toUpperCase() + '\n';
outToClient.writeBytes(capitalizedSentence);
System.out.println("write succ!");
}catch (Exception e) {
e.printStackTrace();
}
}
}).start();
}
}
}這個(gè)時(shí)候,我們用 telnet 客戶(hù)端連接已經(jīng)感受不到服務(wù)端的瓶頸了。但是從我們上的分析來(lái)看,Accept 還是有瓶頸的,就是同時(shí)只能對(duì)一個(gè)請(qǐng)求做連接,而且即使是線程池的模式,如果連接(特別是空閑的)很多,最終也會(huì)出現(xiàn)阻塞的情況。
如果你的業(yè)務(wù)場(chǎng)景是連接數(shù)不多,同時(shí)又需要頻繁的交互數(shù)據(jù),那么用 BIO 模式無(wú)論是對(duì)時(shí)延還是資源使用都有不錯(cuò)的效果(相當(dāng)于 VIP 1v1 服務(wù))。
但是,我們的服務(wù)端代碼通常是面對(duì)海量的連接的,且很多客戶(hù)端連接上,并不會(huì)馬上發(fā)送請(qǐng)求,例如聊天室應(yīng)用,很久用戶(hù)才會(huì)發(fā)送1條消息。這個(gè)時(shí)候如果還是這種 1v1 模式,那么有 100w 個(gè)用戶(hù),就需要維護(hù) 100個(gè)連接,顯然是不合適,這太浪費(fèi)資源了,而且很低效,大部分線程都是在 Block 等待用戶(hù)數(shù)據(jù)。
所以,這個(gè)時(shí)候 NIO(異步io)橫空出世了。網(wǎng)上有一張比較好的對(duì)比圖,可以很好地解釋差異:
我們上文說(shuō)的就是 阻塞I/O ,而現(xiàn)在要講的是非阻塞I/O。圖上主要闡述的是 `read()` 方法的過(guò)程,主要包括兩部分:
第一階段:等待TCP RecvBuffer 數(shù)據(jù)就緒,這個(gè)在傳統(tǒng)的BIO里如果數(shù)據(jù)沒(méi)就緒,就會(huì)阻塞等待,不消耗CPU。
第二階段:將數(shù)據(jù)從內(nèi)核拷貝到用戶(hù)空間,消耗CPU但是速度非???,屬于 memory copy。
非阻塞I/O
所以對(duì)于 非阻塞I/O 來(lái)說(shuō),主要要優(yōu)化的是調(diào)用 `read()` 方法數(shù)據(jù)還未就緒導(dǎo)致阻塞問(wèn)題。這個(gè)解決方法很簡(jiǎn)單,大部分編程語(yǔ)言都有提供 nio 的方法,只要數(shù)據(jù)還沒(méi)準(zhǔn)備就緒不要block,直接返回給調(diào)用者就可以了。這樣我們這個(gè)線程就可以接著去處理其他連接的數(shù)據(jù),這樣就不用每個(gè)連接單獨(dú)只有一個(gè)線程來(lái)服務(wù)了。
I/O 多路復(fù)用
對(duì)于非阻塞 I/O 模式,開(kāi)發(fā)者仍然需要不斷去輪詢(xún)事件狀態(tài),如果請(qǐng)求量級(jí)很大, 這樣的機(jī)制同樣還是會(huì)浪費(fèi)很多資源,同時(shí)開(kāi)發(fā)難度較高。其實(shí)想一想,我們作為開(kāi)發(fā)者的訴求無(wú)非就是監(jiān)聽(tīng)某些事件,比如完成鏈接(accept完成)、數(shù)據(jù)就緒(可read)等。關(guān)于事件的監(jiān)聽(tīng)其實(shí)也無(wú)關(guān)乎編程語(yǔ)言,在操作系統(tǒng)層面就可以做而且可以做的更高效。操作系統(tǒng)上提供了一系列系統(tǒng)調(diào)用,比如 select/poll/epool,這些系統(tǒng)調(diào)用后會(huì)阻塞,當(dāng)有對(duì)應(yīng)的事件到來(lái)觸發(fā)我們注冊(cè)到事件上的Handler邏輯。
所以簡(jiǎn)單來(lái)說(shuō),就是上文說(shuō)的 非阻塞I/O 用戶(hù)自行寫(xiě)輪詢(xún)查看狀態(tài)的邏輯被收斂到操作系統(tǒng)這里提供的 I/O 復(fù)用器了,整個(gè)程序執(zhí)行起來(lái)的邏輯大概變成這樣。
interface ChannelHandler{
void channelReadable(Channel channel);
void channelWritable(Channel channel);
}
class Channel{
Socket socket;
Event event;//讀,寫(xiě)或者連接
}
//IO線程主循環(huán):
class IoThread extends Thread{
public void run(){
Channel channel;
while(channel=Selector.select()){//選擇就緒的事件和對(duì)應(yīng)的連接
if(channel.event==accept){
registerNewChannelHandler(channel);//如果是新連接,則注冊(cè)一個(gè)新的讀寫(xiě)處理器
}
if(channel.event==write){
getChannelHandler(channel).channelWritable(channel);//如果可以寫(xiě),則執(zhí)行寫(xiě)事件
}
if(channel.event==read){
getChannelHandler(channel).channelReadable(channel);//如果可以讀,則執(zhí)行讀事件
}
}
}
Map handlerMap;//所有channel的對(duì)應(yīng)事件處理器
} Reactor 模型
目前大多高性能的網(wǎng)絡(luò)IO框架主要都是基于IO多路復(fù)用 + 池化技術(shù)的的 Reactor 模型,Reactor 其實(shí)只是一個(gè)網(wǎng)絡(luò)模型概念并不是具體的某項(xiàng)具體技術(shù)。常見(jiàn)的主要有三種,單Reactor + 單進(jìn)程/單線程、單Reactor + 多線程、多Reactor + 多進(jìn)程/多線程。
單Reactor + 單進(jìn)程/單線程
多路復(fù)用器 Select 返回結(jié)果后,有個(gè) Dispatch 用于分發(fā)結(jié)果事件。如果是連接建立事件,Acceptor接受連接并創(chuàng)建對(duì)應(yīng)的Handler來(lái)處理后續(xù)事件。如果不是連接事件,直接調(diào)用對(duì)應(yīng)的 Handler,Handler 完成數(shù)據(jù)讀取 read 、process、send 的完整業(yè)務(wù)流程。
這種模式優(yōu)點(diǎn)是簡(jiǎn)單、不用考慮進(jìn)程間通信、線程安全、資源競(jìng)爭(zhēng)等問(wèn)題,但是也有自身局限性,也就是無(wú)法充分利用多核資源,適用于業(yè)務(wù)場(chǎng)景處理很快的場(chǎng)景,比如 Redis 就是用這種方案。
單Reactor + 多線程
相比于上一種方案,不同的是 Handler 只負(fù)責(zé)數(shù)據(jù)讀取不負(fù)責(zé)處理事件,而是有一個(gè)單獨(dú)的 Worker 線程池來(lái)做具體的事情。之所以 processor 要隔離單獨(dú)的線程池是因?yàn)?`read` 方法本身是需要消耗 cpu 資源的,通常不適合大于 cpu 核數(shù),而用戶(hù)自定義的 processor 邏輯里可能有各種網(wǎng)絡(luò)請(qǐng)求,比如 RPC 請(qǐng)求,如果隔離開(kāi)來(lái),那么 processor 可以設(shè)置更大的線程數(shù),提升吞吐量。
這種模式已經(jīng)可以比較充分利用到多核資源了,但是問(wèn)題在于主線程承擔(dān)了所有的事件監(jiān)聽(tīng)和響應(yīng)。瞬間高并發(fā)時(shí)可能成為瓶頸,這就需要多 Reactor 的方案了。
多Reactor + 多進(jìn)程/多線程
處理步驟:
- 父進(jìn)程中 mainReactor 對(duì)象通過(guò) select 監(jiān)控連接建立事件,收到事件后通過(guò) Acceptor接收,將新的連接分配給某個(gè)子進(jìn)程。
- 子進(jìn)程的 subReactor 將 mainReactor 分配的連接加入連接隊(duì)列進(jìn)行監(jiān)聽(tīng),并創(chuàng)建一個(gè)Handler 用于處理連接的各種事件。
- 當(dāng)有新的事件發(fā)生時(shí),subReactor 會(huì)調(diào)用連接對(duì)應(yīng)的 Handler 來(lái)進(jìn)行響應(yīng)。
- Handler 完成 read→處理→send 的完整業(yè)務(wù)流程。
目前著名的開(kāi)源系統(tǒng) Nginx 采用的是多 Reactor 多進(jìn)程,采用多 Reactor 多線程的實(shí)現(xiàn)有Memcache 和 Netty。不過(guò)需要注意的是 Nginx 中與上圖中的方案稍有差異,具體表現(xiàn)在主進(jìn)程中并沒(méi)有mainReactor來(lái)建立連接,而是由子進(jìn)程中的subReactor建立。
異步非阻塞 I/O
服務(wù)器實(shí)現(xiàn)模式為一個(gè)有效請(qǐng)求一個(gè)線程,客戶(hù)端的I/O請(qǐng)求都是由OS先完成了再通知服務(wù)器應(yīng)用去啟動(dòng)線程進(jìn)行處理,AIO又稱(chēng)為NIO2.0,在JDK7才開(kāi)始支持。但是由于 Linux 上 AIO 的底層實(shí)現(xiàn)并不好,所以目前沒(méi)有被廣泛使用。比如大名鼎鼎的Netty框架也是使用NIO而非AIO。
總結(jié)
這篇文章從 socket 編程出發(fā),你了解到了怎么利用socket編寫(xiě)服務(wù)端代碼,然后在 socket 編程時(shí)發(fā)現(xiàn)了痛點(diǎn),一個(gè)在于 accept 建立連接會(huì)阻塞線程,另一個(gè)在于 read 數(shù)據(jù)時(shí)會(huì)阻塞,為了解決阻塞可能導(dǎo)致的低效問(wèn)題,我們嘗試了用多線程方法來(lái)初步解決。
但是在這之后,我們又看到面對(duì)海量連接時(shí),BIO 力不從心的現(xiàn)象,所以引入了 NIO 模型。這里闡述了從 NIO 到 多路復(fù)用器的進(jìn)步,相當(dāng)于是操作系統(tǒng)幫我們做了海量連接事件的監(jiān)聽(tīng),這個(gè)模式也被稱(chēng)作 Reactor 模式。最后講到了異步I/O,雖然理想很美好,但是底層基建并不完善,目前這種模式在生產(chǎn)中被使用還比較少。
我寫(xiě)這篇文章,并沒(méi)有描述很多具體的API,因?yàn)槲蚁Mㄟ^(guò)這個(gè)文章來(lái)幫助大家真正了解IO模型的本質(zhì),而不是羅列topic,或者硬性記憶API,因?yàn)榫幊陶Z(yǔ)言很多,而解決方案的思想是統(tǒng)一的。這也是我們學(xué)習(xí)應(yīng)該注意的,更多的應(yīng)該學(xué)其道,而不是學(xué)其術(shù)。
文章題目:網(wǎng)絡(luò)編程 | 徹底搞懂網(wǎng)絡(luò) IO 模型
文章位置:http://m.fisionsoft.com.cn/article/cdshpds.html


咨詢(xún)
建站咨詢(xún)
