首頁 > 軟體

淺談Redis的事件驅動模型

2022-05-29 14:00:11

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 事件驅動模型內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com