官术网_书友最值得收藏!

2.1 通信方式

OSI是Open System Interconnection的縮寫(xiě),中文翻譯為開(kāi)放式系統(tǒng)互聯(lián)。國(guó)際標(biāo)準(zhǔn)化組織(ISO)制定了OSI模型,定義了不同計(jì)算機(jī)之間實(shí)現(xiàn)互聯(lián)的標(biāo)準(zhǔn),是網(wǎng)絡(luò)通信的基本框架。OSI模型將網(wǎng)絡(luò)通信分為七個(gè)層次,分別是物理層、數(shù)據(jù)鏈路層、網(wǎng)絡(luò)層、傳輸層、會(huì)話層、表示層和應(yīng)用層。

由于復(fù)雜度過(guò)高,OSI 模型并沒(méi)有 TCP/IP 模型應(yīng)用廣泛。TCP/IP 模型可以被理解為 OSI模型的濃縮版本,它將OSI模型的七層抽象為四層:原OSI模型中的物理層和數(shù)據(jù)鏈路層對(duì)應(yīng)網(wǎng)絡(luò)接口層;原OSI模型中的網(wǎng)絡(luò)層和傳輸層仍然保留;原OSI模型中的會(huì)話層、表示層和應(yīng)用層則合并為應(yīng)用層。

TCP/IP模型和OSI模型的對(duì)比關(guān)系如圖2-1所示。

圖2-1 TCP/IP模型和OSI模型

2.1.1 通信協(xié)議

位于傳輸層和應(yīng)用層的通信協(xié)議,是軟件開(kāi)發(fā)工程師需要重點(diǎn)關(guān)注的,下面我們具體來(lái)看一下。

傳輸層協(xié)議

傳輸層的作用是使源端和目的端的計(jì)算機(jī)以對(duì)等的方式進(jìn)行會(huì)話,實(shí)現(xiàn)端到端的傳輸。位于傳輸層的通信協(xié)議有TCP和UDP。采用TCP作為應(yīng)用程序間的遠(yuǎn)程通信傳輸方案十分常見(jiàn), UDP也有其特定的使用場(chǎng)景,下面重點(diǎn)介紹這兩種協(xié)議。

1.TCP

TCP是Transmission Control Protocol的縮寫(xiě),中文譯法為傳輸控制協(xié)議。它是一種面向連接的協(xié)議,提供可靠的雙向字節(jié)流。TCP通過(guò)三次握手的連接創(chuàng)建機(jī)制確保連接的可靠性。一個(gè)簡(jiǎn)明的三次握手流程如圖2-2所示,具體描述如下。

SYN:客戶端發(fā)送包含同步序列號(hào)的SYN報(bào)文,并且同時(shí)傳遞一個(gè)隨機(jī)數(shù)作為順序號(hào)。

為了方便描述,我們將該順序號(hào)設(shè)為x。

SYN-ACK:服務(wù)端在接收到請(qǐng)求之后,返回SYN-ACK報(bào)文作為應(yīng)答,并且同時(shí)傳遞一個(gè)值為x+1的應(yīng)答號(hào)以及另一個(gè)隨機(jī)數(shù)作為服務(wù)端的序列號(hào)。同樣,為了方便描述,我們將服務(wù)端序列號(hào)設(shè)為y。

ACK:客戶端在接收到服務(wù)端的應(yīng)答后,分別將y+1與x+1作為應(yīng)答號(hào)和序列號(hào)再次發(fā)送至服務(wù)端。

圖2-2 一個(gè)簡(jiǎn)明的三次握手流程

在三次握手的流程以及序列號(hào)與應(yīng)答號(hào)都校驗(yàn)無(wú)誤后,才會(huì)完成連接的創(chuàng)建,同時(shí)發(fā)送數(shù)據(jù)。因此TCP的連接創(chuàng)建過(guò)程是較為“昂貴”的。

在開(kāi)啟連接和三次握手之后,客戶端和服務(wù)端即可在網(wǎng)絡(luò)間雙向傳遞消息,圖2-3展示了TCP建立連接和發(fā)送數(shù)據(jù)的過(guò)程。

TCP通過(guò)顯式方式確認(rèn)連接的創(chuàng)建和終止,因此也被稱(chēng)為面向連接的協(xié)議。通信時(shí)存在必要的創(chuàng)建連接開(kāi)銷(xiāo),TCP的開(kāi)銷(xiāo)高于UDP,性能低于UDP。但使用TCP可以保證數(shù)據(jù)的正確性、順序性和不可重復(fù)性,對(duì)于業(yè)務(wù)應(yīng)用間的通信而言,TCP是更合適的選擇。

圖2-3 TCP建立連接和發(fā)送數(shù)據(jù)的過(guò)程

下面我們來(lái)看一下在Java中使用Socket開(kāi)發(fā)TCP的過(guò)程。

Socket是用于連通應(yīng)用層與傳輸層之間的抽象層接口。Socket可翻譯為套接字,這個(gè)譯法并不易于理解,因此下文還是統(tǒng)一稱(chēng)其為Socket。Socket通過(guò)IP地址和端口確定一個(gè)網(wǎng)絡(luò)環(huán)境中唯一的通信句柄(handler),應(yīng)用通過(guò)句柄向網(wǎng)絡(luò)中的其他服務(wù)發(fā)送請(qǐng)求,同時(shí)處理接收的請(qǐng)求。

Java的網(wǎng)絡(luò)編程基礎(chǔ)是從Socket的TCP編程開(kāi)始的,TCP采用C/S模式,即客戶端/服務(wù)器模式,這與服務(wù)化中的消費(fèi)者/提供者模式概念等同,只不過(guò)服務(wù)化中的應(yīng)用可以既是服務(wù)的消費(fèi)者,同時(shí)也是其他服務(wù)的提供者。Java的Socket編程API封裝了TCP的三次握手等復(fù)雜交互,通過(guò)以下六個(gè)步驟可以簡(jiǎn)單實(shí)現(xiàn)一個(gè)基于TCP的Socket編程的處理流程。

服務(wù)端程序綁定一個(gè)未占用的端口用于監(jiān)聽(tīng)客戶端程序的連接請(qǐng)求。

客戶端程序向服務(wù)端發(fā)起連接請(qǐng)求,請(qǐng)求過(guò)程中附帶自身的主機(jī) IP 地址和通信端口號(hào)。

服務(wù)端接受客戶端的連接請(qǐng)求。

客戶端與服務(wù)端通過(guò)Socket進(jìn)行I/O通信。

建立通信管道后,可以考慮使用多線程機(jī)制增加服務(wù)端的吞吐量。

完成通信,客戶端斷開(kāi)與服務(wù)端的連接。

使用Java實(shí)現(xiàn)基于TCP的服務(wù)端的關(guān)鍵代碼如下。

上述代碼用于創(chuàng)建ServerSocket,并將每一次接收的客戶端處理請(qǐng)求放入線程池,已達(dá)到最大吞吐量。

在讀/寫(xiě)消息時(shí),需要對(duì)Java的I/O類(lèi)庫(kù)有一個(gè)基本的理解。使用Java I/O進(jìn)行消息讀取的關(guān)鍵代碼如下。

通過(guò)Socket,我們可以簡(jiǎn)單地獲取相關(guān)的輸入流和輸出流。Java I/O 的API在讀取客戶端發(fā)送的消息時(shí),先使用read(byte [] b)方法讀取消息,然后將結(jié)果放入buffer的字節(jié)數(shù)組。這里定義的字節(jié)數(shù)組大小是1024B,如果傳輸?shù)南⒋笮〈笥谶@個(gè)值,則需要反復(fù)讀取,并在每次讀取時(shí)將中間結(jié)果放入buffer。

當(dāng)read方法的返回值等于-1時(shí),即表示客戶端傳輸消息的過(guò)程已經(jīng)結(jié)束,可以結(jié)束讀取。如果 read 方法的返回值不為-1,則表示上次讀取的字節(jié)數(shù)。因此在循環(huán)讀取時(shí),需要使用read(byte [] b, int offset, int length)方法,避免在最后一次讀取消息時(shí),由于并未使用全部的buffer字節(jié)數(shù)組而導(dǎo)致字節(jié)數(shù)組中l(wèi)ength之后的數(shù)據(jù)仍然是上一次讀取的臟數(shù)據(jù)。

