一文讀懂 Redis 中的多路複用模型

作者 | Rico

出品 | Java 高級架構

幾種 I/O 模型

爲什麼 Redis 中要使用 I/O 多路複用這種技術呢?

首先,Redis 是跑在單線程中的,所有的操作都是按照順序線性執行的,但是由於讀寫操作等待用戶輸入或輸出都是阻塞的,所以 I/O 操作在一般情況下往往不能直接返回,這會導致某一文件的 I/O 阻塞導致整個進程無法對其它客戶提供服務,而 I/O 多路複用就是爲了解決這個問題而出現的。

阻塞 IO

先來看一下傳統的阻塞 I/O 模型到底是如何工作的:當使用 read 或者 write 對某一個文件描述符(File Descriptor 以下簡稱 FD) 進行讀寫時,如果當前 FD 不可讀或不可寫,整個 Redis 服務就不會對其它的操作作出響應,導致整個服務不可用。

這也就是傳統意義上的,也就是我們在編程中使用最多的阻塞模型:

在這種 IO 模型的場景下,我們是給每一個客戶端連接創建一個線程去處理它。不管這個客戶端建立了連接有沒有在做事(發送讀取數據之類),都要去維護這個連接,直到連接斷開爲止。創建過多的線程就會消耗過高的資源,以 Java BIO 爲例

由此可見阻塞 I/O 難以支持高併發的場景

 1public static void main(String[] args) throws IOException {
 2        ServerSocket serverSocket = new ServerSocket(9999);
 3        // 新建一個線程用於接收客戶端連接
 4        // 僞異步 IO
 5        new Thread(() -> {
 6            while (true) {
 7                System.out.println("開始阻塞, 等待客戶端連接");
 8                try {
 9                    Socket socket = serverSocket.accept();
10                    // 每一個新來的連接給其創建一個線程去處理
11                    new Thread(() -> {
12                        byte[] data = new byte[1024];
13                        int len = 0;
14                        System.out.println("客戶端連接成功,阻塞等待客戶端傳入數據");
15                        try {
16                            InputStream inputStream = socket.getInputStream();
17                            // 阻塞式獲取數據直到客戶端斷開連接
18                            while ((len = inputStream.read(data)) != -1) {
19                                // 或取到數據
20                                System.out.println(new String(data, 0, len));
21                                // 處理數據
22                            }
23                        } catch (IOException e) {
24                            e.printStackTrace();
25                        }
26                    }).start();
27                } catch (IOException e) {
28                    e.printStackTrace();
29                }
30            }
31        }).start();
32    }
33

如果接受到了一個客戶端連接而不採用對應的一個線程去處理的話,首先 serverSocket.accept(); 無法去獲取其它連接,其次 inputStream.read() 可以看到獲取到數據後需要處理完成後才能處理接收下一份數據,正因如此在阻塞 I/O 模型的場景下我們需要爲每一個客戶端連接創建一個線程去處理

阻塞模型雖然開發中非常常見也非常易於理解,但是由於它會影響其他 FD 對應的服務,所以在需要處理多個客戶端任務的時候,往往都不會使用阻塞模型。

非阻塞 IO

可以看到是通過服務端應用程序不斷的輪詢內核數據是否準備好,如果數據沒有準備好的話,內核就返回一個 BWOULDBLOCK 錯誤,那麼應用程序就繼續輪詢直到數據準備好了爲止,在 Java 中的 NIO(非阻塞 I/O, New I/O) 底層是通過多路複用 I/O 模型實現的。而現實的場景也是諸如 netty,redis,nginx,nodejs 都是採用的多路複用 I/O 模型,因爲在非阻塞 I/O 這種場景下需要我們不斷的去輪詢,也是會消耗大量的 CPU 資源的,一般很少採用這種方式。我們這裏手寫一段僞代碼來看下

 1Socket socket = serverSocket.accept();
 2// 不斷輪詢內核,哪個 socket 的數據是否準備好了
 3while (true) {
 4data = socket.read();
 5if (data != BWOULDBLOCK) {
 6// 表示獲取數據成功
 7doSomething();
 8}
 9}
10

多路複用 IO

阻塞式的 I/O 模型並不能滿足這裏的需求,我們需要一種效率更高的 I/O 模型來支撐 Redis 的多個客戶(redis-cli),這裏涉及的就是 I/O 多路複用模型了

Java 中的 NIO 就是採用的多路複用機制,他在不同的操作系統有不同的實現,在 windows 上採用的是 select , 在 unix/linux 上是 epoll。而 poll 模型是對 select 稍許升級大致相同。

最先出現的是 select 。後由於 select 的一些痛點比如它在 32 位系統下,單進程支持最多打開 1024 個文件描述符(linux 對 IO 等操作都是通過對應的文件描述符實現的 socket 對應的是 socket 文件描述符),poll 對其進行了一些優化,比如突破了 1024 這個限制,他能打開的文件描述符不受限制(但還是要取決於系統資源),而上述 2 中模型都有一個很大的性能問題導致產生出了 epoll。後面會詳細分析

在 I/O 多路複用模型中,最重要的函數調用就是 select,該方法的能夠同時監控多個文件描述符的可讀可寫情況,當其中的某些文件描述符可讀或者可寫時,select 方法就會返回可讀以及可寫的文件描述符個數。

關於 select 的具體使用方法,在網絡上資料很多,這裏就不過多展開介紹了;

與此同時也有其它的 I/O 多路複用函數 epoll/kqueue/evport,它們相比 select 性能更優秀,同時也能支撐更多的服務。

Reactor 設計模式

Redis 服務採用 Reactor 的方式來實現文件事件處理器(每一個網絡連接其實都對應一個文件描述符)

Redis 基於 Reactor 模式開發了網絡事件處理器,這個處理器被稱爲文件事件處理器。它的組成結構爲 4 部分:多個套接字、IO 多路複用程序、文件事件分派器、事件處理器。因爲文件事件分派器隊列的消費是單線程的,所以 Redis 才叫單線程模型。

消息處理流程

儘管多個文件事件可能會併發地出現,但 I/O 多路複用程序總是會將所有產生事件的套接字都推到一個隊列裏面,然後通過這個隊列,以有序(sequentially)、同步(synchronously)、每次一個套接字的方式向文件事件分派器傳送套接字:當上一個套接字產生的事件被處理完畢之後(該套接字爲事件所關聯的事件處理器執行完畢), I/O 多路複用程序纔會繼續向文件事件分派器傳送下一個套接字。

雖然整個文件事件處理器是在單線程上運行的,但是通過 I/O 多路複用模塊的引入,實現了同時對多個 FD(文件描述符) 讀寫的監控,提高了網絡通信模型的性能,同時也可以保證整個 Redis 服務實現的簡單

文件事件處理器

Redis 基於 Reactor 模式開發了網絡事件處理器,這個處理器叫做文件事件處理器 file event handler。這個文件事件處理器,它是單線程的,所以 Redis 才叫做單線程的模型,它採用 IO 多路複用機制來同時監聽多個 Socket,根據 Socket 上的事件類型來選擇對應的事件處理器來處理這個事件。

如果被監聽的 Socket 準備好執行 accept、read、write、close 等操作的時候,跟操作對應的文件事件就會產生,這個時候文件事件處理器就會調用之前關聯好的事件處理器來處理這個事件。

文件事件處理器是單線程模式運行的,但是通過 IO 多路複用機制監聽多個 Socket,可以實現高性能的網絡通信模型,又可以跟內部其他單線程的模塊進行對接,保證了 Redis 內部的線程模型的簡單性。

文件事件處理器的結構包含 4 個部分:多個 Socket、IO 多路複用程序、文件事件分派器以及事件處理器(命令請求處理器、命令回覆處理器、連接應答處理器等)。

多個 Socket 可能併發的產生不同的操作,每個操作對應不同的文件事件,但是 IO 多路複用程序會監聽多個 Socket,會將 Socket 放入一個隊列中排隊,每次從隊列中取出一個 Socket 給事件分派器,事件分派器把 Socket 給對應的事件處理器。

