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模式中。如有紕漏,懇請大家指出,我會一一加以勘正。謝謝!
?
總結
以上是生活随笔為你收集整理的Redis:事件驱动(IO多路复用)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Redis:事务、管道、Lua脚本
- 下一篇: MYSQL:约束?添加约束?删除约束?以