使用Java實(shí)現(xiàn)基于TCP的客戶端的關(guān)鍵代碼如下。

客戶端通過(guò)IP地址和端口連接服務(wù)端,并獲取Socket的輸入流和輸出流,至于I/O操作也與服務(wù)端的操作一致。使用Java開(kāi)發(fā)TCP,屏蔽了三次握手等眾多協(xié)議上的細(xì)節(jié),降低了開(kāi)發(fā)難度。

2.UDP

UDP是User Datagram Protocol的縮寫(xiě),中文譯法為用戶數(shù)據(jù)報(bào)文協(xié)議。

UDP是一種無(wú)連接的、面向數(shù)據(jù)報(bào)文的協(xié)議。每個(gè)數(shù)據(jù)報(bào)文都是一個(gè)獨(dú)立的信息,包括完整的源地址或目的地址,在網(wǎng)絡(luò)上可以通過(guò)任何可能的路徑被傳往目的地,因此,數(shù)據(jù)報(bào)文能否到達(dá)目的地,到達(dá)目的地的時(shí)間是否準(zhǔn)確,傳送的內(nèi)容是否正確,這些都是不能被保證的。

UDP 不要求通信時(shí)保持統(tǒng)一的連接,也不會(huì)由于接收方確認(rèn)收到報(bào)文而產(chǎn)生開(kāi)銷(xiāo)。圖2-4展示了UDP傳輸數(shù)據(jù)的過(guò)程,通過(guò)與圖2-3進(jìn)行對(duì)比,我們可以看到,UDP中沒(méi)有復(fù)雜的建立連接的過(guò)程,它關(guān)注的僅是數(shù)據(jù)報(bào)文本身。

圖2-4 UDP傳輸數(shù)據(jù)的過(guò)程

雖然UDP可能產(chǎn)生網(wǎng)絡(luò)丟包,并且無(wú)法保證傳輸?shù)脑许樞颍谛阅芊矫娓純?yōu)勢(shì),更加適用于允許數(shù)據(jù)被部分丟棄的業(yè)務(wù)場(chǎng)景,如系統(tǒng)調(diào)用追蹤日志、視頻會(huì)議流等。

下面我們來(lái)看一下在Java中使用Socket開(kāi)發(fā)UDP的過(guò)程。

Java的Socket編程API同樣封裝了UDP,使其發(fā)送端和接收端的開(kāi)發(fā)也變得更加簡(jiǎn)單。與開(kāi)發(fā)TCP相比,除了接口不同,處理流程沒(méi)有區(qū)別。

使用Java實(shí)現(xiàn)基于UDP的消息發(fā)送機(jī)制的關(guān)鍵代碼如下。

使用Java實(shí)現(xiàn)基于UDP的消息接收機(jī)制的關(guān)鍵代碼如下。

相比于開(kāi)發(fā)TCP,開(kāi)發(fā)UDP更加簡(jiǎn)單,Socket的接收端和發(fā)送端以對(duì)等的形式存在,無(wú)須建立連接。但正如示例所示,開(kāi)發(fā)一個(gè)健壯的UDP交互程序,需要考慮報(bào)文無(wú)法及時(shí)發(fā)送的場(chǎng)景。

應(yīng)用層協(xié)議

應(yīng)用層包含所有的高層協(xié)議,例如FTP(File Transfer Protocol,文件傳輸協(xié)議)、SMTP(Simple Mail Transfer Protocol,簡(jiǎn)單電子郵件傳輸協(xié)議)、DNS(Domain Name Service,域名服務(wù))和HTTP(HyperText Transfer Protocol,超文本傳送協(xié)議)等。其中HTTP是當(dāng)今互聯(lián)網(wǎng)應(yīng)用中使用最廣泛的應(yīng)用層協(xié)議,也是應(yīng)用程序間進(jìn)行遠(yuǎn)程通信時(shí)采用比較多的協(xié)議。

1.HTTP

HTTP 是互聯(lián)網(wǎng)中應(yīng)用最為廣泛的協(xié)議,基于瀏覽器的 HTML、XML、JSON 等格式的文本都是通過(guò) HTTP 進(jìn)行傳輸?shù)摹TTP 的使用非常便捷,當(dāng)客戶端向服務(wù)端請(qǐng)求服務(wù)時(shí),只發(fā)送路徑、參數(shù)以及請(qǐng)求方法即可。常用的請(qǐng)求方法有GET、POST、UPDATE、DELETE等,它們是組成RESTful架構(gòu)不可或缺的部分。

HTTP/1.1自1999年起正式被標(biāo)準(zhǔn)化,到目前為止已被廣泛使用。它是無(wú)連接且無(wú)狀態(tài)的。無(wú)連接是指每次連接只處理一個(gè)請(qǐng)求,服務(wù)端處理完該請(qǐng)求且收到應(yīng)答后,便斷開(kāi)連接。無(wú)狀態(tài)是指協(xié)議對(duì)于業(yè)務(wù)事務(wù)處理并沒(méi)有記憶能力,如果后續(xù)處理需要之前的信息,則本次請(qǐng)求必須一次性包含之前的全部信息。

在HTTP/1.1時(shí)代,由于無(wú)法在同一個(gè)連接上并發(fā)請(qǐng)求,瀏覽器需要花費(fèi)大量的時(shí)間等待每一個(gè)資源被響應(yīng),因此瀏覽器通常需要開(kāi)啟多個(gè)連接來(lái)加速請(qǐng)求資源的過(guò)程。但開(kāi)啟過(guò)多連接的代價(jià)是十分昂貴的,所以現(xiàn)代瀏覽器通常都會(huì)被限制最多開(kāi)啟6~8個(gè)HTTP/1.1連接。也正因?yàn)槿绱耍艜?huì)產(chǎn)生各種CSS、JavaScript以及圖片合并技術(shù),用于將眾多小文件合并成一個(gè)完整的大文件,減少文件的個(gè)數(shù),提升瀏覽器加載文件的性能。但不幸的是,單一的大文件會(huì)阻塞后續(xù)的請(qǐng)求,極度影響用戶體驗(yàn)。總之,連接的限制逐漸成了整個(gè)Web 系統(tǒng)的性能瓶頸。

直到2015年,HTTP才進(jìn)行了首次重大升級(jí),由HTTP/1.1變?yōu)镠TTP/2。HTTP/2 的目標(biāo)是,在與 HTTP/1.1語(yǔ)義完全兼容的前提下進(jìn)一步減少網(wǎng)絡(luò)延遲。也就是說(shuō),HTTP/2 是在不改變?cè)?Web 體系的同時(shí)提升性能的,是通過(guò)多路復(fù)用機(jī)制實(shí)現(xiàn)的。

HTTP/2 的多路復(fù)用機(jī)制允許通過(guò)單一的連接同時(shí)發(fā)起多個(gè)請(qǐng)求和響應(yīng)消息,這極大地提升了網(wǎng)絡(luò)傳輸?shù)男阅堋D2-5清晰展示了HTTP/1.1和HTTP/2的差別。

要想在HTTP/1.1中展示一個(gè)包含CSS和JavaScript的HTML頁(yè)面,需要以下九個(gè)步驟。

瀏覽器和服務(wù)器創(chuàng)建連接。

客戶端通過(guò)GET方法請(qǐng)求index.html來(lái)獲取頁(yè)面內(nèi)容。

服務(wù)器返回index.html的內(nèi)容。

客戶端通過(guò)GET方法請(qǐng)求style.css來(lái)獲取頁(yè)面樣式表。

服務(wù)器返回style.css的內(nèi)容。

客戶端通過(guò)GET方法請(qǐng)求script.js來(lái)獲取JavaScript腳本渲染頁(yè)面。

圖2-5 HTTP/1.1和HTTP/2的差別

服務(wù)器返回script.js的內(nèi)容。

瀏覽器加載完畢,開(kāi)始渲染頁(yè)面。

關(guān)閉連接。

