淺談Redis的事件驅動模型

Redis 作為一個 Client-Server 架構的數據庫,其源碼中少不瞭用來實現網絡通信的部分。而你應該也清楚,通常系統實現網絡通信的基本方法是使用Socket編程模型,,包括創建 Socket、監聽端口、處理連接請求和讀寫請求。但是,由於基本的 Socket 編程模型一次隻能處理一個客戶端連接上的請求,所以當要處理高並發請求時,一種方案就是使用多線程,讓每個線程負責處理一個客戶端的請求。

而 Redis 負責客戶端請求解析和處理的線程隻有一個,那麼如果直接采用基本 Socket 模型,就會影響 Redis 支持高並發的客戶端訪問。

因此,為瞭實現高並發的網絡通信,我們常用的 Linux 操作系統,就提供瞭 select、poll 和 epoll 三種編程模型,而在 Linux 上運行的 Redis,通常就會采用其中的epoll模型來進行網絡通訊。

為啥 Redis 通常會選擇 epoll 模型呢?這三種編程模型之間有什麼區別?

要想理解 select、poll 和 epoll 的優勢,我們需要有個對比基礎,也就是基本的 Socket 編程模型。所以接下來,我們就先來瞭解下基本的 Socket 編程模型,以及它的不足之處。

為什麼 Redis 不使用基本的 Socket 編程模型?

使用 Socket 模型實現網絡通信時,需要經過創建 Socket、監聽端口、處理連接和讀寫請求等多個步驟,現在我們就來具體瞭解下這些步驟中的關鍵操作,以此幫助我們分析 Socket 模型中的不足。

首先,當我們需要讓服務器端和客戶端進行通信時,可以在服務器端通過以下三步,來創建監聽客戶端連接的監聽套接字(Listening Socket):

  • 調用 socket 函數,創建一個套接字。我們通常把這個套接字稱為主動套接字(Active Socket);
  • 調用 bind 函數,將主動套接字和當前服務器的 IP 和監聽端口進行綁定;
  • 調用 listen 函數,將主動套接字轉換為監聽套接字,開始監聽客戶端的連接。

在完成上述三步之後,服務器端就可以接收客戶端的連接請求瞭。為瞭能及時地收到客戶端的連接請求,我們可以運行一個循環流程,在該流程中調用 accept 函數,用於接收客戶端連接請求。

這裡你需要註意的是,accept 函數是阻塞函數,也就是說,如果此時一直沒有客戶端連接請求,那麼,服務器端的執行流程會一直阻塞在 accept 函數。一旦有客戶端連接請求到達,accept 將不再阻塞,而是處理連接請求,和客戶端建立連接,並返回已連接套接字(Connected Socket)。

最後,服務器端可以通過調用 recv 或 send 函數,在剛才返回的已連接套接字上,接收並處理讀寫請求,或是將數據發送給客戶端。

代碼:

listenSocket = socket(); //調用socket系統調用創建一個主動套接字
bind(listenSocket); //綁定地址和端口
listen(listenSocket); //將默認的主動套接字轉換為服務器使用的被動套接字,也就是監聽套接字
while(1) { //循環監聽是否有客戶端連接請求到來
connSocket = accept(listenSocket);//接受客戶端連接
recv(connSocket);//從客戶端讀取數據,隻能同時處理一個客戶端
send(connSocket);//給客戶端返回數據,隻能同時處理一個客戶端
}

不過,從上述代碼中,你可能會發現,雖然它能夠實現服務器端和客戶端之間的通信,但是程序每調用一次 accept 函數,隻能處理一個客戶端連接。因此,如果想要處理多個並發客戶端的請求,我們就需要使用多線程,來處理通過 accept 函數建立的多個客戶端連接上的請求。

使用這種方法後,我們需要在 accept 函數返回已連接套接字後,創建一個線程,並將已連接套接字傳遞給創建的線程,由該線程負責這個連接套接字上後續的數據讀寫。同時,服務器端的執行流程會再次調用 accept 函數,等待下一個客戶端連接。

多線程:

listenSocket = socket(); //調用socket系統調用創建一個主動套接字
bind(listenSocket); //綁定地址和端口
listen(listenSocket); //將默認的主動套接字轉換為服務器使用的被動套接字,也就是監聽套接字
while(1) { //循環監聽是否有客戶端連接請求到來
connSocket = accept(listenSocket);//接受客戶端連接
pthread_create(processData, connSocket);//創建新線程對已連接套接字進行處理
}

