1. 程式人生 > >Redis:事件驅動(IO多路複用)

Redis:事件驅動(IO多路複用)

目錄

§  從Redis的工作模式談起

§  Reactor模式

·        C10K問題

·        I/O多路複用技術

·        Reactor的定義

·        Java中的NIO與Netty

§  Redis與Reactor

§  總結

§  參考資料

從Redis的工作模式談起
我們在使用Redis的時候,通常是多個客戶端連線Redis伺服器,然後各自發送命令請求(例如GetSet)到Redis伺服器,最後Redis處理這些請求返回結果
那Redis服務端是使用單程序還是多程序,單執行緒還是多執行緒來處理客戶端請求的呢?

答案是單程序單執行緒

當然,Redis除了處理客戶端的命令請求還有諸如RDB持久化AOF重寫這樣的事情要做,而在做這些事情的時候,Redis會fork(分叉出)子程序去完成但對於accept客戶端連線處理客戶端請求返回命令結果等等這些,Redis是使用主程序及主執行緒來完成的。

我們可能會驚訝Redis在使用單程序及單執行緒來處理請求為什麼會如此高效?在回答這個問題之前,

我們先來討論一個I/O多路複用的模式--Reactor

Reactor模式
C10K問題
考慮這樣一個問題:有10000個客戶端需要連上一個伺服器並保持TCP連線,客戶端會不定時的傳送請求給伺服器,伺服器收到請求後需及時處理並返回結果我們應該怎麼解決?

方案一:我們使用一個執行緒來監聽,當一個新的客戶端發起連線時,建立連線並new一個執行緒來處理這個新連線

缺點:當客戶端數量很多時,服務端執行緒數過多,即便不壓垮伺服器,由於CPU有限其效能也極其不理想因此此方案不可用


方案二:我們使用一個執行緒監聽,當一個新的客戶端發起連線時,建立連線並使用執行緒池處理該連線

優點:客戶端連線數量不會壓垮服務端

缺點:服務端處理能力受限於執行緒池的執行緒數,而且如果客戶端連線中大部分處於空閒狀態的話服務端的執行緒資源被浪費


因此,一個執行緒僅僅處理一個客戶端連線無論如何都是不可接受的,那能不能一個執行緒處理多個連線呢?該執行緒輪詢每個連線,如果某個連線有請求則處理請求,沒有請求則處理下一個連線,這樣可以實現嗎?

答案是肯定的,而且不必輪詢我們可以通過I/O多路複用技術來解決這個問題

I/O多路複用技術(三種裡最佳)
現代的UNIX作業系統提供了select/poll/kqueue/epoll這樣的系統呼叫,這些系統呼叫的功能是:你告知我一批套接字(socket),當這些套接字的可讀或可寫事件發生時,我通知你這些事件資訊。(IO中講到的,裡面的事件分離者。在我的理解有點像中介的味道,在socket和事件處理者中充當傳話的角色)、

I/O 多路複用模組(整個 I/O 多路複用模組在事件迴圈看來就是一個輸入事件、輸出 aeFiredEvent 陣列的一個黑箱)


I/O 多路複用模組封裝了底層的 select、epoll、avport 以及 kqueue這些 I/O 多路複用函式(實現了handle找實現的handler過程),為上層提供了相同的介面。

 

當如下任一情況發生時,會產生套接字的可讀事件:

§  該套接字的接收緩衝區中的資料位元組數大於等於套接字接收緩衝區低水位標記的大小;

§  該套接字的讀半部關閉(也就是收到了FIN),對這樣的套接字的讀操作將返回0(也就是返回EOF);

§  該套接字是一個監聽套接字且已完成的連線數不為0;

§  該套接字有錯誤待處理,對這樣的套接字的讀操作將返回-1

當如下任一情況發生時,會產生套接字的可寫事件:

§  該套接字的傳送緩衝區中的可用空間位元組數大於等於套接字傳送緩衝區低水位標記的大小;

§  該套接字的寫半部關閉,繼續寫會產生SIGPIPE訊號;

§  非阻塞模式下,connect返回之後,該套接字連線成功或失敗;

§  該套接字有錯誤待處理,對這樣的套接字的寫操作將返回-1

此外,在UNIX系統上,一切皆檔案套接字也不例外,每一個套接字都有對應的fd(即檔案描述符)我們簡單看看這幾個系統呼叫的原型