可以看到,渲染每個(gè)頁(yè)面時(shí)都需要加載一個(gè)頁(yè)面的HTML、CSS和JavaScript文件,這些文件是同步等待的,雖然可以通過(guò)開(kāi)啟多個(gè)連接來(lái)加速加載,但會(huì)增加服務(wù)端的負(fù)荷。并且在每次請(qǐng)求結(jié)束之后,瀏覽器和服務(wù)器之間的連接便會(huì)關(guān)閉,下次請(qǐng)求還需要進(jìn)行握手并建立連接。

HTTP/2將展現(xiàn)一個(gè)頁(yè)面的過(guò)程進(jìn)行了很大的優(yōu)化,只包含七個(gè)步驟,具體如下。

瀏覽器和服務(wù)器創(chuàng)建連接。由于 HTTP/2支持長(zhǎng)連接,因此如果之前創(chuàng)建的連接仍然存在,則此步驟可以省略。

客戶端通過(guò)GET方法請(qǐng)求index.html來(lái)獲取頁(yè)面內(nèi)容。因?yàn)楸仨毾全@取HTML的內(nèi)容才能知道該頁(yè)面中還包含哪些需要加載的資源,因此獲取頁(yè)面內(nèi)容是同步的。

服務(wù)器返回index.html的內(nèi)容。

客戶端通過(guò)GET方法請(qǐng)求style.css和script.js來(lái)獲取頁(yè)面樣式表和JavaScript腳本。通過(guò)一個(gè)連接的多路復(fù)用可以同時(shí)請(qǐng)求多個(gè)文件。

服務(wù)器通過(guò)連接的多路復(fù)用返回style.css和script.js的內(nèi)容。

瀏覽器加載完畢,開(kāi)始渲染頁(yè)面。

保留連接,以便下次請(qǐng)求時(shí)使用。可以通過(guò)設(shè)置連接保留時(shí)間和最大連接限制以避免用戶離開(kāi)網(wǎng)站以及服務(wù)端持有連接過(guò)多的問(wèn)題。

關(guān)于HTTP/2和HTTP/1.1的性能差異,感興趣的讀者可以通過(guò)網(wǎng)絡(luò)資料進(jìn)行深入了解,例如,大家可以訪問(wèn)https://http2.akamai.com/demo,查看Akamai公司建立的一個(gè)官方演示示例。這個(gè)示例同時(shí)請(qǐng)求379張圖片,用于展示HTTP/1.1和HTTP/2的性能差異。在電腦配置不同、網(wǎng)絡(luò)情況不同、服務(wù)器負(fù)載情況不同時(shí),得到的結(jié)果肯定也不同。圖2-6是筆者使用自己的電腦進(jìn)行演示時(shí)的截圖,我們可以從中看到HTTP/1.1和HTTP/2在加載時(shí)間上的差異。

HTTP/2 通過(guò)數(shù)據(jù)流(stream)的方式支持連接的多路復(fù)用。一個(gè)連接可以包含多個(gè)數(shù)據(jù)流,多個(gè)數(shù)據(jù)流發(fā)送的數(shù)據(jù)互不影響,將請(qǐng)求和響應(yīng)在同一個(gè)連接中分成不同的數(shù)據(jù)流可以進(jìn)一步提升交互的性能。

HTTP/2將每次的請(qǐng)求和響應(yīng)以幀(frame)為單位進(jìn)行了更細(xì)粒度的劃分,所有的幀都在數(shù)據(jù)流上進(jìn)行傳輸,數(shù)據(jù)流會(huì)確定好幀的發(fā)送順序,另一端會(huì)按照接收的順序來(lái)處理。除了多路復(fù)用,HTTP/2還提供服務(wù)器推送和請(qǐng)求頭壓縮等功能。

隨著服務(wù)化的發(fā)展,HTTP 不再僅被用于瀏覽器或移動(dòng)端與后端服務(wù)的交互,而是越來(lái)越多地被用于后端應(yīng)用之間的交互。與微服務(wù)配套使用的“HTTP/1.1+RESTful API”組合已經(jīng)非常成熟,由Google開(kāi)源的基于HTTP/2的異構(gòu)語(yǔ)言高性能RPC框架gRPC也受到了廣泛的關(guān)注。

圖2-6 HTTP/1.1和HTTP/2在加載時(shí)間上的差異

2.長(zhǎng)連接與短連接

長(zhǎng)連接與短連接都是客戶端連接服務(wù)端的方式。

長(zhǎng)連接是指客戶端與服務(wù)端長(zhǎng)期保持連接,連接不會(huì)在一次業(yè)務(wù)操作結(jié)束后斷開(kāi),連接一旦創(chuàng)建成功便可以最大限度地復(fù)用,以降低資源開(kāi)銷(xiāo)、提升性能。長(zhǎng)連接的維護(hù)成本較高,需要實(shí)時(shí)監(jiān)控檢查,以保持連接的連通性。

短連接是指客戶端和服務(wù)端在處理完一次請(qǐng)求之后即斷開(kāi)連接,下次處理請(qǐng)求時(shí)則需要重新建立連接。雖然每次建立連接的消耗都比較大,但短連接無(wú)須維護(hù)連接的狀態(tài),相比長(zhǎng)連接,其實(shí)現(xiàn)復(fù)雜度大幅降低。

對(duì)于長(zhǎng)連接和短連接的認(rèn)識(shí),有以下兩個(gè)常見(jiàn)的誤區(qū)。

第一個(gè)誤區(qū):區(qū)分TCP和HTTP的關(guān)鍵在于,TCP使用長(zhǎng)連接方式,HTTP使用短連接方式。通過(guò)前面的介紹,我們知道TCP與HTTP處于不同的網(wǎng)絡(luò)層次,而HTTP是基于TCP的,因此TCP和HTTP的區(qū)別并不在于使用長(zhǎng)連接還是短連接。

第二個(gè)誤區(qū):HTTP只能使用短連接。前面的章節(jié)也介紹過(guò),HTTP自HTTP/2以來(lái),已經(jīng)全面支持長(zhǎng)連接,而TCP也可以使用短連接。

那么,對(duì)于長(zhǎng)連接和短連接,使用時(shí)究竟應(yīng)該如何選擇呢?

長(zhǎng)連接更加適用于端對(duì)端的頻繁通信。每個(gè)基于TCP的連接都需要經(jīng)過(guò)三次握手,高頻率的通信如果將時(shí)間都浪費(fèi)在連接的建立上,就很不劃算了。但是,由于維護(hù)連接會(huì)產(chǎn)生消耗,因此連接的數(shù)量不能無(wú)限制增加。綜上所述,長(zhǎng)連接更加適用于面向后端的系統(tǒng)之間的交互。例如,應(yīng)用系統(tǒng)之間的交互,數(shù)據(jù)庫(kù)訪問(wèn)服務(wù)與數(shù)據(jù)庫(kù)的交互等。它們的共同特點(diǎn)是,交互頻率高且連接個(gè)數(shù)有限。

基于 B/S 模式的瀏覽器與服務(wù)器交互的情況,更加適合使用短連接。HTTP 是無(wú)狀態(tài)的,瀏覽器和服務(wù)器每進(jìn)行一次交互便會(huì)建立一次連接,任務(wù)結(jié)束后便直接關(guān)閉連接。面向互聯(lián)網(wǎng)海量用戶的網(wǎng)站為每一個(gè)用戶維持一個(gè)連接,這是無(wú)法承受的成本,而且相對(duì)于服務(wù)之間的交互,人為操作的頻率與之完全不是一個(gè)數(shù)量級(jí)。除了面向用戶的連接,面向服務(wù)的后端場(chǎng)景也有可能使用短連接,由于基于HTTP的短連接實(shí)現(xiàn)起來(lái)非常便捷,因此如果服務(wù)間交互的性能不是系統(tǒng)瓶頸,那么使用短連接也是可以的。

總之,選擇長(zhǎng)連接還是短連接不能一概而論,而是應(yīng)該視情況而定。

2.1.2 I/O模型

I/O即輸入/輸出(Input/Output)。每個(gè)應(yīng)用系統(tǒng)間相互的依賴調(diào)用都無(wú)法完全避免,我們將這樣的系統(tǒng)間調(diào)用稱(chēng)為遠(yuǎn)程通信。每個(gè)應(yīng)用系統(tǒng)自身也將或多或少地產(chǎn)生數(shù)據(jù),我們稱(chēng)這種本地調(diào)用為本地讀/寫(xiě)。I/O便是遠(yuǎn)程通信和本地讀/寫(xiě)的核心。