processData(connSocket){
recv(connSocket);//從客戶端讀取數據,隻能同時處理一個客戶端
send(connSocket);//給客戶端返回數據,隻能同時處理一個客戶端
}

雖然這種方法能提升服務器端的並發處理能力,但是,Redis 的主執行流程是由一個線程在執行,無法使用多線程的方式來提升並發處理能力。所以,該方法對redis並不起作用。

還有沒有什麼其他方法,能幫助 Redis 提升並發客戶端的處理能力呢?這就要用到操作系統提供的IO多路復用功能。在基本的 Socket 編程模型中,accept 函數隻能在一個監聽套接字上監聽客戶端的連接,recv 函數也隻能在一個已連接套接字上,等待客戶端發送的請求。

因為 Linux 操作系統在實際應用中比較廣泛,所以這節課,我們主要來學習 Linux 上的 IO 多路復用機制。Linux 提供的 IO 多路復用機制主要有三種,分別是 select、poll 和 epoll。下面,我們就分別來學習下這三種機制的實現思路和使用方法。然後,我們再來看看,為什麼 Redis 通常是選擇使用 epoll 這種機制來實現網絡通信。

select 和 poll 機制實現 IO 多路復用

首先,我們來瞭解下 select 機制的編程模型。

不過在具體學習之前,我們需要知道,對於一種 IO 多路復用機制來說,我們需要掌握哪些要點,這樣可以幫助我們快速抓住不同機制的聯系與區別。其實,當我們學習 IO 多路復用機制時,我們需要能回答以下問題:第一,多路復用機制會監聽套接字上的哪些事件?第二,多路復用機制可以監聽多少個套接字?第三,當有套接字就緒時,多路復用機制要如何找到就緒的套接字?

select機制

select 機制中的一個重要函數就是 select 函數。對於 select 函數來說,它的參數包括監聽的文件描述符數量__nfds、、被監聽描述符的三個集合readfds、writefds、exceptfds,以及監聽時阻塞等待的超時時長timeout。select函數原型:

int select(int __nfds, fd_set *__readfds, fd_set *__writefds, fd_set *__exceptfds, struct timeval *__timeout)

這裡你需要註意的是,Linux 針對每一個套接字都會有一個文件描述符,也就是一個非負整數,用來唯一標識該套接字。所以,在多路復用機制的函數中,Linux 通常會用文件描述符作為參數。有瞭文件描述符,函數也就能找到對應的套接字,進而進行監聽、讀寫等操作。

select函數三個參數表示的是,被監聽描述符的集合,其實就是被監聽套接字的集合。那麼,為什麼會有三個集合呢?

剛才提出的第一個問題相關,也就是多路復用機制會監聽套接字上的哪些事件。select 函數使用三個集合,表示監聽的三類事件,分別是讀數據事件,寫數據事件,異常事件。

我們進一步可以看到,參數 readfds、writefds 和 exceptfds 的類型是 fd_set 結構體,它主要定義部分如下所示。其中,fd_mask類型是 long int 類型的別名,__FD_SETSIZE 和 __NFDBITS 這兩個宏定義的大小默認為 1024 和 32。

所以,fd_set 結構體的定義,其實就是一個 long int 類型的數組,該數組中一共有 32 個元素(1024/32=32),每個元素是 32 位(long int 類型的大小),而每一位可以用來表示一個文件描述符的狀態。瞭解瞭 fd_set 結構體的定義,我們就可以回答剛才提出的第二個問題瞭。select 函數對每一個描述符集合,都可以監聽 1024 個描述符。

如何使用 select 機制來實現網絡通信

首先,我們在調用 select 函數前,可以先創建好傳遞給 select 函數的描述符集合,然後再創建監聽套接字。而為瞭讓創建的監聽套接字能被 select 函數監控,我們需要把這個套接字的描述符加入到創建好的描述符集合中。

然後,我們就可以調用 select 函數,並把創建好的描述符集合作為參數傳遞給 select 函數。程序在調用 select 函數後,會發生阻塞。而當 select 函數檢測到有描述符就緒後,就會結束阻塞,並返回就緒的文件描述符個數。