select(int nfds, fd_set *r, fd_set *w,fd_set *e, struct timeval *timeout)

對於select(),我們需要傳3個集合,r(讀),w(寫)和e其中,r表示我們對哪些fd的可讀事件感興趣,w表示我們對哪些fd的可寫事件感興趣每個集合其實是一個bitmap,通過0/1表示我們感興趣的fd例如,

如:我們對於fd為6的可讀事件感興趣,那麼r集合的第6個bit需要被設定為1這個系統呼叫會阻塞,直到我們感興趣的事件(至少一個)發生呼叫返回時,核心同樣使用這3個集合來存放fd實際發生的事件資訊也就是說,呼叫前這3個集合表示我們感興趣的事件,呼叫後這3個集合表示實際發生的事件

select為最早期的UNIX系統呼叫,它存在4個問題:

1)這3個bitmap有大小限制(FD_SETSIZE,通常為1024);

2)由於這3個集合在返回時會被核心修改,因此我們每次呼叫時都需要重新設定

3)我們在呼叫完成後需要掃描這3個集合才能知道哪些fd的讀/寫事件發生了,一般情況下全量集合比較大而實際發生讀/寫事件的fd比較少,效率比較低下;

4)核心在每次呼叫都需要掃描這3個fd集合,然後檢視哪些fd的事件實際發生,在讀/寫比較稀疏的情況下同樣存在效率問題

由於存在這些問題,於是人們對select進行了改進,從而有了poll

poll(struct pollfd *fds, int nfds, inttimeout)

 

struct pollfd {

int fd;

short events;

short revents;

}

 

poll呼叫需要傳遞的是一個pollfd結構的陣列,呼叫返回時結果資訊也存放在這個數組裡面pollfd的結構中存放著fd我們對該fd感興趣的事件(events)以及該fd實際發生的事件(revents)poll傳遞的不是固定大小的bitmap,因此

select的問題1解決了;poll將感興趣事件和實際發生事件分開了,因此

select的問題2也解決了但

select的問題3和問題4仍然沒有解決

select問題3比較容易解決,只要系統呼叫返回的是實際發生相應事件的fd集合,我們便不需要掃描全量的fd集合

對於select的問題4,我們為什麼需要每次呼叫都傳遞全量的fd呢?

核心可不可以在第一次呼叫的時候記錄這些fd,然後我們在以後的呼叫中不需要再傳這些fd呢?

問題的關鍵在於無狀態對於每一次系統呼叫,核心不會記錄下任何資訊,所以每次呼叫都需要重複傳遞相同資訊

上帝說要有狀態,所以我們有了epoll和kqueue

int epoll_create(int size);

int epoll_ctl(int epfd, int op, int fd,struct epoll_event *event);

int epoll_wait(int epfd, struct epoll_event*events, int maxevents, int timeout);

epoll_create的作用是建立一個context,這個context相當於狀態儲存者的概念

epoll_ctl的作用是,當你對一個新的fd的讀/寫事件感興趣時,通過該呼叫將fd與相應的感興趣事件更新到context中

epoll_wait的作用是,等待context中fd的事件發生

就是這麼簡單

epoll是Linux中的實現,kqueue則是在FreeBSD的實現

int kqueue(void);

int kevent(int kq, const struct kevent*changelist, int nchanges, struct kevent *eventlist, int nevents, const structtimespec *timeout);

與epoll相同的是,kqueue建立一個context;與epoll不同的是,kqueue用kevent代替了epoll_ctl和epoll_wait

epoll和kqueue解決了select存在的問題通過它們,我們可以高效的通過系統呼叫來獲取多個套接字的讀/寫事件,從而解決一個執行緒處理多個連線的問題

Reactor的定義


通過select/poll/epoll/kqueue這些I/O多路複用函式庫,我們解決了一個執行緒處理多個連線的問題,但整個Reactor模式的完整框架是怎樣的呢?參考這篇paper,我們可以對Reactor模式有個完整的描述

 

Handles:表示作業系統管理的資源,我們可以理解為fd

Synchronous Event Demultiplexer:同步事件分離器,阻塞等待Handles中的事件發生

Initiation Dispatcher:初始分派器,作用為新增Event handler(事件處理器)刪除Event handler以及分派事件給Event handler也就是說,SynchronousEvent Demultiplexer負責等待新事件發生,事件發生時通知InitiationDispatcher,然後Initiation Dispatcher呼叫event handler處理事件