雖然地位重要,但I(xiàn)/O的性能發(fā)展明顯落后于 CPU。對(duì)于高性能、高并發(fā)的應(yīng)用系統(tǒng)來(lái)說(shuō),如何回避I/O瓶頸從而提升性能,這一點(diǎn)是至關(guān)重要的。

一般來(lái)說(shuō),I/O模型可以分為阻塞與非阻塞、同步與異步,下面我們分別進(jìn)行介紹。

阻塞與非阻塞

阻塞 I/O 是指,在用戶進(jìn)程發(fā)起 I/O 操作后,需要等待操作完成才能繼續(xù)運(yùn)行。前面介紹的Socket編程使用的就是這種方式。阻塞I/O的編程模型非常易于理解,但性能卻并不理想,它會(huì)造成CPU大量閑置。使用阻塞I/O開(kāi)發(fā)的系統(tǒng),其吞吐量會(huì)比較低。雖然可以進(jìn)行優(yōu)化,使每一次 Socket 請(qǐng)求使用獨(dú)立的線程,但這樣做會(huì)造成線程膨脹,使系統(tǒng)越來(lái)越慢,最終宕機(jī)。通過(guò)線程池可以控制系統(tǒng)創(chuàng)建線程的數(shù)量,但仍然無(wú)法實(shí)現(xiàn)系統(tǒng)性能最優(yōu)。

非阻塞I/O是指,在用戶進(jìn)程發(fā)起I/O操作后,無(wú)須等待操作完成即可繼續(xù)進(jìn)行其他操作,但用戶進(jìn)程需要定期詢問(wèn)I/O操作是否就緒。可以使用一個(gè)線程監(jiān)聽(tīng)所有的 Socket請(qǐng)求,從而極大地減少線程數(shù)量。對(duì)于I/O與CPU密集程度適度的操作而言,使用非阻塞將會(huì)極大地提升系統(tǒng)吞吐量,但用戶進(jìn)程不停輪詢會(huì)在一定程度上導(dǎo)致額外的CPU資源浪費(fèi)。

因此,判斷阻塞I/O與非阻塞I/O時(shí)應(yīng)關(guān)注程序是否在等待調(diào)用結(jié)果——如果系統(tǒng)內(nèi)核中的數(shù)據(jù)還未準(zhǔn)備完成,用戶進(jìn)程是繼續(xù)等待直至準(zhǔn)備完成,還是直接返回并先處理其他事情。

同步與異步

操作系統(tǒng)的I/O遠(yuǎn)比上面講述的要復(fù)雜。Linux內(nèi)核會(huì)將所有的外部設(shè)備當(dāng)作一個(gè)文件來(lái)操作,與外部設(shè)備的交互均可等同于對(duì)文件進(jìn)行操作,Linux 對(duì)文件的讀/寫(xiě)全是通過(guò)內(nèi)核提供的系統(tǒng)調(diào)用來(lái)實(shí)現(xiàn)的。Linux內(nèi)核使用file descriptor對(duì)本地文件進(jìn)行讀/寫(xiě),同理,Linux內(nèi)核使用socket file descriptor處理與Socket相關(guān)的網(wǎng)絡(luò)讀/寫(xiě),即應(yīng)用程序?qū)ξ募淖x/寫(xiě)通過(guò)對(duì)描述符的讀/寫(xiě)來(lái)實(shí)現(xiàn)。 I/O 涉及兩個(gè)系統(tǒng)對(duì)象,一個(gè)是調(diào)用它的用戶進(jìn)程,另一個(gè)是系統(tǒng)內(nèi)核(kernel)。一次讀取操作涉及以下幾個(gè)步驟。

用戶進(jìn)程調(diào)用read方法向內(nèi)核發(fā)起讀請(qǐng)求并等待就緒。

內(nèi)核將要讀取的數(shù)據(jù)復(fù)制到文件描述符所指向的內(nèi)核緩存區(qū)。

內(nèi)核將數(shù)據(jù)從內(nèi)核緩存區(qū)復(fù)制到用戶進(jìn)程空間。

阻塞與非阻塞、同步與異步都是I/O的不同維度。

同步 I/O 是指,在系統(tǒng)內(nèi)核準(zhǔn)備好處理數(shù)據(jù)后,還需要等待內(nèi)核將數(shù)據(jù)復(fù)制到用戶進(jìn)程,才能進(jìn)行處理。

異步I/O是指,用戶進(jìn)程無(wú)須關(guān)心實(shí)際I/O的操作過(guò)程,只需在I/O完成后由內(nèi)核接收通知,I/O操作全部由內(nèi)核進(jìn)程來(lái)執(zhí)行。

由此可見(jiàn),同步I/O和異步I/O針對(duì)的是內(nèi)核,而阻塞I/O與非阻塞I/O針對(duì)的則是調(diào)用它的函數(shù)。

同步I/O在實(shí)際使用中還是非常常見(jiàn)的。select、poll、epoll是Linux系統(tǒng)中使用最多的I/O多路復(fù)用機(jī)制。I/O 多路復(fù)用可以監(jiān)視多個(gè)描述符,一旦某個(gè)描述符讀/寫(xiě)操作就緒,便可以通知程序進(jìn)行相應(yīng)的讀/寫(xiě)操作。盡管實(shí)現(xiàn)方式不同,但select、poll、epoll都屬于同步I/O,它們?nèi)夹枰谧x/寫(xiě)事件就緒后再進(jìn)行讀/寫(xiě)操作,內(nèi)核向用戶進(jìn)程復(fù)制數(shù)據(jù)的過(guò)程仍然是阻塞的,但異步I/O無(wú)須自己負(fù)責(zé)讀/寫(xiě)操作,它負(fù)責(zé)把數(shù)據(jù)從內(nèi)核復(fù)制到用戶空間。

總結(jié)來(lái)說(shuō),判斷是同步I/O還是異步I/O,主要關(guān)注內(nèi)核數(shù)據(jù)復(fù)制到用戶空間時(shí)是否需要等待。

2.1.3 Java中的I/O

Java對(duì)于I/O的封裝分為BIO、NIO和AIO。Java目前并不支持異步I/O,BIO對(duì)應(yīng)的是阻塞同步I/O,NIO和AIO對(duì)應(yīng)的都是非阻塞同步I/O。由于Java的I/O接口比較面向底層,開(kāi)發(fā)工程師上手的難度并不低,因此衍生出不少第三方的 I/O 處理框架,如 Netty、Mina 等,使用它們能夠更加容易地開(kāi)發(fā)出健壯的通信類(lèi)程序。我們首先來(lái)看一下Java的I/O原生處理框架。

BIO

Java中的BIO是JDK 1.4以前的唯一選擇,程序直觀、簡(jiǎn)單、易理解。BIO操作每次從數(shù)據(jù)流中讀取字節(jié)直至讀取完成,這個(gè)過(guò)程中數(shù)據(jù)不會(huì)被緩存,但讀取效率較低,對(duì)服務(wù)器資源的占用也較高。因此,在當(dāng)前有很多替代方案的前提下,不建議大規(guī)模使用BIO,BIO僅適用于連接數(shù)少且并發(fā)不高的場(chǎng)景。

BIO 服務(wù)器實(shí)現(xiàn)模式為每一個(gè)連接都分配了一個(gè)線程,即客戶端有連接請(qǐng)求時(shí),服務(wù)端就需要啟動(dòng)一個(gè)線程進(jìn)行處理。它缺乏彈性伸縮能力,服務(wù)端的線程個(gè)數(shù)和客戶端并發(fā)訪問(wèn)數(shù)呈正比,隨著訪問(wèn)量的增加,線程數(shù)量會(huì)迅速膨脹,最終導(dǎo)致系統(tǒng)性能急劇下降。可以通過(guò)合理使用線程池來(lái)改進(jìn)“一連接一線程”模型,實(shí)現(xiàn)一個(gè)線程處理多個(gè)客戶端,但開(kāi)啟線程的數(shù)量終歸會(huì)受到系統(tǒng)資源的限制,而且頻繁進(jìn)行線程上下文切換也會(huì)導(dǎo)致CPU的利用率降低。BIO的處理架構(gòu)如圖2-7所示。