那麼此時,我們就可以在描述符集合中查找哪些描述符就緒瞭。然後,我們對已就緒描述符對應的套接字進行處理。比如,如果是 readfds 集合中有描述符就緒,這就表明這些就緒描述符對應的套接字上,有讀事件發生,此時,我們就在該套接字上讀取數據。

而因為 select 函數一次可以監聽 1024 個文件描述符的狀態,所以 select 函數在返回時,也可能會一次返回多個就緒的文件描述符。這樣一來,我們就可以使用一個循環流程,依次對就緒描述符對應的套接字進行讀寫或異常處理操作。

select函數有兩個不足

  • 首先,select 函數對單個進程能監聽的文件描述符數量是有限制的,它能監聽的文件描述符個數由 __FD_SETSIZE 決定,默認值是 1024。

  • 其次,當 select 函數返回後,我們需要遍歷描述符集合,才能找到具體是哪些描述符就緒瞭。這個遍歷過程會產生一定開銷,從而降低程序的性能。

poll機制

poll 機制的主要函數是 poll 函數,我們先來看下它的原型定義,如下所示:

int poll(struct pollfd *__fds, nfds_t __nfds, int __timeout)

其中,參數 *__fds 是 pollfd 結構體數組,參數 __nfds 表示的是 *__fds 數組的元素個數,而 __timeout 表示 poll 函數阻塞的超時時間。

pollfd 結構體裡包含瞭要監聽的描述符,以及該描述符上要監聽的事件類型。這個我們可以從 pollfd 結構體的定義中看出來,如下所示。pollfd 結構體中包含瞭三個成員變量 fd、events 和 revents,分別表示要監聽的文件描述符、要監聽的事件類型和實際發生的事件類型。

pollfd 結構體中要監聽和實際發生的事件類型,是通過以下三個宏定義來表示的,分別是 POLLRDNORM、POLLWRNORM 和 POLLERR,它們分別表示可讀、可寫和錯誤事件。

瞭解瞭 poll 函數的參數後,我們來看下如何使用 poll 函數完成網絡通信。這個流程主要可以分成三步:

  • 第一步,創建 pollfd 數組和監聽套接字,並進行綁定;
  • 第二步,將監聽套接字加入 pollfd 數組,並設置其監聽讀事件,也就是客戶端的連接請求;
  • 第三步,循環調用 poll 函數,檢測 pollfd 數組中是否有就緒的文件描述符。

而在第三步的循環過程中,其處理邏輯又分成瞭兩種情況:

  • 如果是連接套接字就緒,這表明是有客戶端連接,我們可以調用 accept 接受連接,並創建已連接套接字,並將其加入 pollfd 數組,並監聽讀事件;

  • 如果是已連接套接字就緒,這表明客戶端有讀寫請求,我們可以調用 recv/send 函數處理讀寫請求。

其實,和 select 函數相比,poll 函數的改進之處主要就在於,它允許一次監聽超過 1024 個文件描述符。但是當調用瞭 poll 函數後,我們仍然需要遍歷每個文件描述符,檢測該描述符是否就緒,然後再進行處理。

epoll機制

首先,epoll 機制是使用 epoll_event 結構體,來記錄待監聽的文件描述符及其監聽的事件類型的,這和 poll 機制中使用 pollfd 結構體比較類似。

那麼,對於 epoll_event 結構體來說,其中包含瞭 epoll_data_t 聯合體變量,以及整數類型的 events 變量。epoll_data_t 聯合體中有記錄文件描述符的成員變量 fd,而 events 變量會取值使用不同的宏定義值,來表示 epoll_data_t 變量中的文件描述符所關註的事件類型,比如一些常見的事件類型包括以下這幾種。

  • EPOLLIN:讀事件,表示文件描述符對應套接字有數據可讀。
  • EPOLLOUT:寫事件,表示文件描述符對應套接字有數據要寫。
  • EPOLLERR:錯誤事件,表示文件描述符對於套接字出錯。

在使用 select 或 poll 函數的時候,創建好文件描述符集合或 pollfd 數組後,就可以往數組中添加我們需要監聽的文件描述符。

但是對於 epoll 機制來說,我們則需要先調用 epoll_create 函數,創建一個 epoll 實例。這個 epoll 實例內部維護瞭兩個結構,分別是記錄要監聽的文件描述符和已經就緒的文件描述符,,而對於已經就緒的文件描述符來說,它們會被返回給用戶程序進行處理。

