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