BIO編程難度不高,前面介紹的Socket編程的例子使用的就是BIO模式,所以這里不再贅述。BIO已經(jīng)不足以應(yīng)對(duì)當(dāng)前的互聯(lián)網(wǎng)場(chǎng)景,這一點(diǎn)要格外注意。

圖2-7 BIO的處理架構(gòu)

NIO

JDK 1.4的java.nio.*包中引入了全新的Java I/O類(lèi)庫(kù)。它最初使用select/poll模型,JDK 1.5之后又增加了對(duì)epoll的支持,不過(guò)只有Linux系統(tǒng)內(nèi)核版本在2.6及以上時(shí)才能生效。相比于BIO,NIO 的性能有了質(zhì)的提升,它適用于連接數(shù)多且連接比較短的輕量級(jí)操作架構(gòu),后端應(yīng)用系統(tǒng)間的調(diào)用使用NIO會(huì)非常合適。在目前互聯(lián)網(wǎng)高負(fù)載、高并發(fā)的場(chǎng)景下,NIO有極大的用武之地。它的美中不足是編程模型比較復(fù)雜,使用它實(shí)現(xiàn)一個(gè)健壯的框架并非易事。

NIO通過(guò)事件模型的異步通知機(jī)制去處理輸入/輸出的相關(guān)操作。在客戶端的連接建立完畢且讀取準(zhǔn)備就緒后,位于服務(wù)端的連接接收器便會(huì)觸發(fā)相關(guān)事件。與BIO不同,NIO的一切處理都是通過(guò)事件驅(qū)動(dòng)的,客戶端連接到服務(wù)端并創(chuàng)建通信管道,服務(wù)端會(huì)將通信管道注冊(cè)到事件選擇器,由事件選擇器接管事件的監(jiān)聽(tīng),并派發(fā)至工作線程進(jìn)行讀取、編解碼、計(jì)算以及發(fā)送。圖2-8展示了NIO的處理架構(gòu)。

在使用NIO之前,我們需要理解其中的一些核心概念,下面具體來(lái)看一下。

1.Buffer

Buffer是包含需要讀取或?qū)懭氲臄?shù)據(jù)的緩沖區(qū)。NIO中所有數(shù)據(jù)的讀/寫(xiě)操作均通過(guò)緩沖區(qū)進(jìn)行。常用的Buffer實(shí)現(xiàn)類(lèi)有ByteBuffer、MappedByteBuffer、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer、CharBuffer等。

圖2-8 NIO的處理架構(gòu)

所有類(lèi)型的Buffer實(shí)現(xiàn)類(lèi)都包含3個(gè)基本屬性:capacity、limit和position。

capacity 是緩沖區(qū)可容納的最大數(shù)據(jù)量,在緩沖區(qū)創(chuàng)建時(shí)被設(shè)置且不能在運(yùn)行時(shí)被改變。limit 是緩沖區(qū)當(dāng)前數(shù)據(jù)量的邊界。position 是下一個(gè)將要被讀或?qū)懙脑厮饕恢谩_@3個(gè)屬性的關(guān)系是capacity≥limit ≥ position≥0。

圖2-9展示了向緩沖區(qū)寫(xiě)入數(shù)據(jù)和從緩沖區(qū)讀取數(shù)據(jù)時(shí),這3個(gè)屬性的狀態(tài)。

圖2-9 緩沖區(qū)標(biāo)志位狀態(tài)

在寫(xiě)入數(shù)據(jù)時(shí),limit和capacity相同,每寫(xiě)入一組數(shù)據(jù),position便會(huì)加1,直至position到達(dá)capacity的位置或數(shù)據(jù)寫(xiě)入完畢,最終limit指向position的數(shù)值。在讀取數(shù)據(jù)時(shí),每讀取一組數(shù)據(jù),position 便會(huì)加1,讀取到 limit 所在的位置即結(jié)束,如果緩沖區(qū)完全被數(shù)據(jù)充滿,那么limit則等于capacity。

除了上述3個(gè)基本屬性,Buffer中還有一個(gè)mark屬性,用于標(biāo)記操作的位置,具體使用方式為,通過(guò)調(diào)用mark()方法將mark賦值給position,再通過(guò)調(diào)用reset()方法將position恢復(fù)為mark記錄的值。

在 NIO 中,有兩種不同的緩沖區(qū),分別是直接緩沖區(qū)(direct buffer)和非直接緩沖區(qū)(non-direct buffer)。直接緩沖區(qū)可以直接操作JVM的堆外內(nèi)存,即系統(tǒng)內(nèi)核緩存中分配的緩沖區(qū);非直接緩沖區(qū)則只能操作JVM的堆中內(nèi)存。

創(chuàng)建直接緩沖區(qū)的代碼如下。

創(chuàng)建非直接緩沖區(qū)的代碼如下。

創(chuàng)建和釋放直接緩沖區(qū)比非直接緩沖區(qū)的代價(jià)要大一些。但使用直接緩沖區(qū)可以減少?gòu)南到y(tǒng)內(nèi)核進(jìn)程到用戶進(jìn)程間的數(shù)據(jù)拷貝,I/O的性能會(huì)有所提升。因此,應(yīng)該盡量將直接緩沖區(qū)用于I/O傳輸字節(jié)數(shù)較多且無(wú)須反復(fù)創(chuàng)建緩沖區(qū)的場(chǎng)景。

2.Channel

Channel是一個(gè)雙向的數(shù)據(jù)讀/寫(xiě)通道。與只能用于數(shù)據(jù)流的單向操作不同,Channel可以實(shí)現(xiàn)讀和寫(xiě)同時(shí)操作。Channel同時(shí)支持阻塞和非阻塞模式,在NIO中當(dāng)然更加推薦使用非阻塞模式。

用于文件操作的通道是FileChannel,用于網(wǎng)絡(luò)操作的通道則是SelectableChannel。NIO與BIO模型中的ServerSocket和Socket對(duì)應(yīng)的通道是ServerSocketChannel和SocketChannel,它們都是SelectableChannel的實(shí)現(xiàn)類(lèi)。

3.Selector

Selector通過(guò)不斷輪詢注冊(cè)在其上的Channel來(lái)選擇并分發(fā)已處理就緒的事件。它可以同時(shí)輪詢多個(gè) Channel,一個(gè) Selector 即使接入成千上萬(wàn)個(gè)客戶端也不會(huì)產(chǎn)生明顯的性能瓶頸。Selector是整個(gè)NIO的核心,理解Selector機(jī)制是理解整個(gè)NIO的關(guān)鍵所在。

Selector是所有Channel的管理者,當(dāng)Selector發(fā)現(xiàn)某個(gè)Channel的數(shù)據(jù)狀態(tài)有變化時(shí),會(huì)通過(guò)SelectorKey觸發(fā)相關(guān)事件,并由對(duì)此事件感興趣的應(yīng)用實(shí)現(xiàn)相關(guān)的事件處理器。使用單線程來(lái)處理多Channel可以極大地減少多個(gè)線程對(duì)系統(tǒng)資源的占用,降低上下文切換帶來(lái)的開(kāi)銷(xiāo)。

Selector可以被認(rèn)為是 NIO 中的管家。舉例說(shuō)明,在一個(gè)宅邸中,管家所負(fù)責(zé)的工作就是不停地檢查各個(gè)工作人員的狀態(tài),如仆人出門(mén)買(mǎi)東西、仆人回到宅邸、廚師做好飯等事件。這樣宅邸中所有人的狀態(tài),只需要詢問(wèn)管家就可以了。

一個(gè)Selector可以同時(shí)注冊(cè)、監(jiān)聽(tīng)和輪詢成百上千個(gè)Channel,一個(gè)用于處理I/O的線程可以同時(shí)并發(fā)處理多個(gè)客戶端連接,具體數(shù)量取決于進(jìn)程可用的最大文件句柄數(shù)。由于處理 I/O的線程數(shù)大幅減少,因此CPU用于處理線程切換和競(jìng)爭(zhēng)的時(shí)間也相應(yīng)減少,即NIO中的CPU利用率比BIO中的CPU利用率大幅提高。

