Redis與Reactor模式
近期看了Redis的設計與實現,這本書寫的還不錯,看完后對Redis的理解有非常大的幫助。
另外,作者整理了一份Redis源代碼凝視,大家能夠clone下來閱讀。
Redis是開源的緩存數據庫,因為其高性能而受到大家的歡迎。同一時候,它的代碼量僅僅有6w多行,相比起mysql動則上百萬行的代碼量,實現比較簡單。
Redis中有非常多方面都非常有意思,在這篇文章中我想探討的是Redis中的Reactor模式。
文件夾
從Redis的工作模式談起
我們在使用Redis的時候。一般是多個client連接Redisserver,然后各自發送命令請求(比如Get、Set)到Redisserver,最后Redis處理這些請求返回結果。
那Redis服務端是使用單進程還是多進程,單線程還是多線程來處理client請求的呢?
答案是單進程單線程。
當然。Redis除了處理client的命令請求還有諸如RDB持久化、AOF重寫這種事情要做。而在做這些事情的時候,Redis會fork子進程去完畢。但對於acceptclient連接、處理client請求、返回命令結果等等這些。Redis是使用主進程及主線程來完畢的。
我們可能會吃驚Redis在使用單進程及單線程來處理請求為什么會如此高效?在回答這個問題之前,我們先來討論一個I/O多路復用的模式--Reactor。
Reactor模式
C10K問題
考慮這樣一個問題:有10000個client須要連上一個server並保持TCP連接。client會不定時的發送請求給server,server收到請求后需及時處理並返回結果。我們應該怎么解決?
方案一:我們使用一個線程來監聽,當一個新的client發起連接時,建立連接並new一個線程來處理這個新連接。
缺點:當client數量非常多時,服務端線程數過多,即便不壓垮server,因為CPU有限其性能也極其不理想。
因此此方案不可用。
方案二:我們使用一個線程監聽。當一個新的client發起連接時。建立連接並使用線程池處理該連接。
長處:client連接數量不會壓垮服務端。
缺點:服務端處理能力受限於線程池的線程數,並且假設client連接中大部分處於空暇狀態的話服務端的線程資源被浪費。
因此。一個線程只處理一個client連接不管怎樣都是不可接受的。那能不能一個線程處理多個連接呢?該線程輪詢每一個連接,假設某個連接有請求則處理請求。沒有請求則處理下一個連接,這樣能夠實現嗎?
答案是肯定的。並且不必輪詢。
我們能夠通過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比較easy解決,僅僅要系統調用返回的是實際發生對應事件的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模式的呢?
首先。Redisserver中有兩類事件,文件事件和時間事件。
-
文件事件(file event):Redisclient通過socket與Redisserver連接,而文件事件就是server對套接字操作的抽象。
比如,client發了一個GET命令請求。對於Redisserver來說就是一個文件事件。
-
時間事件(time event):server定時或周期性運行的事件。比如,定期運行RDB持久化。
在這里我們主要關注Redis處理文件事件的模型。
參考《Redis的設計與實現》,Redis的文件事件處理模型是這種:
在這個模型中,Redisserver用主線程運行I/O多路復用程序、文件事件分派器以及事件處理器。並且。雖然多個文件事件可能會並發出現。Redisserver是順序處理各個文件事件的。
Redisserver主線程的運行流程在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模式中。
如有紕漏,懇請大家指出,我會一一加以勘正。謝謝!