幾種 I/O 模型
爲什麼 Redis 中要使用 I/O 多路複用這種技術呢?
首先,Redis 是跑在單線程中的,所有的操作都是按照順序線性執行的,但是由於讀寫操作等待用戶輸入或輸出都是阻塞的,所以 I/O 操作在一般情況下往往不能直接返回,這會導致某一文件的 I/O 阻塞導致整個進程無法對其它客戶提供服務,而 I/O 多路複用就是爲了解決這個問題而出現的。
阻塞IO
先來看一下傳統的阻塞 I/O 模型到底是如何工作的:當使用 read 或者 write 對某一個文件描述符(File Descriptor 以下簡稱 FD)進行讀寫時,如果當前 FD 不可讀或不可寫,整個 Redis 服務就不會對其它的操作作出響應,導致整個服務不可用。
這也就是傳統意義上的,也就是我們在編程中使用最多的阻塞模型:
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(9999);
// 新建一個線程用於接收客戶端連接
// 僞異步 IO
new Thread(() -> {
while (true) {
System.out.println("開始阻塞, 等待客戶端連接");
try {
Socket socket = serverSocket.accept();
// 每一個新來的連接給其創建一個線程去處理
new Thread(() -> {
byte[] data = new byte[1024];
int len = 0;
System.out.println("客戶端連接成功,阻塞等待客戶端傳入數據");
try {
InputStream inputStream = socket.getInputStream();
// 阻塞式獲取數據直到客戶端斷開連接
while ((len = inputStream.read(data)) != -1) {
// 或取到數據
System.out.println(new String(data, 0, len));
// 處理數據
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
如果接受到了一個客戶端連接而不採用對應的一個線程去處理的話,首先 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 資源的,一般很少採用這種方式。我們這裏手寫一段僞代碼來看下
Socket socket = serverSocket.accept();
// 不斷輪詢內核,哪個 socket 的數據是否準備好了
while (true) {
data = socket.read();
if (data != BWOULDBLOCK) {
// 表示獲取數據成功
doSomething();
}
}
多路複用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 服務實現的簡單
文件事件處理器
文件事件
當 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 中,我們通過宏定義的使用,合理的選擇不同的子模塊:
ifdef HAVE_EVPORT
include "ae_evport.c"
else
ifdef HAVE_EPOLL
include "ae_epoll.c"
else
ifdef HAVE_KQUEUE
include "ae_kqueue.c"
elsec
include "ae_select.c"
endif
endif
因爲 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的處理流程如下:
爲啥Redis單線程模型也能效率這麼高?
作者:Rico
原文:hogwartsrico.github.io/2020/06/24/Redis-and-Multiplexing/
END
版權申明:內容來源網絡,版權歸原創者所有。除非無法確認,我們都會標明作者及出處,如有侵權煩請告知,我們會立即刪除並表示歉意。謝謝。
本文分享自微信公衆號 - JAVA高級架構(gaojijiagou)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。