如果面試官問(wèn)我:Redis為什么這么快?
我肯定會(huì)說(shuō):因?yàn)镽edis是內(nèi)存數(shù)據(jù)庫(kù)!如果不是直接把數(shù)據(jù)放在內(nèi)存里,甭管怎么優(yōu)化數(shù)據(jù)結(jié)構(gòu)、設(shè)計(jì)怎樣的網(wǎng)絡(luò)I/O模型,都不可能達(dá)到如今這般的執(zhí)行效率。
但是這么回答多半會(huì)讓我直接回去等通知了。。。因?yàn)槊嬖嚬傧肼?tīng)到的就是數(shù)據(jù)結(jié)構(gòu)和網(wǎng)絡(luò)模型方面的回答,雖然這兩者只是在內(nèi)存基礎(chǔ)上的錦上添花。
說(shuō)這些并非為了強(qiáng)調(diào)網(wǎng)絡(luò)模型并不重要,恰恰相反,它是Redis實(shí)現(xiàn)高吞吐量的重要底層支撐,是“高性能”的重要原因,卻不是“快”的直接理由。
本文將從BIO開(kāi)始介紹,經(jīng)過(guò)NIO、多路復(fù)用,最終說(shuō)回Redis的Reactor模型,力求詳盡。本文與其他文章的不同點(diǎn)主要在于:
1、不會(huì)介紹同步阻塞I/O、同步非阻塞I/O、異步阻塞I/O、異步非阻塞I/O等概念,這些術(shù)語(yǔ)只是對(duì)底層原理的一些概念總結(jié)而已,我覺(jué)得沒(méi)有用。底層原理搞懂了,這些概念根本不重要,我希望讀完本文之后,各位能夠不再糾結(jié)這些概念。
2、不會(huì)只拿生活中例子來(lái)說(shuō)明問(wèn)題。之前看過(guò)特別多的文章,這些文章舉的“燒水”、“取快遞”的例子真的是深入淺出,但是看懂這些例子會(huì)讓我們有一種我們真的懂了的錯(cuò)覺(jué)。尤其對(duì)于網(wǎng)絡(luò)I/O模型而言,很難找到生活中非常貼切的例子,這種例子不過(guò)是已經(jīng)懂了的人高屋建瓴,對(duì)外輸出的一種形式,但是對(duì)于一知半解的讀者而言卻猶如鈍刀殺人。
牛皮已經(jīng)吹出去了,正文開(kāi)始。
1. 一次I/O到底經(jīng)歷了什么
我們都知道,網(wǎng)絡(luò)I/O是通過(guò)Socket實(shí)現(xiàn)的,在說(shuō)明網(wǎng)絡(luò)I/O之前,我們先來(lái)回顧(了解)一下本地I/O的流程。
舉一個(gè)非常簡(jiǎn)單的例子,下面的代碼實(shí)現(xiàn)了文件的拷貝,將file1.txt的數(shù)據(jù)拷貝到file2.txt中:
public static void main(String[] args) throws Exception {
FileInputStream in = new FileInputStream("/tmp/file1.txt");
FileOutputStream out = new FileOutputStream("/tmp/file2.txt");
byte[] buf = new byte[in.available()];
in.read(buf);
out.write(buf);
}
這個(gè)I/O操作在底層到底經(jīng)歷了什么呢?下圖給出了說(shuō)明:
本地I/O示意圖
大致可以概括為如下幾個(gè)過(guò)程:
in.read(buf)
執(zhí)行時(shí),程序向內(nèi)核發(fā)起read()
系統(tǒng)調(diào)用;- 操作系統(tǒng)發(fā)生上下文切換,由用戶(hù)態(tài)(User mode)切換到內(nèi)核態(tài)(Kernel mode),把數(shù)據(jù)讀取到內(nèi)核緩沖區(qū) (buffer)中;
- 內(nèi)核把數(shù)據(jù)從內(nèi)核空間拷貝到用戶(hù)空間,同時(shí)由內(nèi)核態(tài)轉(zhuǎn)為用戶(hù)態(tài);
- 繼續(xù)執(zhí)行
out.write(buf)
; - 再次發(fā)生上下文切換,將數(shù)據(jù)從用戶(hù)空間buffer拷貝到內(nèi)核空間buffer中,由內(nèi)核把數(shù)據(jù)寫(xiě)入文件。
之所以先拿本地I/O舉個(gè)例子,是因?yàn)槲蚁胝f(shuō)明I/O模型并非僅僅針對(duì)網(wǎng)絡(luò)IO(雖然網(wǎng)絡(luò)I/O最常被我們拿來(lái)舉例),本地I/O同樣受到I/O模型的約束。比如在這個(gè)例子中,本地I/O用的就是典型的BIO,至于什么是BIO,稍安勿躁,接著往下看。
除此之外,通過(guò)本地I/O,我還想向各位說(shuō)明下面幾件事情:
- 我們編寫(xiě)的程序本身并不能對(duì)文件進(jìn)行讀寫(xiě)操作,這個(gè)步驟必須依賴(lài)于操作系統(tǒng),換個(gè)詞兒就是「內(nèi)核」;
- 一個(gè)看似簡(jiǎn)單的I/O操作卻在底層引發(fā)了多次的用戶(hù)空間和內(nèi)核空間的切換,并且數(shù)據(jù)在內(nèi)核空間和用戶(hù)空間之間拷貝來(lái)拷貝去。
不同于本地I/O是從本地的文件中讀取數(shù)據(jù),網(wǎng)絡(luò)I/O是通過(guò)網(wǎng)卡讀取網(wǎng)絡(luò)中的數(shù)據(jù),網(wǎng)絡(luò)I/O需要借助Socket來(lái)完成,所以接下來(lái)我們重新認(rèn)識(shí)一下Socket。
2. 什么是Socket
這部分在一定程度上是我的強(qiáng)迫癥作祟,我關(guān)于文章對(duì)知識(shí)點(diǎn)講解的完備性上對(duì)自己近乎苛刻。我覺(jué)得把Socket講明白對(duì)接下來(lái)的講解是一件很重要的事情,看過(guò)我之前的文章的讀者或許能意識(shí)到,我盡量避免把前置知識(shí)直接以鏈接的形式展示出來(lái),我認(rèn)為會(huì)割裂整篇文章的閱讀體驗(yàn)。
不割裂的結(jié)果就是文章可能顯得很啰嗦,好像一件事情非得從盤(pán)古開(kāi)天辟地開(kāi)始講起。因此,如果各位覺(jué)得對(duì)這個(gè)知識(shí)點(diǎn)有足夠的把握,就直接略過(guò)好了~
我們所做的任何需要和遠(yuǎn)程設(shè)備進(jìn)行交互的操作,并非是操作軟件本身進(jìn)行的數(shù)據(jù)通信。舉個(gè)例子就是我們用瀏覽器刷B站視頻的時(shí)候,并非是瀏覽器自身向B站請(qǐng)求視頻數(shù)據(jù)的,而是必須委托操作系統(tǒng)內(nèi)核中的協(xié)議棧。
網(wǎng)絡(luò)I/O
而Socket庫(kù)就是操作系統(tǒng)提供給我們的,用于調(diào)用協(xié)議棧網(wǎng)絡(luò)功能的一堆程序組件的集合,也就是我們平時(shí)聽(tīng)過(guò)的操作系統(tǒng)庫(kù)函數(shù),Socket庫(kù)和協(xié)議棧的關(guān)系如下圖所示。
Socket庫(kù)和協(xié)議棧的關(guān)系
用戶(hù)進(jìn)程向操作系統(tǒng)內(nèi)核的協(xié)議棧發(fā)出委托時(shí),需要按照指定的順序來(lái)調(diào)用 Socket 庫(kù)中的程序組件。
本文的所有案例都以TCP協(xié)議為例進(jìn)行講解。
大家可以把數(shù)據(jù)收發(fā)想象成在兩臺(tái)計(jì)算機(jī)之間創(chuàng)建了一條數(shù)據(jù)通道,計(jì)算機(jī)通過(guò)這條通道進(jìn)行數(shù)據(jù)收發(fā)的雙向操作,當(dāng)然,這條通道是邏輯上的,并非實(shí)際存在。
TCP連接有邏輯通道
數(shù)據(jù)通過(guò)管道流動(dòng)這個(gè)比較好理解,但是問(wèn)題在于這條管道雖然只是邏輯上存在,但是這個(gè)“邏輯”也不是光用腦袋想想就會(huì)出現(xiàn)的。就好比我們手機(jī)打電話,你總得先把號(hào)碼撥出去呀。
對(duì)應(yīng)到網(wǎng)絡(luò)I/O中,就意味著雙方必須創(chuàng)建各自的數(shù)據(jù)出入口,然后將兩個(gè)數(shù)據(jù)出入口像連接水管一樣接通,這個(gè)數(shù)據(jù)出入口就是上圖中的套接字,就是大名鼎鼎的socket。
客戶(hù)端和服務(wù)端之間的通信可以被概括為如下4個(gè)步驟:
- 服務(wù)端創(chuàng)建socket,等待客戶(hù)端連接(創(chuàng)建socket階段);
- 客戶(hù)端創(chuàng)建socket,連接到服務(wù)端(連接階段);
- 收發(fā)數(shù)據(jù)(通信階段);
- 斷開(kāi)管道并刪除socket(斷開(kāi)連接)。
每一步都是通過(guò)特定語(yǔ)言的API調(diào)用Socket庫(kù),Socket庫(kù)委托協(xié)議棧進(jìn)行操作的。socket就是調(diào)用Socket庫(kù)中程序組件之后的產(chǎn)成品,比如Java中的ServerSocket,本質(zhì)上還是調(diào)用操作系統(tǒng)的Socket庫(kù),因此下文的代碼實(shí)例雖然采用Java語(yǔ)言,但是希望各位讀者注意: 只有語(yǔ)法上抽象與具體的區(qū)別,socket的操作邏輯是完全一致的 。
但是,我還是得花點(diǎn)口舌啰嗦一下這幾個(gè)步驟的一些細(xì)節(jié),為了不至于太枯燥,接下來(lái)將這4個(gè)步驟和BIO
一起講解。
3. 阻塞I/O(Blocking I/O,BIO)
我們先從比較簡(jiǎn)單的客戶(hù)端開(kāi)始談起。
3.1 客戶(hù)端的socket流程
public class BlockingClient {
public static void main(String[] args) {
try {
// 創(chuàng)建套接字 & 建立連接
Socket socket = new Socket("localhost", 8099);
// 向服務(wù)端寫(xiě)數(shù)據(jù)
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
bufferedWriter.write("我是客戶(hù)端,收到請(qǐng)回答!!\\n");
bufferedWriter.flush();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
String line = bufferedReader.readLine();
System.out.println("收到服務(wù)端返回的數(shù)據(jù):" + line);
} catch (IOException e) {
// 錯(cuò)誤處理
}
}
}
上面展示了一段非常簡(jiǎn)單的Java BIO的客戶(hù)端代碼,相信你們一定不會(huì)感到陌生,接下來(lái)我們一點(diǎn)點(diǎn)分析客戶(hù)端的socket操作究竟做了什么。
Socket socket = new Socket("localhost", 8099);
雖然只是簡(jiǎn)單的一行語(yǔ)句,但是其中包含了兩個(gè)步驟,分別是創(chuàng)建套接字、建立連接,等價(jià)于下面兩行偽代碼:
<描述符> = socket(<使用IPv4>, <使用TCP>, ...);
connect(<描述符>, <服務(wù)器IP地址和端口號(hào)>, ...);
注意:
文中會(huì)出現(xiàn)多個(gè)關(guān)于*ocket的術(shù)語(yǔ),比如Socket庫(kù),就是操作系統(tǒng)提供的庫(kù)函數(shù);socket組件就是Socket庫(kù)中和socket相關(guān)的程序的統(tǒng)稱(chēng);socket()函數(shù)以及socket(或稱(chēng):套接字)就是接下來(lái)要講的內(nèi)容,我會(huì)盡量在描述過(guò)程中不產(chǎn)生混淆,大家注意根據(jù)上下文進(jìn)行辨析。
3.1.1 何為socket?
上文已經(jīng)說(shuō)了,邏輯管道存在的前提是需要各自先創(chuàng)建socket(就好比你打電話之前得先有手機(jī)),然后將兩個(gè)socket進(jìn)行關(guān)聯(lián)。客戶(hù)端創(chuàng)建socket非常簡(jiǎn)單,只需要調(diào)用Socket庫(kù)中的socket組件的socket()
函數(shù)就可以了。
<描述符> = socket(<使用IPv4>, <使用TCP>, ...);
客戶(hù)端代碼調(diào)用socket()
函數(shù)向協(xié)議棧申請(qǐng)創(chuàng)建socket,協(xié)議棧會(huì)根據(jù)你的參數(shù)來(lái)決定socket是IPv4
還是IPv6
,是TCP
還是UDP
。除此之外呢?
基本的臟活累活都是協(xié)議棧完成的,協(xié)議棧想傳遞消息總得知道目的IP和端口吧,要是你用的是TCP
協(xié)議,你甚至還得記錄每個(gè)包的發(fā)送時(shí)間以及每個(gè)包是否收到回復(fù),否則TCP
的超時(shí)重傳就不會(huì)正常工作。。。等等。。。
因此,協(xié)議棧會(huì)申請(qǐng)一塊內(nèi)存空間,在其中存放諸如此類(lèi)的各種控制信息,協(xié)議棧就是根據(jù)這些控制信息來(lái)工作的,這些控制信息我們就可以理解為是socket的實(shí)體。怎么樣,是不是之前感覺(jué)虛無(wú)縹緲的socket突然鮮活了起來(lái)?
我們看一個(gè)更鮮活的例子,我在本級(jí)上執(zhí)行netstat -anop
命令,得到的每一行信息我們就可以理解為是一個(gè)socket,我們重點(diǎn)看一下下圖中標(biāo)注的兩條。
這兩條都是redis-server
的socket信息,第1條表示redis-server
服務(wù)正在IP為127.0.0.1
,端口為6379
的主機(jī)上等待遠(yuǎn)程客戶(hù)端連接,因?yàn)镕oreign address為0.0.0.0:*
,表示通信還未開(kāi)始,IP無(wú)法確定,因此State為LISTEN
狀態(tài);第2條表示redis-server
服務(wù)已經(jīng)建立了與IP為127.0.0.1
的客戶(hù)端之間的連接,且客戶(hù)端使用49968
的端口號(hào),目前該socket的狀態(tài)為ESTABLISHED
。
協(xié)議棧創(chuàng)建完socket之后,會(huì)返回一個(gè)描述符給應(yīng)用程序。描述符用來(lái)識(shí)別不同的socket,可以將描述符理解成某個(gè)socket的編號(hào),就好比你去洗澡的時(shí)候,前臺(tái)會(huì)發(fā)給你一個(gè)手牌,原理差不多。
之后對(duì)socket進(jìn)行的任何操作,只要我們出示自己的手牌,啊呸,描述符,協(xié)議棧就能知道我們想通過(guò)哪個(gè)socket進(jìn)行數(shù)據(jù)收發(fā)了。
描述符就是socket的號(hào)碼牌
至于為什么不直接返回socket的內(nèi)存地址以及其他細(xì)節(jié),可以參考我之前寫(xiě)的文章《2>&1到底是什么意思》
3.1.2 何為連接?
connect(<描述符>, <服務(wù)器IP地址和端口號(hào)>, ...);
socket剛創(chuàng)建的時(shí)候,里邊沒(méi)啥有用的信息,別說(shuō)自己即將通信的對(duì)象長(zhǎng)啥樣了,就是叫啥,現(xiàn)在在哪兒也不知道,更別提協(xié)議棧,自然是啥也知道!
因此,第1件事情就是應(yīng)用程序需要把服務(wù)器的IP地址
和端口號(hào)
告訴協(xié)議棧,有了街道和門(mén)牌號(hào),接下來(lái)協(xié)議棧就可以去找服務(wù)器了。
對(duì)于服務(wù)器也是一樣的情況,服務(wù)器也有自己的socket,在接收到客戶(hù)端的信息的同時(shí),服務(wù)器也得知道客戶(hù)端的IP
和端口號(hào)
啊,要不然只能單線聯(lián)系了。因此對(duì)客戶(hù)端做的第1件事情就有了要求,必須把客戶(hù)端自己的IP
以及端口號(hào)
告知服務(wù)器,然后兩者就可以愉快的聊天了。
這就是 3次握手 。
一句話概括連接的含義: 連接實(shí)際上是通信的雙方交換控制信息,并將必要的控制信息保存在各自的socket中的過(guò)程 。
連接過(guò)后,每個(gè)socket就被4個(gè)信息唯一標(biāo)識(shí),通常我們稱(chēng)為四元組:
socket四元組
趁熱打鐵,我們趕緊再說(shuō)一說(shuō)服務(wù)器端創(chuàng)建socket以及接受連接的過(guò)程。
3.2 服務(wù)端的socket流程
public class BIOServerSocket {
public static void main(String[] args) {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket(8099);
System.out.println("啟動(dòng)服務(wù):監(jiān)聽(tīng)端口:8099");
// 等待客戶(hù)端的連接過(guò)來(lái),如果沒(méi)有連接過(guò)來(lái),就會(huì)阻塞
while (true) {
// 表示阻塞等待監(jiān)聽(tīng)一個(gè)客戶(hù)端連接,返回的socket表示連接的客戶(hù)端信息
Socket socket = serverSocket.accept();
System.out.println("客戶(hù)端:" + socket.getPort());
// 表示獲取客戶(hù)端的請(qǐng)求報(bào)文
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// 讀操作也是阻塞的
String clientStr = bufferedReader.readLine();
System.out.println("收到客戶(hù)端發(fā)送的消息:" + clientStr);
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
bufferedWriter.write("ok\\n");
bufferedWriter.flush();
}
} catch (IOException e) {
// 錯(cuò)誤處理
} finally {
// 其他處理
}
}
}
上面一段是非常簡(jiǎn)單的Java BIO的服務(wù)端代碼,代碼的含義就是:
- 創(chuàng)建socket;
- 將socket設(shè)置為等待連接狀態(tài);
- 接受客戶(hù)端連接;
- 收發(fā)數(shù)據(jù)。
這些步驟調(diào)用的底層代碼的偽代碼如下:
// 創(chuàng)建socket
-
多路復(fù)用
+關(guān)注
關(guān)注
0文章
35瀏覽量
25509 -
BIO
+關(guān)注
關(guān)注
0文章
6瀏覽量
9353
發(fā)布評(píng)論請(qǐng)先 登錄
相關(guān)推薦
評(píng)論