所以,我們在使用 epoll 機制時,就不用像使用 select 和 poll 一樣,遍歷查詢哪些文件描述符已經就緒瞭。這樣一來, epoll 的效率就比 select 和 poll 有瞭更高的提升。

在創建瞭 epoll 實例後,我們需要再使用 epoll_ctl 函數,給被監聽的文件描述符添加監聽事件類型,以及使用 epoll_wait 函數獲取就緒的文件描述符。

瞭解瞭 epoll 函數的使用方法瞭。實際上,也正是因為 epoll 能自定義監聽的描述符數量,以及可以直接返回就緒的描述符,Redis 在設計和實現網絡通信框架時,就基於 epoll 機制中的 epoll_create、epoll_ctl 和 epoll_wait 等函數和讀寫事件,進行瞭封裝開發,實現瞭用於網絡通信的事件驅動框架,從而使得 Redis 雖然是單線程運行,但是仍然能高效應對高並發的客戶端訪問。

Reactor 模型的工作機制

Reactor 模型就是網絡服務器端用來處理高並發網絡 IO 請求的一種編程模型,模型特征:

  • 三類處理事件,即連接事件、寫事件、讀事件;
  • 三個關鍵角色,即 reactor、acceptor、handler。

Reactor 模型處理的是客戶端和服務器端的交互過程,而這三類事件正好對應瞭客戶端和服務器端交互過程中,不同類請求在服務器端引發的待處理事件:

  • 當一個客戶端要和服務器端進行交互時,客戶端會向服務器端發送連接請求,以建立連接,這就對應瞭服務器端的一個鏈接事件

  • 一旦連接建立後,客戶端會給服務器端發送讀請求,以便讀取數據。服務器端在處理讀請求時,需要向客戶端寫回數據,這對應瞭服務器端的寫事件

  • 無論客戶端給服務器端發送讀或寫請求,服務器端都需要從客戶端讀取請求內容,所以在這裡,讀或寫請求的讀取就對應瞭服務器端的讀事件

三個關鍵角色:

  • 首先,連接事件由 acceptor 來處理,負責接收連接;acceptor 在接收連接後,會創建 handler,用於網絡連接上對後續讀寫事件的處理;

  • 其次,讀寫事件由 handler 處理;

  • 最後,在高並發場景中,連接事件、讀寫事件會同時發生,所以,我們需要有一個角色專門監聽和分配事件,這就是 reactor 角色。當有連接請求時,reactor 將產生的連接事件交由 acceptor 處理;當有讀寫請求時,reactor 將讀寫事件交由 handler 處理。

那麼,現在我們已經知道,這三個角色是圍繞事件的監聽、轉發和處理來進行交互的,那麼在編程時,我們又該如何實現這三者的交互呢?這就離不開事件驅動。

所謂的事件驅動框架,就是在實現 Reactor 模型時,需要實現的代碼整體控制邏輯。簡單來說,事件驅動框架包括瞭兩部分:一是事件初始化,二事件捕獲,分化和處理主循環。

事件初始化是在服務器程序啟動時就執行的,它的作用主要是創建需要監聽的事件類型,以及該類事件對應的 handler。而一旦服務器完成初始化後,事件初始化也就相應完成瞭,服務器程序就需要進入到事件捕獲、分發和處理的主循環中。

用while循環來作為這個主循環。然後在這個主循環中,我們需要捕獲發生的事件、判斷事件類型,並根據事件類型,調用在初始化時創建好的事件 handler 來實際處理事件。

比如說,當有連接事件發生時,服務器程序需要調用 acceptor 處理函數,創建和客戶端的連接。而當有讀事件發生時,就表明有讀或寫請求發送到瞭服務器端,服務器程序就要調用具體的請求處理函數,從客戶端連接中讀取請求內容,進而就完成瞭讀事件的處理。

Reactor 模型的基本工作機制:客戶端的不同類請求會在服務器端觸發連接、讀、寫三類事件,這三類事件的監聽、分發和處理又是由 reactor、acceptor、handler 三類角色來完成的,然後這三類角色會通過事件驅動框架來實現交互和事件處理。

到此這篇關於淺談Redis的事件驅動模型的文章就介紹到這瞭,更多相關Redis 事件驅動模型內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: