Redis與Reactor模式
最近看了Redis的設計與實現,這本書寫的還不錯,看完后對Redis的理解有很大的幫助。另外,作者整理了一份Redis源碼注釋,大家可以clone下來閱讀。
Redis是開源的緩存數據庫,由于其高性能而受到大家的歡迎。同時,它的代碼量只有6w多行,相比起mysql動則上百萬行的代碼量,實現比較簡單。
Redis中有很多方面都很有意思,在這篇文章中我想探討的是Redis中的Reactor模式。
從Redis的工作模式談起
我們在使用Redis的時候,通常是多個客戶端連接Redis服務器,然后各自發送命令請求(例如Get、Set)到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這樣的系統調用,這些系統調用的功能是:你告知我一批套接字,當這些套接字的可讀或可寫事件發生時,我通知你這些事件信息。
根據圣經《UNIX網絡編程卷1》,當如下任一情況發生時,會產生套接字的可讀事件:
- 該套接字的接收緩沖區中的數據字節數大于等于套接字接收緩沖區低水位標記的大小;
- 該套接字的讀半部關閉(也就是收到了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, int timeout)
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 struct timespec *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。也就是說,Synchronous Event Demultiplexer負責等待新事件發生,事件發生時通知Initiation Dispatcher,然后Initiation Dispatcher調用event handler處理事件。
Event Handler :事件處理器的接口
Concrete Event Handler :事件處理器的實際實現,而且綁定了一個Handle。因為在實際情況中,我們往往不止一種事件處理器,因此這里將事件處理器接口和實現分開,與C++、Java這些高級語言中的多態類似。
以上各子模塊間協作的步驟描述如下:
-
我們注冊Concrete Event Handler到Initiation Dispatcher中。
-
Initiation Dispatcher調用每個Event Handler的get_handle接口獲取其綁定的Handle。
-
Initiation Dispatcher調用handle_events開始事件處理循環。在這里,Initiation Dispatcher會將步驟2獲取的所有Handle都收集起來,使用Synchronous Event Demultiplexer來等待這些Handle的事件發生。
-
當某個(或某幾個)Handle的事件發生時,Synchronous Event Demultiplexer通知Initiation Dispatcher。
-
Initiation Dispatcher根據發生事件的Handle找出所對應的Handler。
-
Initiation Dispatcher調用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)中關閉停止事件處理循環;最后退出。
總結
在這篇文章中,我們從Redis的工作模型開始,討論了C10K問題、I/O多路復用技術、Java的NIO,最后回歸到Redis的Reactor模式中。如有紕漏,懇請大家指出,我會一一加以勘正。謝謝!
參考資料
- The C10K problem
- Scalable Event Multiplexing: epoll vs. kqueue
- Kqueue: A generic and scalable event notification facility
- Scalable IO in Java
- Reactor: An Object Behavioral Pattern for Demultiplexing and Dispatching Handles for Synchronous Events
來自: http://www.dengshenyu.com/redis/2016/01/09/redis-reactor-pattern.html