淺談Redis的事件驅(qū)動模型
Redis 作為一個 Client-Server 架構(gòu)的數(shù)據(jù)庫,其源碼中少不了用來實(shí)現(xiàn)網(wǎng)絡(luò)通信的部分。而你應(yīng)該也清楚,通常系統(tǒng)實(shí)現(xiàn)網(wǎng)絡(luò)通信的基本方法是使用Socket編程模型,,包括創(chuàng)建 Socket、監(jiān)聽端口、處理連接請求和讀寫請求。但是,由于基本的 Socket 編程模型一次只能處理一個客戶端連接上的請求,所以當(dāng)要處理高并發(fā)請求時,一種方案就是使用多線程,讓每個線程負(fù)責(zé)處理一個客戶端的請求。
而 Redis 負(fù)責(zé)客戶端請求解析和處理的線程只有一個,那么如果直接采用基本 Socket 模型,就會影響 Redis 支持高并發(fā)的客戶端訪問。
因此,為了實(shí)現(xiàn)高并發(fā)的網(wǎng)絡(luò)通信,我們常用的 Linux 操作系統(tǒng),就提供了 select、poll 和 epoll 三種編程模型,而在 Linux 上運(yùn)行的 Redis,通常就會采用其中的epoll模型來進(jìn)行網(wǎng)絡(luò)通訊。
為啥 Redis 通常會選擇 epoll 模型呢?這三種編程模型之間有什么區(qū)別?
要想理解 select、poll 和 epoll 的優(yōu)勢,我們需要有個對比基礎(chǔ),也就是基本的 Socket 編程模型。所以接下來,我們就先來了解下基本的 Socket 編程模型,以及它的不足之處。
為什么 Redis 不使用基本的 Socket 編程模型?
使用 Socket 模型實(shí)現(xiàn)網(wǎng)絡(luò)通信時,需要經(jīng)過創(chuàng)建 Socket、監(jiān)聽端口、處理連接和讀寫請求等多個步驟,現(xiàn)在我們就來具體了解下這些步驟中的關(guān)鍵操作,以此幫助我們分析 Socket 模型中的不足。
首先,當(dāng)我們需要讓服務(wù)器端和客戶端進(jìn)行通信時,可以在服務(wù)器端通過以下三步,來創(chuàng)建監(jiān)聽客戶端連接的監(jiān)聽套接字(Listening Socket):
- 調(diào)用 socket 函數(shù),創(chuàng)建一個套接字。我們通常把這個套接字稱為主動套接字(Active Socket);
- 調(diào)用 bind 函數(shù),將主動套接字和當(dāng)前服務(wù)器的 IP 和監(jiān)聽端口進(jìn)行綁定;
- 調(diào)用 listen 函數(shù),將主動套接字轉(zhuǎn)換為監(jiān)聽套接字,開始監(jiān)聽客戶端的連接。
在完成上述三步之后,服務(wù)器端就可以接收客戶端的連接請求了。為了能及時地收到客戶端的連接請求,我們可以運(yùn)行一個循環(huán)流程,在該流程中調(diào)用 accept 函數(shù),用于接收客戶端連接請求。
這里你需要注意的是,accept 函數(shù)是阻塞函數(shù),也就是說,如果此時一直沒有客戶端連接請求,那么,服務(wù)器端的執(zhí)行流程會一直阻塞在 accept 函數(shù)。一旦有客戶端連接請求到達(dá),accept 將不再阻塞,而是處理連接請求,和客戶端建立連接,并返回已連接套接字(Connected Socket)。
最后,服務(wù)器端可以通過調(diào)用 recv 或 send 函數(shù),在剛才返回的已連接套接字上,接收并處理讀寫請求,或是將數(shù)據(jù)發(fā)送給客戶端。
代碼:
listenSocket = socket(); //調(diào)用socket系統(tǒng)調(diào)用創(chuàng)建一個主動套接字 bind(listenSocket); //綁定地址和端口 listen(listenSocket); //將默認(rèn)的主動套接字轉(zhuǎn)換為服務(wù)器使用的被動套接字,也就是監(jiān)聽套接字 while(1) { //循環(huán)監(jiān)聽是否有客戶端連接請求到來 connSocket = accept(listenSocket);//接受客戶端連接 recv(connSocket);//從客戶端讀取數(shù)據(jù),只能同時處理一個客戶端 send(connSocket);//給客戶端返回?cái)?shù)據(jù),只能同時處理一個客戶端 }
不過,從上述代碼中,你可能會發(fā)現(xiàn),雖然它能夠?qū)崿F(xiàn)服務(wù)器端和客戶端之間的通信,但是程序每調(diào)用一次 accept 函數(shù),只能處理一個客戶端連接。因此,如果想要處理多個并發(fā)客戶端的請求,我們就需要使用多線程,來處理通過 accept 函數(shù)建立的多個客戶端連接上的請求。
使用這種方法后,我們需要在 accept 函數(shù)返回已連接套接字后,創(chuàng)建一個線程,并將已連接套接字傳遞給創(chuàng)建的線程,由該線程負(fù)責(zé)這個連接套接字上后續(xù)的數(shù)據(jù)讀寫。同時,服務(wù)器端的執(zhí)行流程會再次調(diào)用 accept 函數(shù),等待下一個客戶端連接。
多線程:
listenSocket = socket(); //調(diào)用socket系統(tǒng)調(diào)用創(chuàng)建一個主動套接字 bind(listenSocket); //綁定地址和端口 listen(listenSocket); //將默認(rèn)的主動套接字轉(zhuǎn)換為服務(wù)器使用的被動套接字,也就是監(jiān)聽套接字 while(1) { //循環(huán)監(jiān)聽是否有客戶端連接請求到來 connSocket = accept(listenSocket);//接受客戶端連接 pthread_create(processData, connSocket);//創(chuàng)建新線程對已連接套接字進(jìn)行處理 } processData(connSocket){ recv(connSocket);//從客戶端讀取數(shù)據(jù),只能同時處理一個客戶端 send(connSocket);//給客戶端返回?cái)?shù)據(jù),只能同時處理一個客戶端 }
雖然這種方法能提升服務(wù)器端的并發(fā)處理能力,但是,Redis 的主執(zhí)行流程是由一個線程在執(zhí)行,無法使用多線程的方式來提升并發(fā)處理能力。所以,該方法對redis并不起作用。
還有沒有什么其他方法,能幫助 Redis 提升并發(fā)客戶端的處理能力呢?這就要用到操作系統(tǒng)提供的IO多路復(fù)用功能。在基本的 Socket 編程模型中,accept 函數(shù)只能在一個監(jiān)聽套接字上監(jiān)聽客戶端的連接,recv 函數(shù)也只能在一個已連接套接字上,等待客戶端發(fā)送的請求。
因?yàn)?Linux 操作系統(tǒng)在實(shí)際應(yīng)用中比較廣泛,所以這節(jié)課,我們主要來學(xué)習(xí) Linux 上的 IO 多路復(fù)用機(jī)制。Linux 提供的 IO 多路復(fù)用機(jī)制主要有三種,分別是 select、poll 和 epoll。下面,我們就分別來學(xué)習(xí)下這三種機(jī)制的實(shí)現(xiàn)思路和使用方法。然后,我們再來看看,為什么 Redis 通常是選擇使用 epoll 這種機(jī)制來實(shí)現(xiàn)網(wǎng)絡(luò)通信。
select 和 poll 機(jī)制實(shí)現(xiàn) IO 多路復(fù)用
首先,我們來了解下 select 機(jī)制的編程模型。
不過在具體學(xué)習(xí)之前,我們需要知道,對于一種 IO 多路復(fù)用機(jī)制來說,我們需要掌握哪些要點(diǎn),這樣可以幫助我們快速抓住不同機(jī)制的聯(lián)系與區(qū)別。其實(shí),當(dāng)我們學(xué)習(xí) IO 多路復(fù)用機(jī)制時,我們需要能回答以下問題:第一,多路復(fù)用機(jī)制會監(jiān)聽套接字上的哪些事件?第二,多路復(fù)用機(jī)制可以監(jiān)聽多少個套接字?第三,當(dāng)有套接字就緒時,多路復(fù)用機(jī)制要如何找到就緒的套接字?
select機(jī)制
select 機(jī)制中的一個重要函數(shù)就是 select 函數(shù)。對于 select 函數(shù)來說,它的參數(shù)包括監(jiān)聽的文件描述符數(shù)量__nfds、、被監(jiān)聽描述符的三個集合readfds、writefds、exceptfds,以及監(jiān)聽時阻塞等待的超時時長timeout。select函數(shù)原型:
int select(int __nfds, fd_set *__readfds, fd_set *__writefds, fd_set *__exceptfds, struct timeval *__timeout)
這里你需要注意的是,Linux 針對每一個套接字都會有一個文件描述符,也就是一個非負(fù)整數(shù),用來唯一標(biāo)識該套接字。所以,在多路復(fù)用機(jī)制的函數(shù)中,Linux 通常會用文件描述符作為參數(shù)。有了文件描述符,函數(shù)也就能找到對應(yīng)的套接字,進(jìn)而進(jìn)行監(jiān)聽、讀寫等操作。
select函數(shù)三個參數(shù)表示的是,被監(jiān)聽描述符的集合,其實(shí)就是被監(jiān)聽套接字的集合。那么,為什么會有三個集合呢?
剛才提出的第一個問題相關(guān),也就是多路復(fù)用機(jī)制會監(jiān)聽套接字上的哪些事件。select 函數(shù)使用三個集合,表示監(jiān)聽的三類事件,分別是讀數(shù)據(jù)事件,寫數(shù)據(jù)事件,異常事件。
我們進(jìn)一步可以看到,參數(shù) readfds、writefds 和 exceptfds 的類型是 fd_set 結(jié)構(gòu)體,它主要定義部分如下所示。其中,fd_mask類型是 long int 類型的別名,__FD_SETSIZE 和 __NFDBITS 這兩個宏定義的大小默認(rèn)為 1024 和 32。
所以,fd_set 結(jié)構(gòu)體的定義,其實(shí)就是一個 long int 類型的數(shù)組,該數(shù)組中一共有 32 個元素(1024/32=32),每個元素是 32 位(long int 類型的大小),而每一位可以用來表示一個文件描述符的狀態(tài)。了解了 fd_set 結(jié)構(gòu)體的定義,我們就可以回答剛才提出的第二個問題了。select 函數(shù)對每一個描述符集合,都可以監(jiān)聽 1024 個描述符。
如何使用 select 機(jī)制來實(shí)現(xiàn)網(wǎng)絡(luò)通信
首先,我們在調(diào)用 select 函數(shù)前,可以先創(chuàng)建好傳遞給 select 函數(shù)的描述符集合,然后再創(chuàng)建監(jiān)聽套接字。而為了讓創(chuàng)建的監(jiān)聽套接字能被 select 函數(shù)監(jiān)控,我們需要把這個套接字的描述符加入到創(chuàng)建好的描述符集合中。
然后,我們就可以調(diào)用 select 函數(shù),并把創(chuàng)建好的描述符集合作為參數(shù)傳遞給 select 函數(shù)。程序在調(diào)用 select 函數(shù)后,會發(fā)生阻塞。而當(dāng) select 函數(shù)檢測到有描述符就緒后,就會結(jié)束阻塞,并返回就緒的文件描述符個數(shù)。
那么此時,我們就可以在描述符集合中查找哪些描述符就緒了。然后,我們對已就緒描述符對應(yīng)的套接字進(jìn)行處理。比如,如果是 readfds 集合中有描述符就緒,這就表明這些就緒描述符對應(yīng)的套接字上,有讀事件發(fā)生,此時,我們就在該套接字上讀取數(shù)據(jù)。
而因?yàn)?select 函數(shù)一次可以監(jiān)聽 1024 個文件描述符的狀態(tài),所以 select 函數(shù)在返回時,也可能會一次返回多個就緒的文件描述符。這樣一來,我們就可以使用一個循環(huán)流程,依次對就緒描述符對應(yīng)的套接字進(jìn)行讀寫或異常處理操作。
select函數(shù)有兩個不足
首先,select 函數(shù)對單個進(jìn)程能監(jiān)聽的文件描述符數(shù)量是有限制的,它能監(jiān)聽的文件描述符個數(shù)由 __FD_SETSIZE 決定,默認(rèn)值是 1024。
其次,當(dāng) select 函數(shù)返回后,我們需要遍歷描述符集合,才能找到具體是哪些描述符就緒了。這個遍歷過程會產(chǎn)生一定開銷,從而降低程序的性能。
poll機(jī)制
poll 機(jī)制的主要函數(shù)是 poll 函數(shù),我們先來看下它的原型定義,如下所示:
int poll(struct pollfd *__fds, nfds_t __nfds, int __timeout)
其中,參數(shù) *__fds 是 pollfd 結(jié)構(gòu)體數(shù)組,參數(shù) __nfds 表示的是 *__fds 數(shù)組的元素個數(shù),而 __timeout 表示 poll 函數(shù)阻塞的超時時間。
pollfd 結(jié)構(gòu)體里包含了要監(jiān)聽的描述符,以及該描述符上要監(jiān)聽的事件類型。這個我們可以從 pollfd 結(jié)構(gòu)體的定義中看出來,如下所示。pollfd 結(jié)構(gòu)體中包含了三個成員變量 fd、events 和 revents,分別表示要監(jiān)聽的文件描述符、要監(jiān)聽的事件類型和實(shí)際發(fā)生的事件類型。
pollfd 結(jié)構(gòu)體中要監(jiān)聽和實(shí)際發(fā)生的事件類型,是通過以下三個宏定義來表示的,分別是 POLLRDNORM、POLLWRNORM 和 POLLERR,它們分別表示可讀、可寫和錯誤事件。
了解了 poll 函數(shù)的參數(shù)后,我們來看下如何使用 poll 函數(shù)完成網(wǎng)絡(luò)通信。這個流程主要可以分成三步:
- 第一步,創(chuàng)建 pollfd 數(shù)組和監(jiān)聽套接字,并進(jìn)行綁定;
- 第二步,將監(jiān)聽套接字加入 pollfd 數(shù)組,并設(shè)置其監(jiān)聽讀事件,也就是客戶端的連接請求;
- 第三步,循環(huán)調(diào)用 poll 函數(shù),檢測 pollfd 數(shù)組中是否有就緒的文件描述符。
而在第三步的循環(huán)過程中,其處理邏輯又分成了兩種情況:
如果是連接套接字就緒,這表明是有客戶端連接,我們可以調(diào)用 accept 接受連接,并創(chuàng)建已連接套接字,并將其加入 pollfd 數(shù)組,并監(jiān)聽讀事件;
如果是已連接套接字就緒,這表明客戶端有讀寫請求,我們可以調(diào)用 recv/send 函數(shù)處理讀寫請求。
其實(shí),和 select 函數(shù)相比,poll 函數(shù)的改進(jìn)之處主要就在于,它允許一次監(jiān)聽超過 1024 個文件描述符。但是當(dāng)調(diào)用了 poll 函數(shù)后,我們?nèi)匀恍枰闅v每個文件描述符,檢測該描述符是否就緒,然后再進(jìn)行處理。
epoll機(jī)制
首先,epoll 機(jī)制是使用 epoll_event 結(jié)構(gòu)體,來記錄待監(jiān)聽的文件描述符及其監(jiān)聽的事件類型的,這和 poll 機(jī)制中使用 pollfd 結(jié)構(gòu)體比較類似。
那么,對于 epoll_event 結(jié)構(gòu)體來說,其中包含了 epoll_data_t 聯(lián)合體變量,以及整數(shù)類型的 events 變量。epoll_data_t 聯(lián)合體中有記錄文件描述符的成員變量 fd,而 events 變量會取值使用不同的宏定義值,來表示 epoll_data_t 變量中的文件描述符所關(guān)注的事件類型,比如一些常見的事件類型包括以下這幾種。
- EPOLLIN:讀事件,表示文件描述符對應(yīng)套接字有數(shù)據(jù)可讀。
- EPOLLOUT:寫事件,表示文件描述符對應(yīng)套接字有數(shù)據(jù)要寫。
- EPOLLERR:錯誤事件,表示文件描述符對于套接字出錯。
在使用 select 或 poll 函數(shù)的時候,創(chuàng)建好文件描述符集合或 pollfd 數(shù)組后,就可以往數(shù)組中添加我們需要監(jiān)聽的文件描述符。
但是對于 epoll 機(jī)制來說,我們則需要先調(diào)用 epoll_create 函數(shù),創(chuàng)建一個 epoll 實(shí)例。這個 epoll 實(shí)例內(nèi)部維護(hù)了兩個結(jié)構(gòu),分別是記錄要監(jiān)聽的文件描述符和已經(jīng)就緒的文件描述符,,而對于已經(jīng)就緒的文件描述符來說,它們會被返回給用戶程序進(jìn)行處理。
所以,我們在使用 epoll 機(jī)制時,就不用像使用 select 和 poll 一樣,遍歷查詢哪些文件描述符已經(jīng)就緒了。這樣一來, epoll 的效率就比 select 和 poll 有了更高的提升。
在創(chuàng)建了 epoll 實(shí)例后,我們需要再使用 epoll_ctl 函數(shù),給被監(jiān)聽的文件描述符添加監(jiān)聽事件類型,以及使用 epoll_wait 函數(shù)獲取就緒的文件描述符。
了解了 epoll 函數(shù)的使用方法了。實(shí)際上,也正是因?yàn)?epoll 能自定義監(jiān)聽的描述符數(shù)量,以及可以直接返回就緒的描述符,Redis 在設(shè)計(jì)和實(shí)現(xiàn)網(wǎng)絡(luò)通信框架時,就基于 epoll 機(jī)制中的 epoll_create、epoll_ctl 和 epoll_wait 等函數(shù)和讀寫事件,進(jìn)行了封裝開發(fā),實(shí)現(xiàn)了用于網(wǎng)絡(luò)通信的事件驅(qū)動框架,從而使得 Redis 雖然是單線程運(yùn)行,但是仍然能高效應(yīng)對高并發(fā)的客戶端訪問。
Reactor 模型的工作機(jī)制
Reactor 模型就是網(wǎng)絡(luò)服務(wù)器端用來處理高并發(fā)網(wǎng)絡(luò) IO 請求的一種編程模型,模型特征:
- 三類處理事件,即連接事件、寫事件、讀事件;
- 三個關(guān)鍵角色,即 reactor、acceptor、handler。
Reactor 模型處理的是客戶端和服務(wù)器端的交互過程,而這三類事件正好對應(yīng)了客戶端和服務(wù)器端交互過程中,不同類請求在服務(wù)器端引發(fā)的待處理事件:
當(dāng)一個客戶端要和服務(wù)器端進(jìn)行交互時,客戶端會向服務(wù)器端發(fā)送連接請求,以建立連接,這就對應(yīng)了服務(wù)器端的一個鏈接事件
一旦連接建立后,客戶端會給服務(wù)器端發(fā)送讀請求,以便讀取數(shù)據(jù)。服務(wù)器端在處理讀請求時,需要向客戶端寫回?cái)?shù)據(jù),這對應(yīng)了服務(wù)器端的寫事件
無論客戶端給服務(wù)器端發(fā)送讀或?qū)懻埱?,服?wù)器端都需要從客戶端讀取請求內(nèi)容,所以在這里,讀或?qū)懻埱蟮淖x取就對應(yīng)了服務(wù)器端的讀事件
三個關(guān)鍵角色:
首先,連接事件由 acceptor 來處理,負(fù)責(zé)接收連接;acceptor 在接收連接后,會創(chuàng)建 handler,用于網(wǎng)絡(luò)連接上對后續(xù)讀寫事件的處理;
其次,讀寫事件由 handler 處理;
最后,在高并發(fā)場景中,連接事件、讀寫事件會同時發(fā)生,所以,我們需要有一個角色專門監(jiān)聽和分配事件,這就是 reactor 角色。當(dāng)有連接請求時,reactor 將產(chǎn)生的連接事件交由 acceptor 處理;當(dāng)有讀寫請求時,reactor 將讀寫事件交由 handler 處理。
那么,現(xiàn)在我們已經(jīng)知道,這三個角色是圍繞事件的監(jiān)聽、轉(zhuǎn)發(fā)和處理來進(jìn)行交互的,那么在編程時,我們又該如何實(shí)現(xiàn)這三者的交互呢?這就離不開事件驅(qū)動。
所謂的事件驅(qū)動框架,就是在實(shí)現(xiàn) Reactor 模型時,需要實(shí)現(xiàn)的代碼整體控制邏輯。簡單來說,事件驅(qū)動框架包括了兩部分:一是事件初始化,二事件捕獲,分化和處理主循環(huán)。
事件初始化是在服務(wù)器程序啟動時就執(zhí)行的,它的作用主要是創(chuàng)建需要監(jiān)聽的事件類型,以及該類事件對應(yīng)的 handler。而一旦服務(wù)器完成初始化后,事件初始化也就相應(yīng)完成了,服務(wù)器程序就需要進(jìn)入到事件捕獲、分發(fā)和處理的主循環(huán)中。
用while循環(huán)來作為這個主循環(huán)。然后在這個主循環(huán)中,我們需要捕獲發(fā)生的事件、判斷事件類型,并根據(jù)事件類型,調(diào)用在初始化時創(chuàng)建好的事件 handler 來實(shí)際處理事件。
比如說,當(dāng)有連接事件發(fā)生時,服務(wù)器程序需要調(diào)用 acceptor 處理函數(shù),創(chuàng)建和客戶端的連接。而當(dāng)有讀事件發(fā)生時,就表明有讀或?qū)懻埱蟀l(fā)送到了服務(wù)器端,服務(wù)器程序就要調(diào)用具體的請求處理函數(shù),從客戶端連接中讀取請求內(nèi)容,進(jìn)而就完成了讀事件的處理。
Reactor 模型的基本工作機(jī)制:客戶端的不同類請求會在服務(wù)器端觸發(fā)連接、讀、寫三類事件,這三類事件的監(jiān)聽、分發(fā)和處理又是由 reactor、acceptor、handler 三類角色來完成的,然后這三類角色會通過事件驅(qū)動框架來實(shí)現(xiàn)交互和事件處理。
到此這篇關(guān)于淺談Redis的事件驅(qū)動模型的文章就介紹到這了,更多相關(guān)Redis 事件驅(qū)動模型內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Redis與本地緩存的結(jié)合實(shí)現(xiàn)
我們開發(fā)中經(jīng)常用到Redis作為緩存,本文主要介紹了Redis與本地緩存的結(jié)合實(shí)現(xiàn),文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2022-07-07基于redis 7.2.3的makefile源碼解讀學(xué)習(xí)
這篇文章主要為大家介紹了基于redis 7.2.3的makefile源碼解讀學(xué)習(xí),有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-12-12Redis Template實(shí)現(xiàn)分布式鎖的實(shí)例代碼
使用Redis的SETNX命令獲取分布式鎖的步驟,接下來通過本文給大家介紹Redis Template實(shí)現(xiàn)分布式鎖的實(shí)例代碼,代碼簡單易懂,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友參考下吧2018-09-09redis事務(wù)_動力節(jié)點(diǎn)Java學(xué)院整理
這篇文章主要介紹了redis事務(wù),小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-08-08CentOS系統(tǒng)中Redis數(shù)據(jù)庫的安裝配置指南
Redis是一個基于主存存儲的數(shù)據(jù)庫,性能很強(qiáng),這里我們就來看一下CentOS系統(tǒng)中Redis數(shù)據(jù)庫的安裝配置指南,包括將Redis作為系統(tǒng)服務(wù)運(yùn)行的技巧等,需要的朋友可以參考下2016-06-06深入解析Redis的LRU與LFU算法實(shí)現(xiàn)
這篇文章主要重點(diǎn)介紹了Redis的LRU與LFU算法實(shí)現(xiàn),并分析總結(jié)了兩種算法的實(shí)現(xiàn)效果以及存在的問題,并闡述其優(yōu)劣特性,感興趣的小伙伴跟著小編一起來看看吧2023-07-07