Event Handler:事件處理器的介面

Concrete Event Handler:事件處理器的實際實現,而且綁定了一個Handle因為在實際情況中,我們往往不止一種事件處理器,因此這裡將事件處理器介面和實現分開,與C++Java這些高階語言中的多型類似

以上各子模組間協作的步驟描述如下:(其實就是專案中所做的基於redis 的非同步框架差不多)

1.    我們註冊Concrete Event Handler到InitiationDispatcher中

2.   Initiation Dispatcher呼叫每個Event Handler的get_handle介面獲取其繫結的Handle

3.   Initiation Dispatcher呼叫handle_events開始事件處理迴圈在這裡,InitiationDispatcher會將步驟2獲取的所有Handle都收集起來,使用Synchronous Event Demultiplexer來等待這些Handle的事件發生

4.    當某個(或某幾個)Handle的事件發生時,Synchronous Event Demultiplexer通知InitiationDispatcher

5.   Initiation Dispatcher根據發生事件的Handle找出所對應的Handler

6.    InitiationDispatcher呼叫Handler的handle_event方法處理事件

時序圖如下:


另外,該文章舉了一個分散式日誌處理的例子,感興趣的同學可以看下

通過以上的敘述,我們清楚了Reactor的大概框架以及涉及到的底層I/O多路複用技術

Java中的NIO與Netty
談到Reactor模式,在這裡奉上Java大神Doug Lea的Scalable IO in Java,裡面提到了Java網路程式設計中的經典模式NIO(非堵塞)以及Reactor,並且有相關程式碼幫助理解,看完後獲益良多

另外,Java的NIO是比較底層的,我們實際在網路程式設計中還需要自己處理很多問題(譬如socket的讀半包),稍不注意就會掉進坑裡幸好,我們有了Netty這麼一個網路處理框架,免去了很多麻煩

Redis與Reactor
在上面的討論中,我們瞭解了Reactor模式,那麼Redis中又是怎麼使用Reactor模式的呢?

首先,Redis伺服器中有兩類事件,檔案事件和時間事件

§  檔案事件(file event):Redis客戶端通過socket與Redis伺服器連線,而檔案事件就是伺服器對套接字操作的抽象例如,客戶端發了一個GET命令請求,對於Redis伺服器來說就是一個檔案事件

§  時間事件(time event):伺服器定時或週期性執行的事件例如,定期執行RDB持久化

在這裡我們主要關注Redis處理檔案事件的模型參考Redis的設計與實現,Redis的檔案事件處理模型是這樣的:


在這個模型中,Redis伺服器用主執行緒執行I/O多路複用程式檔案事件分派器以及事件處理器而且,儘管多個檔案事件可能會併發出現,Redis伺服器是順序處理各個檔案事件的

Redis伺服器主執行緒的執行流程在Redis.c的main函式中體現,而關於處理檔案事件的主要的有這幾行:

int main(int argc, char **argv) {

...

initServer();

...

aeMain();

...

aeDeleteEventLoop(server.el);

return 0;

}

在initServer()中,建立各個事件處理器;在aeMain()中,執行事件處理迴圈;在aeDeleteEventLoop(server.el)中關閉停止事件處理迴圈;最後退出

總結
多路 I/O 複用模型是利用select、poll、epoll可以同時監察多個流的 I/O 事件的能力,在空閒的時候,會把當前執行緒阻塞掉,當有一個或多個流有I/O事件時,就從阻塞態中喚醒,於是程式就會輪詢一遍所有的流(epoll是隻輪詢那些真正發出了事件的流),並且只依次順序的處理就緒的流,這種做法就避免了大量的無用操作。這裡“多路”指的是多個網路連線,“複用”指的是複用同一個執行緒。採用多路 I/O 複用技術可以讓單個執行緒高效的處理多個連線請求(儘量減少網路IO的時間消耗),且Redis在記憶體中操作資料的速度非常快(記憶體內的操作不會成為這裡的效能瓶頸),主要以上兩點造就了Redis具有很高的吞吐量。

在這篇文章中,我們從Redis的工作模型開始,討論了C10K問題、I/O多路複用技術、Java的NIO,最後迴歸到Redis的Reactor模式中。如有紕漏,懇請大家指出,我會一一加以勘正。謝謝!