最常見(jiàn)的Selector監(jiān)聽(tīng)事件有以下幾種。

客戶端連接服務(wù)端事件:對(duì)應(yīng)的SelectorKey為OP_CONNECT。

服務(wù)端接收客戶端連接事件:對(duì)應(yīng)的SelectorKey為OP_ACCEPT。

讀事件:對(duì)應(yīng)的SelectorKey為OP_READ。

寫(xiě)事件:對(duì)應(yīng)的SelectorKey為OP_WRITE。

介紹完NIO中的核心概念,我們?cè)賮?lái)介紹一下NIO的Reactor模式。

I/O多路復(fù)用機(jī)制采用事件分離器將I/O事件從事件源中分離,并分發(fā)至相應(yīng)的讀寫(xiě)事件處理器,開(kāi)發(fā)者只需要注冊(cè)待處理的事件及其回調(diào)方法即可。

NIO采用Reactor模式來(lái)實(shí)現(xiàn)I/O操作。Reactor模式是I/O多路復(fù)用技術(shù)的一種常見(jiàn)模式,主要用于同步I/O。在Reactor模式中,事件分離器在Socket讀寫(xiě)操作準(zhǔn)備就緒后,會(huì)將就緒事件傳遞給相應(yīng)的處理器并由其完成實(shí)際的讀寫(xiě)工作。事件分離器由一個(gè)不斷循環(huán)的獨(dú)立線程來(lái)實(shí)現(xiàn),在NIO中,事件分離器的角色由Selector擔(dān)任。它負(fù)責(zé)查詢I/O是否就緒,并在I/O就緒后調(diào)用預(yù)先注冊(cè)的相關(guān)處理器進(jìn)行處理。

以讀取數(shù)據(jù)操作為例,Reactor模式的流程如下。

Selector阻塞并等待讀事件發(fā)生。

Selector被讀事件喚醒,發(fā)送讀就緒事件至預(yù)先注冊(cè)的事件處理器。

應(yīng)用程序讀取數(shù)據(jù)。

應(yīng)用程序處理相關(guān)業(yè)務(wù)邏輯。

下面是使用NIO初始化一個(gè)同步非阻塞I/O服務(wù)端的核心代碼。

值得注意的是,在服務(wù)端初始化時(shí),只需向通道注冊(cè)SelectionKey.OP_ACCEPT事件即可,當(dāng)OP_ACCEPT事件未到達(dá)時(shí),selector.select()將一直阻塞。OP_ACCEPT事件表示服務(wù)端已就緒,可以開(kāi)始處理客戶端的連接。

下面是使用NIO處理同步非阻塞I/O請(qǐng)求的服務(wù)端核心代碼。

如果沒(méi)有已經(jīng)注冊(cè)的事件到達(dá),selector.select()將會(huì)一直處于阻塞狀態(tài)。當(dāng)有注冊(cè)事件到達(dá)時(shí),阻塞狀態(tài)結(jié)束,繼續(xù)處理。因此selector.select()非常適合用作循環(huán)的開(kāi)始。這里處理了建立連接和讀取消息這兩個(gè)最常見(jiàn)的操作。當(dāng)OP_ACCEPT事件未到達(dá)時(shí), selector.select()將一直阻塞。server.accept()用于客戶端連接的初始化,主要步驟是與客戶端建立連接,設(shè)置非阻塞模型,以及注冊(cè)管道讀取事件。只有在與客戶端建立連接時(shí)注冊(cè)了消息讀取,在后續(xù)有消息從客戶端發(fā)送過(guò)來(lái)時(shí),selector.select()才會(huì)響應(yīng)。由于在初始化的start方法中只注冊(cè)了OP_ACCEPT事件,因此需要在接受連接創(chuàng)建之后注冊(cè) OP_READ 事件,用于處理讀數(shù)據(jù)操作(不注冊(cè) OP_READ事件的話,程序是不會(huì)處理消息讀取事件的)。

下面是使用NIO初始化一個(gè)同步非阻塞I/O客戶端的核心代碼。

下面是使用NIO處理同步非阻塞I/O請(qǐng)求的客戶端核心代碼。

理解和學(xué)會(huì)使用Selector是NIO的關(guān)鍵。采取I/O多路復(fù)用可以在同一時(shí)間處理多客戶端的接入請(qǐng)求,該項(xiàng)技術(shù)能夠?qū)⒍鄠€(gè)I/O阻塞復(fù)用至同一個(gè)Selector阻塞,讓?xiě)?yīng)用具有通過(guò)單線程同時(shí)處理多客戶端請(qǐng)求的能力。與傳統(tǒng)的多線程模型相比,I/O多路復(fù)用比傳統(tǒng)的多線程模型更能降低系統(tǒng)開(kāi)銷(xiāo)。

NIO通過(guò)非阻塞I/O實(shí)現(xiàn)編程模型,雖然大大增加了代碼的編寫(xiě)難度,但給應(yīng)用的性能帶來(lái)了質(zhì)的提升。因此,直到現(xiàn)在,在使用Java原生接口編寫(xiě)網(wǎng)絡(luò)通信程序時(shí),NIO仍然使用得最多。

AIO

隨著Java 7的推出,NIO.2也進(jìn)入了人們的視野。雖然NIO.2在2003年的JSR203(JSR為Java Specification Requests的縮寫(xiě),即Java規(guī)范提案,因此JSR203指Java的第203號(hào)規(guī)范提案)中就已經(jīng)被提出,但直到2011年才于JDK 7中實(shí)現(xiàn)并一同發(fā)布。NIO.2提供了更多的文件系統(tǒng)操作API以及文件的異步I/O操作(即AIO)。

AIO采用Proactor模式實(shí)現(xiàn)I/O操作。Proactor模式是I/O多路復(fù)用技術(shù)的另一種常見(jiàn)模式,它主要用于異步 I/O 處理。Proactor 模式與 Reactor 模式類(lèi)似,它們都使用事件分離器分離讀/寫(xiě)與任務(wù)派發(fā),但它比 Reactor 模式更進(jìn)一步,它不關(guān)心如何處理讀/寫(xiě)事件,而是由操作系統(tǒng)將讀/寫(xiě)操作執(zhí)行完后再通知回調(diào)方法,回調(diào)方法只關(guān)心自己需要處理的業(yè)務(wù)邏輯。

Reactor 模式的回調(diào)方法是在讀/寫(xiě)操作執(zhí)行之前被調(diào)用的,由應(yīng)用開(kāi)發(fā)者負(fù)責(zé)處理讀/寫(xiě)事件,而 Proactor 模式的回調(diào)方法則是在讀/寫(xiě)操作完畢后被調(diào)用的,應(yīng)用開(kāi)發(fā)者無(wú)須關(guān)心與讀/寫(xiě)相關(guān)的事情。因此Reactor模式用于同步I/O,而Proactor模式則面向異步I/O。

以讀取數(shù)據(jù)操作為例,Proactor模式的流程如下。

事件分離器阻塞并等待讀事件發(fā)生。

事件分離器被讀事件喚醒,并發(fā)送讀事件至操作系統(tǒng)進(jìn)行異步I/O處理。

事件分離器將數(shù)據(jù)準(zhǔn)備完畢的消息發(fā)送至預(yù)先注冊(cè)的事件處理器。

應(yīng)用程序處理相關(guān)業(yè)務(wù)邏輯。

不同的操作系統(tǒng)都對(duì) I/O 操作提供了系統(tǒng)級(jí)的支持,Java 作為跨平臺(tái)的開(kāi)發(fā)語(yǔ)言,在 I/O操作時(shí)需要對(duì)不同的操作系統(tǒng)進(jìn)行統(tǒng)一封裝。

AIO 實(shí)現(xiàn)時(shí)分別對(duì) Linux 與 Windows 平臺(tái)進(jìn)行了不同的封裝。在 Linux 操作系統(tǒng)中,2.6及以上版本的內(nèi)核對(duì)應(yīng)的是epoll,低版本則對(duì)應(yīng)select/poll,Windows系統(tǒng)使用iocp的系統(tǒng)級(jí)支持。由于Java的服務(wù)端程序很少將Windows作為生產(chǎn)服務(wù)器,因此Linux的I/O模型更加受到關(guān)注。雖然Windows中的iocp支持真正的異步I/O,但在Linux中,AIO并未真正使用操作系統(tǒng)所提供的異步I/O,它仍然使用poll或epoll,并將API封裝為異步I/O的樣子,但是其本質(zhì)仍然是同步非阻塞I/O。

AIO有兩種使用方式:一種是較為簡(jiǎn)單的將來(lái)式;另一種是稍為復(fù)雜的回調(diào)式。

將來(lái)式使用java.util.concurrent.Future對(duì)結(jié)果進(jìn)行訪問(wèn),在提交一個(gè)I/O請(qǐng)求之后即返回一個(gè)Future對(duì)象,然后通過(guò)檢查Future的狀態(tài)得到“操作完成”“失敗”或“正在進(jìn)行中”的狀態(tài),調(diào)用Future的get阻塞當(dāng)前進(jìn)程或獲取消息。但由于Future的get方法是同步并阻塞的,與完全同步的編程模式無(wú)異,導(dǎo)致異步操作僅為擺設(shè),因此并不推薦使用。

回調(diào)式是AIO的推薦使用方式。NIO.2提供java.nio.channels.CompletionHandler作為回調(diào)接口,該接口定義了completed和failed方法,用于讓?xiě)?yīng)用開(kāi)發(fā)者自行覆蓋并實(shí)現(xiàn)業(yè)務(wù)邏輯。當(dāng)I/O操作結(jié)束后,系統(tǒng)將會(huì)調(diào)用CompletionHandler的completed或failed方法來(lái)結(jié)束回調(diào)。

下面是使用AIO處理同步非阻塞I/O請(qǐng)求的服務(wù)端核心代碼。

以上代碼比起 NIO 的代碼要精簡(jiǎn)不少,至少?zèng)]有 Selector 的輪詢需要處理。AIO 采用AsynchronousChannelGroup 的線程池來(lái)處理事務(wù),這些事務(wù)主要包括等待 I/O 事件、處理數(shù)據(jù)以及分發(fā)至各個(gè)注冊(cè)的回調(diào)函數(shù)。通過(guò)匿名內(nèi)部類(lèi)的方式注冊(cè)事件回調(diào)方法,覆蓋 completed方法用于處理I/O的后續(xù)業(yè)務(wù)邏輯,方法最后需要再調(diào)用accept方法接受下一次請(qǐng)求,覆蓋failed方法用于處理I/O中產(chǎn)生的錯(cuò)誤。

AIO的客戶端代碼更簡(jiǎn)單,下面是AIO的客戶端核心代碼。

AIO雖然在編程接口上比起NIO更加簡(jiǎn)單,但是由于其使用的I/O模型與NIO是一樣的,因此兩者在性能方面并未有明顯差異。由于AIO出現(xiàn)的時(shí)間較晚,而且并沒(méi)有帶來(lái)實(shí)質(zhì)性的性能提升,因此沒(méi)有達(dá)到預(yù)想中的普及效果。

Netty

雖然AIO的出現(xiàn)進(jìn)一步簡(jiǎn)化了NIO的開(kāi)發(fā),但實(shí)際使用AIO進(jìn)行開(kāi)發(fā)的應(yīng)用并不是很多。主要原因是,Java語(yǔ)言本身的發(fā)展遠(yuǎn)遠(yuǎn)落后其豐富的第三方開(kāi)源產(chǎn)品。在這種情況下,AIO并沒(méi)有成為主流的網(wǎng)絡(luò)通信應(yīng)用的開(kāi)發(fā)利器,加之在AIO沒(méi)有出現(xiàn)時(shí),NIO的API過(guò)于底層,導(dǎo)致編寫(xiě)一個(gè)健壯的網(wǎng)絡(luò)通信程序十分復(fù)雜,因此一系列的第三方通信框架誕生并快速成長(zhǎng), Netty和Mina就是其中的佼佼者。

發(fā)展至今,Netty由于具有優(yōu)雅的編程模型以及健壯的異常處理方式,漸漸成為了網(wǎng)絡(luò)通信應(yīng)用開(kāi)發(fā)的首選框架。

Netty最初是由Jboss提供的一個(gè)Java開(kāi)源框架,目前已獨(dú)立發(fā)展。它基于 Java NIO開(kāi)發(fā),是通過(guò)異步非阻塞和事件驅(qū)動(dòng)來(lái)實(shí)現(xiàn)的一個(gè)高性能、高可靠、高可定制的通信框架。

相比于直接使用 NIO,Netty 的 API 使用更簡(jiǎn)單,并且內(nèi)置了各種協(xié)議和序列化的支持。Netty還能夠通過(guò)ChannelHandler對(duì)通信框架進(jìn)行靈活擴(kuò)展。在AIO出現(xiàn)后,Netty也在切換內(nèi)核方面進(jìn)行了嘗試,但由于AIO的性能并未比基于epoll的NIO有本質(zhì)提升,并且還引入了不必要的線程模型增加了編碼的復(fù)雜度,因此Netty在4.x版本中將AIO移除了。Netty官方網(wǎng)站提供的Netty邏輯模型如圖2-10所示。

圖2-10 Netty邏輯模型

Netty由核心(Core)、傳輸服務(wù)(Transport Servies)以及協(xié)議支持(Protocol Support)這幾個(gè)模塊組成。核心模塊提供了性能極高的零拷貝能力,還提供了統(tǒng)一的通信API和可高度擴(kuò)展的事件驅(qū)動(dòng)模型。傳輸服務(wù)模塊和協(xié)議支持模塊是對(duì) Netty 的有力補(bǔ)充。傳輸服務(wù)模塊支持了TCP和UDP等Socket通信,以及HTTP和同一JVM內(nèi)的通信通道。協(xié)議支持模塊則對(duì)常見(jiàn)的序列化協(xié)議進(jìn)行支持,如Protobuf、gzip等。我們?cè)谥v解序列化協(xié)議時(shí)會(huì)重點(diǎn)介紹這部分。

前文談到,I/O是需要將數(shù)據(jù)從系統(tǒng)內(nèi)核復(fù)制到用戶進(jìn)程中再進(jìn)行下一步操作的。所謂的零拷貝是指,無(wú)須為數(shù)據(jù)在內(nèi)存之間的復(fù)制消耗資源,即不需要將數(shù)據(jù)內(nèi)容復(fù)制到用戶空間,而是直接在內(nèi)核空間中將數(shù)據(jù)傳輸至網(wǎng)絡(luò),從而提升系統(tǒng)的整體性能。

Linux 的 sendfile 函數(shù)實(shí)現(xiàn)了零拷貝的功能,而使用 Linux 函數(shù)的 Java NIO 也通過(guò)其FileChannel 的 transfer 方法實(shí)現(xiàn)了該功能。Netty 同樣通過(guò)封裝 NIO 實(shí)現(xiàn)了零拷貝功能,而且Netty 還提供了各種便利的緩沖區(qū)對(duì)象,在操作系統(tǒng)層面之外的 Java 應(yīng)用層面上進(jìn)行數(shù)據(jù)優(yōu)化時(shí)可以達(dá)到更優(yōu)的效果。

前面介紹過(guò),NIO 中使用了 Reactor 模式進(jìn)行事件輪詢和派發(fā)。對(duì)于如何合理將各種事件從Selector中分離處理,NIO并未提供實(shí)現(xiàn)方案,而需要開(kāi)發(fā)人員自行解決。Netty建議將用于處理客戶端連接的Selector與用于處理消息讀寫(xiě)的Selector分離,以便將一些比較耗時(shí)的I/O操作隔離至不同的線程中執(zhí)行,從而減少I(mǎi)/O等待時(shí)間。Netty將Selector封裝為NioEventLoop,用于處理客戶端連接的EventLoop稱(chēng)為boss,用于處理讀寫(xiě)操作的EventLoop稱(chēng)為worker。

處理EventLoop的線程模型可以分為單線程Reactor、worker多線程Reactor以及全多線程Reactor三種。

單線程 Reactor 是不分離 boss 與 worker 的事件選擇器,統(tǒng)一使用單線程處理。Netty已經(jīng)將boss與worker分離,因此不推薦此模式。