然後一個 Socket 的事件處理完之後,IO 多路複用程序纔會將隊列中的下一個 Socket 給事件分派器。文件事件分派器會根據每個 Socket 當前產生的事件,來選擇對應的事件處理器來處理。

文件事件

當 Socket 變得可讀時(比如客戶端對 redis 執行 write 操作,或者 close 操作),或者有新的可以應答的 Socket 出現時(客戶端對 redis 執行 connect 操作),Socket 就會產生一個 AE_READABLE 事件。

當 Socket 變得可寫的時候(客戶端對 redis 執行 read 操作),Socket 會產生一個 AE_WRITABLE 事件。

IO 多路複用程序可以同時監聽 AE_REABLE 和 AE_WRITABLE 兩種事件,如果一個 Socket 同時產生了這兩種事件,那麼文件事件分派器優先處理 AE_READABLE 事件,然後纔是 AE_WRITABLE 事件。

文件事件處理器

如果是客戶端要連接 redis,那麼會爲 Socket 關聯連接應答處理器。

如果是客戶端要寫數據到 redis,那麼會爲 Socket 關聯命令請求處理器。

如果是客戶端要從 redis 讀數據,那麼會爲 Socket 關聯命令回覆處理器。

客戶端與 redis 通信的一次流程

在 Redis 啓動初始化的時候,Redis 會將連接應答處理器跟 AE_READABLE 事件關聯起來,接着如果一個客戶端跟 Redis 發起連接,此時會產生一個 AE_READABLE 事件,然後由連接應答處理器來處理跟客戶端建立連接,創建客戶端對應的 Socket,同時將這個 Socket 的 AE_READABLE 事件跟命令請求處理器關聯起來。

當客戶端向 Redis 發起請求的時候(不管是讀請求還是寫請求,都一樣),首先就會在 Socket 產生一個 AE_READABLE 事件,然後由對應的命令請求處理器來處理。這個命令請求處理器就會從 Socket 中讀取請求相關數據,然後進行執行和處理。

接着 Redis 這邊準備好了給客戶端的響應數據之後,就會將 Socket 的 AE_WRITABLE 事件跟命令回覆處理器關聯起來,當客戶端這邊準備好讀取響應數據時,就會在 Socket 上產生一個 AE_WRITABLE 事件,會由對應的命令回覆處理器來處理,就是將準備好的響應數據寫入 Socket,供客戶端來讀取。

命令回覆處理器寫完之後,就會刪除這個 Socket 的 AE_WRITABLE 事件和命令回覆處理器的關聯關係。

多路複用模塊

I/O 多路複用模塊封裝了底層的 select、epoll、avport 以及 kqueue 這些 I/O 多路複用函數,爲上層提供了相同的接口。

整個 I/O 多路複用模塊抹平了不同平臺上 I/O 多路複用函數的差異性,提供了相同的接口

子模塊的選擇

因爲 Redis 需要在多個平臺上運行,同時爲了最大化執行的效率與性能,所以會根據編譯平臺的不同選擇不同的 I/O 多路複用函數作爲子模塊,提供給上層統一的接口;在 Redis 中,我們通過宏定義的使用,合理的選擇不同的子模塊:

 1ifdef HAVE_EVPORT
 2include "ae_evport.c"
 3else
 4    ifdef HAVE_EPOLL
 5    include "ae_epoll.c"
 6    else
 7        ifdef HAVE_KQUEUE
 8        include "ae_kqueue.c"
 9        elsec
10        include "ae_select.c"
11        endif
12    endif
13

因爲 select 函數是作爲 POSIX 標準中的系統調用,在不同版本的操作系統上都會實現,所以將其作爲保底方案:

Redis 會優先選擇時間複雜度爲 $O(1)$ 的 I/O 多路複用函數作爲底層實現,包括 Solaries 10 中的 evport、Linux 中的 epoll 和 macOS/FreeBSD 中的 kqueue,上述的這些函數都使用了內核內部的結構,並且能夠服務幾十萬的文件描述符。

但是如果當前編譯環境沒有上述函數,就會選擇 select 作爲備選方案,由於其在使用時會掃描全部監聽的描述符,所以其時間複雜度較差 $O(n)$,並且只能同時服務 1024 個文件描述符,所以一般並不會以 select 作爲第一方案使用。

動圖理解

通常的一次的請求結果如下圖所示:

但是,服務器往往不會只處理一次請求,往往是多個請求,這一個請求,這時候每來一個請求,就會生成一個進程或線程。

在這些請求線程或者進程中,大部分都處於等待階段,只有少部分是接收數據。這樣一來,非常耗費資源,而且這些線程或者進程的管理,也是個事兒。

於是,有人想到一個辦法:我們只用一個線程或者進程來和系統內核打交道,並想辦法把每個應用的 I/O 流狀態記錄下來,一有響應變及時返回給相應的應用。

或者下圖:

select、poll、epoll

select, poll, epoll 都是 I/O 多路複用的具體實現,他們出現是有先後順序的。

select 是第一個實現 (1983 左右在 BSD 裏面實現的)。

select 被實現後,發現諸多問題,然後 1997 年實現了 poll,對 select 進行了改進,select 和 poll 是很類似的。

再後來,2002 做出重大改進實現了 epoll。

epoll 和 select/poll 有着很大的不同:

例如:select/poll 的處理流程如下:

而 epoll 的處理流程如下:

這樣,就無需遍歷成千上萬個消息列表了,直接可以定位哪個 socket 有數據。

那麼,這是如何實現的呢?

早期的時候 epoll 的實現是一個哈希表,但是後來由於佔用空間比較大,改爲了紅黑樹和鏈表

其中鏈表中全部爲活躍的鏈接,紅黑樹中放的是所有事件。兩部分各司其職。這樣一來,當收到內核的數據時,只需遍歷鏈表中的數據就行了,而註冊 read 事件或者 write 事件的時候,向紅黑樹中記錄。

結果導致:

select:將之前傳入的 fd_set 拷貝傳出到用戶態並返回就緒的文件描述符總數。用戶態並不知道是哪些文件描述符處於就緒態,需要遍歷來判斷。

epoll:epoll_wait 只用觀察就緒鏈表中有無數據即可,最後將鏈表的數據返回給數組並返回就緒的數量。內核將就緒的文件描述符放在傳入的數組中,所以只用遍歷依次處理即可。這裏返回的文件描述符是通過 mmap 讓內核和用戶空間共享同一塊內存實現傳遞的,減少了不必要的拷貝。

爲啥 Redis 單線程模型也能效率這麼高?

1)純內存操作

Redis 將所有數據放在內存中,內存的響應時長大約爲 100 納秒,這是 redis 的 QPS 過萬的重要基礎。

2)核心是基於非阻塞的 IO 多路複用機制

有了非阻塞 IO 意味着線程在讀寫 IO 時可以不必再阻塞了,讀寫可以瞬間完成然後線程可以繼續幹別的事了。

redis 需要處理多個 IO 請求,同時把每個請求的結果返回給客戶端。由於 redis 是單線程模型,同一時間只能處理一個 IO 事件,於是 redis 需要在合適的時間暫停對某個 IO 事件的處理,轉而去處理另一個 IO 事件,這就需要用到 IO 多路複用技術了, 就好比一個管理者,能夠管理個 socket 的 IO 事件,當選擇了哪個 socket,就處理哪個 socket 上的 IO 事件,其他 IO 事件就暫停處理了。

3)單線程反而避免了多線程的頻繁上下文切換帶來的性能問題。(百度多線程上下文切換)

第一,單線程可以簡化數據結構和算法的實現。併發數據結構實現不但困難而且開發測試比較麻

第二,單線程避免了線程切換和競態產生的消耗,對於服務端開發來說,鎖和線程切換通常是性能殺手。

單線程的問題:對於每個命令的執行時間是有要求的。如果某個命令執行過長,會造成其他命令的阻塞,所以 redis 適用於那些需要快速執行的場景。

作者:Rico

原文:hogwartsrico.github.io/2020/06/24/Redis-and-Multiplexing/

版權申明:內容來源網絡,版權歸原創者所有。除非無法確認,我們都會標明作者及出處,如有侵權煩請告知,我們會立即刪除並表示歉意。謝謝。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/sSM16v_cUpRznf80myQozA