Redis網路模型究竟有多強

蟬沐風發表於2022-12-27

如果面試官問我: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中:

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);
}

這個I/O操作在底層到底經歷了什麼呢?下圖給出了說明:

本地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,我還想向各位說明下面幾件事情:

  1. 我們編寫的程式本身並不能對檔案進行讀寫操作,這個步驟必須依賴於作業系統,換個詞兒就是「核心」;
  2. 一個看似簡單的I/O操作卻在底層引發了多次的使用者空間和核心空間的切換,並且資料在核心空間和使用者空間之間複製來複製去。

不同於本地I/O是從本地的檔案中讀取資料,網路I/O是透過網路卡讀取網路中的資料,網路I/O需要藉助Socket來完成,所以接下來我們重新認識一下Socket。

2. 什麼是Socket

這部分在一定程度上是我的強迫症作祟,我關於文章對知識點講解的完備性上對自己近乎苛刻。我覺得把Socket講明白對接下來的講解是一件很重要的事情,看過我之前的文章的讀者或許能意識到,我儘量避免把前置知識直接以連結的形式展示出來,我認為會割裂整篇文章的閱讀體驗。

不割裂的結果就是文章可能顯得很囉嗦,好像一件事情非得從盤古開天闢地開始講起。因此,如果各位覺得對這個知識點有足夠的把握,就直接略過好了~

我們所做的任何需要和遠端裝置進行互動的操作,並非是操作軟體本身進行的資料通訊。舉個例子就是我們用瀏覽器刷B站影片的時候,並非是瀏覽器自身向B站請求影片資料的,而是必須委託作業系統核心中的協議棧。

網路I/O

協議棧就是下邊這些書的程式碼實現,裡邊包含了TCP/IP及其他各種網路實現細節,這樣解釋應該好理解吧。

協議棧就是計算機網路的實現

而Socket庫就是作業系統提供給我們的,用於呼叫協議棧網路功能的一堆程式元件的集合,也就是我們平時聽過的作業系統庫函式,Socket庫和協議棧的關係如下圖所示。

Socket庫和協議棧的關係

使用者程式向作業系統核心的協議棧發出委託時,需要按照指定的順序來呼叫 Socket 庫中的程式元件。

本文的所有案例都以TCP協議為例進行講解。

大家可以把資料收發想象成在兩臺計算機之間建立了一條資料通道,計算機透過這條通道進行資料收發的雙向操作,當然,這條通道是邏輯上的,並非實際存在。

TCP連線有邏輯通道

資料透過管道流動這個比較好理解,但是問題在於這條管道雖然只是邏輯上存在,但是這個“邏輯”也不是光用腦袋想想就會出現的。就好比我們手機打電話,你總得先把號碼撥出去呀。

對應到網路I/O中,就意味著雙方必須建立各自的資料出入口,然後將兩個資料出入口像連線水管一樣接通,這個資料出入口就是上圖中的套接字,就是大名鼎鼎的socket。

客戶端和服務端之間的通訊可以被概括為如下4個步驟:

  1. 服務端建立socket,等待客戶端連線(建立socket階段);
  2. 客戶端建立socket,連線到服務端(連線階段);
  3. 收發資料(通訊階段);
  4. 斷開管道並刪除socket(斷開連線)。

每一步都是透過特定語言的API呼叫Socket庫,Socket庫委託協議棧進行操作的。socket就是呼叫Socket庫中程式元件之後的產成品,比如Java中的ServerSocket,本質上還是呼叫作業系統的Socket庫,因此下文的程式碼例項雖然採用Java語言,但是希望各位讀者注意:只有語法上抽象與具體的區別,socket的操作邏輯是完全一致的

但是,我還是得花點口舌囉嗦一下這幾個步驟的一些細節,為了不至於太枯燥,接下來將這4個步驟和BIO一起講解。

3. 阻塞I/O(Blocking I/O,BIO)

我們先從比較簡單的客戶端開始談起。

3.1. 客戶端的socket流程

public class BlockingClient {
    public static void main(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,我們重點看一下下圖中標註的兩條。

image-20221221230726718

這兩條都是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的號碼牌

至於為什麼不直接返回socket的記憶體地址以及其他細節,可以參考我之前寫的文章《2>&1到底是什麼意思》

3.1.2. 何為連線?

connect(<描述符>, <伺服器IP地址和埠號>, ...);

socket剛建立的時候,裡邊沒啥有用的資訊,別說自己即將通訊的物件長啥樣了,就是叫啥,現在在哪兒也不知道,更別提協議棧,自然是啥也知道!

因此,第1件事情就是應用程式需要把伺服器的IP地址埠號告訴協議棧,有了街道和門牌號,接下來協議棧就可以去找伺服器了。

對於伺服器也是一樣的情況,伺服器也有自己的socket,在接收到客戶端的資訊的同時,伺服器也得知道客戶端的IP埠號啊,要不然只能單線聯絡了。因此對客戶端做的第1件事情就有了要求,必須把客戶端自己的IP以及埠號告知伺服器,然後兩者就可以愉快的聊天了。

這就是3次握手

一句話概括連線的含義:連線實際上是通訊的雙方交換控制資訊,並將必要的控制資訊儲存在各自的socket中的過程

連線過後,每個socket就被4個資訊唯一標識,通常我們稱為四元組:

socket四元組

趁熱打鐵,我們趕緊再說一說伺服器端建立socket以及接受連線的過程。

3.2. 服務端的socket流程

public class BIOServerSocket {
    public static void main(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的服務端程式碼,程式碼的含義就是:

  1. 建立socket;
  2. 將socket設定為等待連線狀態;
  3. 接受客戶端連線;
  4. 收發資料。

這些步驟呼叫的底層程式碼的虛擬碼如下:

// 建立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

文字太多了,有點幹,上個圖!

listen與3次握手

解釋一下動圖中的內容:

  1. 客戶端呼叫connect()函式,開始3次握手,首先傳送一個SYN X的報文(X是個數字,下同);
  2. 服務端收到來自客戶端的SYN,然後在監聽socket對應的半連線佇列中建立一個新的socket,然後對客戶端發回響應SYN Y,捎帶手對客戶端的報文給個ACK
  3. 直到客戶端完成第3次握手,剛才新建立的socket就會被轉移到已連線佇列;
  4. 當程式呼叫accept()時,會將已連線佇列頭部的socket返回;如果已連線佇列為空,那麼程式將被睡眠,直到已連線佇列中有新的socket,程式才會被喚醒,將這個socket返回

第4步就是阻塞的本質啊,朋友們!

3.3. 答疑時間

3.3.1. Q1.佇列中的物件是socket嗎?

呃。。。乖,我們就把它當成socket就好了,這樣容易理解,其實具體裡邊存放的資料結構是啥,我也很想知道,等我寫完這篇文章,我研究完了告訴你。

3.3.2. Q2.accept()這個函式你還沒講是啥意思呢?

accept()函式是由服務端呼叫的,用於從已連線佇列中返回一個socket描述符;如果socket為阻塞式的,那麼如果已連線佇列為空,accept()程式就會被睡眠。BIO恰好就是這個樣子。

3.3.3. 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 class BIOServerSocketWithThread {
    public static void main(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
                    public void run() {
                        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()卡死了。

BIO改造之後

所以我們改造完之後的程式是不是就是非阻塞IO了呢?

想多了。。。我們只是用了點奇技淫巧罷了,改造完的程式碼在系統呼叫層面該阻塞的地方還是阻塞,說白了,Java提供的API完全受限於作業系統提供的系統呼叫,在Java語言級別沒能力改變底層BIO的事實!

Java沒這個能力!

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檔案。

image-20221224183210265

這麼多程式怎麼知道哪個是我們需要追蹤的呢?我就挑了一個容量最大的檔案進行檢視,也就是out.30792,事實上,這個檔案也恰好是我們需要的,擷取一下里邊的內容給大家看一下。

image-20221224183707700

可以看到圖中的有非常多的行,說明我們寫的這麼幾行程式碼其實默默呼叫了非常多的系統呼叫,拋開細枝末節,看一下上圖中我重點標註的系統呼叫,是不是就是上文中我解釋過的函式?我再詳細解釋一下每一步,大家聯絡上文,會對BIO的底層理解的更加通透。

  1. 生成監聽socket,並返回socket描述符7,接下來對socket進行操作的函式都會有一個引數為7
  2. 8099埠繫結到監聽socket,bind的第一個引數就是7,說明就是對監聽socket進行的操作;
  3. listen()將監聽socket(引數為7)設定為被動接受連線的socket,並且將佇列的長度設定為50;
  4. 實際上就是System.out.println("啟動服務:監聽埠:8099");這一句的系統呼叫,只不過中文被編碼了,所以我特意把:8099圈出來證明一下;

額外說兩點:

其一:可以看到,這麼一句簡單的列印輸出在底層實際呼叫了兩次write系統呼叫,這就是為什麼不推薦在生產環境下使用列印語句的原因,多少會影響系統效能;

其二:write()的第一個引數為1,也是檔案描述符,表示的是標準輸出stdout,關於標準輸入、標準輸出、標準錯誤和檔案描述符之間的關係可以參見《2>&1到底是什麼意思》

  1. 系統呼叫阻塞在了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的內容發生了變化:

image-20221224184440712

  1. poll()函式結束阻塞,程式接著呼叫accept()函式返回一個連線socket,該socket的描述符為8
  2. 就是System.out.println("客戶端:" + socket.getPort());的底層呼叫;
  3. 底層使用clone()創造了一個新程式去處理連線socket,該程式的pid為31168,因此JDK8的執行緒在底層其實就是輕量級程式;
  4. 回到poll()函式繼續阻塞等待新客戶端連線。

由於建立了一個新的程式,因此在目錄下對多出一個out.31168的檔案,我們看一下該檔案的內容:

image-20221224185036849

發現子程式阻塞在了recvfrom()這個系統呼叫上,對應的Java原始碼就是bufferedReader.readLine();,直到客戶端主動給服務端傳送訊息,阻塞才會結束。

3.7. BIO總結

到此為止,我們就透過底層的系統呼叫證明了BIO在accept()以及readLine()上的阻塞。最後用一張圖來結束BIO之旅。

BIO模型

BIO之所以是BIO,是因為系統底層呼叫是阻塞的,上圖中的程式呼叫recv,其系統呼叫直到資料包準備好並且被複制到應用程式的緩衝區或者發生錯誤為止才會返回,在此整個期間,程式是被阻塞的,啥也幹不了。

4. 非阻塞I/O(NonBlocking I/O)

上文花了太多的筆墨描述BIO,接下來的非阻塞IO我們只抓主要矛盾,其餘參考BIO即可。

如果你看過其他介紹非阻塞IO的文章,下面這個圖片你多少會有點眼熟。

NIO模型

非阻塞IO指的是程式發起系統呼叫之後,核心不會將程式投入睡眠,而是會立即返回一個結果,這個結果可能恰好是我們需要的資料,又或者是某些錯誤。

你可能會想,這種非阻塞帶來的輪詢有什麼用呢?大多數都是空輪詢,白白浪費CPU而已,還不如讓程式休眠來的合適。

4.1. Java的非阻塞實現

這個問題暫且擱置一下,我們先看Java在語法層面是如何提供非阻塞功能的,細節慢慢聊。

public class NoBlockingServer {

    public static List<SocketChannel> channelList = new ArrayList<>();

    public static void main(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中的ServerSocketSocket。此外,透過下面兩行的配置,將監聽socket和連線socket設定為非阻塞。

// 將監聽socket設定為非阻塞
serverSocketChannel.configureBlocking(false);

// 將連線socket設定為非阻塞
socketChannel.configureBlocking(false);

我們上文強調過,Java自身並沒有將socket設定為非阻塞的本事,一定是在某個時間點上,作業系統核心提供了這個功能,才使得Java設計出了新的API來提供非阻塞功能

之所以需要上面兩行程式碼的顯式設定,也恰好說明了核心是預設將socket設定為阻塞狀態的,需要非阻塞,就得額外呼叫其他系統呼叫。我們透過man命令檢視一下socket()這個方法(截圖的中間省略了一部分內容):

man 2 socket

image-20221225144028751

我們可以看到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。

下面給出一張accept()返回一個連線socket情況下的動圖,希望對大家理解整個流程有幫助。

非阻塞IO

4.3. 掀開非阻塞IO的底褲

我將上面的程式在CentOS下再次用strace程式追蹤一下,具體步驟不再贅述,下面是out日誌檔案的內容(我忽略了絕大多數沒用的)。

非阻塞IO的系統呼叫分析

4.4. 非阻塞IO總結

NIO模型

再放一遍這個圖,有一個細節需要大家注意,系統呼叫向核心要資料時,核心的動作分成兩步:

  1. 等待資料(從網路卡緩衝區複製到核心緩衝區)

  2. 複製資料(資料從核心緩衝區複製到使用者空間)

只有在第1步時,系統呼叫是非阻塞的,第2步程式依然需要等待這個複製過程,然後才能返回,這一步是阻塞的。

非阻塞IO模型僅用一個執行緒就能處理所有操作,對比BIO的一個客戶端需要一個執行緒而言進步還是巨大的。但是他的致命問題在於會不停地進行系統呼叫,不停的進行accept(),不停地對連線socket進行read()操作,即使大部分時間都是白忙活。要知道,系統呼叫涉及到使用者空間和核心空間的多次轉換,會嚴重影響整體效能。

所以,一個自然而言的想法就是,能不能別讓程式瞎輪詢。

比如有人告訴程式監聽socket是不是被連線了,有的話程式再執行accept();比如有人告訴程式哪些連線socket有資料從客戶端傳送過來了,然後程式只對有資料的連線socket進行read()

這個方案就是I/O多路複用


剩下的內容另起一篇吧,現在處於發燒狀態,八成是陽了,小夥伴們注意身體,下期見~

相關文章