worker多線程Reactor是使用獨(dú)立線程處理boss EventLoop的,并且使用線程池來(lái)維持多個(gè)worker EventLoop。這種模式可以滿足大部分場(chǎng)景。

全多線程Reactor是使用獨(dú)立線程池分別處理boss EventLoop和worker EventLoop的。對(duì)于需要安全驗(yàn)證等比較耗時(shí)的場(chǎng)景,可以考慮使用此模式。

Netty對(duì)于這三種不同的線程模型都能夠輕松支持,只需動(dòng)態(tài)調(diào)配EventLoopGroup的線程數(shù)量即可。

截止到目前,Netty的穩(wěn)定版本是4.1.x,雖然不久前Netty 5.x的版本已經(jīng)被開(kāi)發(fā)出來(lái),但由于它使用了ForkJoinPool,導(dǎo)致代碼的復(fù)雜度增加,同時(shí)沒(méi)有明顯的性能改善,因此Netty的作者直接刪除了Netty 5的代碼分支。本書(shū)中的所有例子均基于Netty 4.1.x版本。

下面是使用Netty創(chuàng)建服務(wù)端啟動(dòng)程序的核心代碼。

以上代碼的大致流程如下。

初始化分發(fā)與監(jiān)聽(tīng)事件的輪詢線程組。Netty使用的是與NIO相同的Selector方式,這里通過(guò)EventLoopGroup初始化線程池,這個(gè)線程池只需要一個(gè)線程用于監(jiān)聽(tīng)事件是否到達(dá),并且觸發(fā)事件監(jiān)聽(tīng)回調(diào)方法。EventLoopGroup 有多種實(shí)現(xiàn)方式,這里的NioEventLoopGroup是使用NIO的實(shí)現(xiàn)方式作為其實(shí)現(xiàn)類(lèi)的,這也是最常用的實(shí)現(xiàn)類(lèi)。

初始化工作線程組。EventLoopGroup的NIO線程組用于處理I/O的工作線程,可以指定合理的線程池大小,默認(rèn)值為當(dāng)前服務(wù)器CPU核數(shù)的2倍。

初始化服務(wù)端的Netty啟動(dòng)類(lèi)。Netty通過(guò)ServerBootstrap簡(jiǎn)化服務(wù)端的煩瑣啟動(dòng)流程。

設(shè)置監(jiān)聽(tīng)線程組與工作線程組。

將處理I/O的通道設(shè)置為使用NIO。

添加事件回調(diào)方法處理器,即相應(yīng)的事件觸發(fā)后的監(jiān)聽(tīng)處理器,通過(guò)自定義的回調(diào)處理器處理業(yè)務(wù)邏輯。這段代碼中添加了3個(gè)回調(diào)處理器。

添加解碼回調(diào)處理器,用于將通過(guò)網(wǎng)絡(luò)傳遞過(guò)來(lái)的客戶端二進(jìn)制字節(jié)數(shù)組解碼成服務(wù)端所需要的對(duì)象。可以使用 weakCachingConcurrentResolver 創(chuàng)建線程安全的WeakReferenceMap,對(duì)類(lèi)加載器進(jìn)行緩存。這里使用了Netty內(nèi)置的ObjectDecoder,它使用了Java原生的序列化方式將二進(jìn)制字節(jié)數(shù)組反序列化為正確的對(duì)象。關(guān)于序列化的更多知識(shí),將在下一節(jié)中詳細(xì)說(shuō)明。

添加編碼回調(diào)處理器,用于將服務(wù)端回寫(xiě)至客戶端的對(duì)象編碼為二進(jìn)制字節(jié)數(shù)組,以便通過(guò)網(wǎng)絡(luò)進(jìn)行傳遞。這里使用了Netty內(nèi)置的ObjectEncoder,它同樣使用Java原生的序列化方式將對(duì)象序列化為二進(jìn)制字節(jié)數(shù)組。

添加定制化業(yè)務(wù)的回調(diào)處理器。

設(shè)置與網(wǎng)絡(luò)通道相關(guān)的參數(shù)。

綁定提供服務(wù)的端口并且開(kāi)始準(zhǔn)備接受客戶端發(fā)送過(guò)來(lái)的請(qǐng)求。

主線程等待,直到服務(wù)端進(jìn)程結(jié)束(Socket關(guān)閉)才停止等待。

優(yōu)雅關(guān)閉線程組。

服務(wù)端的主啟動(dòng)程序還是非常簡(jiǎn)單和清晰的,真正的自定制業(yè)務(wù)處理流程在回調(diào)的處理函數(shù)中。下面是服務(wù)端的業(yè)務(wù)回調(diào)處理類(lèi)NettyServerHandler的核心代碼。

由于Netty已經(jīng)將大量的技術(shù)細(xì)節(jié)屏蔽和隔離,因此NettyServerHandler看起來(lái)非常簡(jiǎn)單,它只需要由EventLoopGroup監(jiān)聽(tīng)相應(yīng)事件,并在接收到事件后分別調(diào)用相關(guān)的回調(diào)方法即可,這個(gè)例子中只對(duì)讀取客戶端輸入以及錯(cuò)誤處理有響應(yīng)。

channelRead方法在客戶端發(fā)送消息到服務(wù)端時(shí)觸發(fā)。這里可以定制化實(shí)現(xiàn)業(yè)務(wù)邏輯,最后將對(duì)象寫(xiě)入緩沖區(qū)并刷新緩沖區(qū)至客戶端。這里如果不調(diào)用writeAndFlush 方法而是調(diào)用write方法,則消息只會(huì)寫(xiě)入緩沖區(qū),而不會(huì)真正寫(xiě)入客戶端。但由使用者合理地多次調(diào)用write之后再調(diào)用flush方法,便可以合并緩沖區(qū)向客戶端寫(xiě)入的次數(shù),達(dá)到通過(guò)減少交互次數(shù)來(lái)提升性能的目的。

值得注意的是,這里直接將Java的對(duì)象寫(xiě)入了緩沖區(qū),而無(wú)須將其轉(zhuǎn)換為ByteBuf對(duì)象。這是因?yàn)橹霸贜ettyServer中配置了ObjectEncoder,它可以自動(dòng)對(duì)Java對(duì)象進(jìn)行序列化,當(dāng)網(wǎng)絡(luò)出現(xiàn)錯(cuò)誤時(shí)會(huì)調(diào)用這個(gè)方法。為了簡(jiǎn)單起見(jiàn),這里只是將異常信息打印至標(biāo)準(zhǔn)輸出(stdout),并未做出額外處理。

客戶端代碼與服務(wù)端較為相似,這里不再贅述。

通過(guò)對(duì)上述代碼的分析,可以看出,Netty 分離了業(yè)務(wù)處理以及序列化/反序列化與服務(wù)端主進(jìn)程的耦合,使得代碼更加清晰易懂,并且以非常簡(jiǎn)單優(yōu)雅的方式提供了支持異步處理的框架。Netty的出現(xiàn)極大簡(jiǎn)化了NIO的開(kāi)發(fā),因此對(duì)于非遺留代碼,建議使用Netty構(gòu)建網(wǎng)絡(luò)程序。

相比于 Mina,Netty 在內(nèi)存管理和綜合性能方面更勝一籌。它的缺點(diǎn)是向前兼容性不夠友好,Netty 3.x與Netty 4.x的API并不兼容。筆者認(rèn)為,Netty 4.x的API和架構(gòu)設(shè)計(jì)更加合理,因此建議新開(kāi)發(fā)的程序使用Netty 4.x。

主站蜘蛛池模板: 广河县| 交口县| 巴塘县| 墨脱县| 余庆县| 安远县| 大理市| 浦城县| 镇雄县| 五常市| 潢川县| 巫溪县| 古交市| 崇义县| 那坡县| 晋州市| 定远县| 汝城县| 什邡市| 江津市| 阳山县| 乌海市| 辽宁省| 高州市| 浠水县| 揭阳市| 永兴县| 项城市| 磐石市| 徐汇区| 鄂尔多斯市| 滨州市| 遂昌县| 财经| 阿克苏市| 兰州市| 安泽县| 和田市| 仁寿县| 铁岭市| 兴仁县|