詳解redis網(wǎng)絡IO模型
前言
"redis是單線程的" 這句話我們耳熟能詳。但它有一定的前提,redis整個服務不可能只用到一個線程完成所有工作,它還有持久化、key過期刪除、集群管理等其它模塊,redis會通過fork子進程或開啟額外的線程去處理。所謂的單線程是指從網(wǎng)絡連接(accept) -> 讀取請求內容(read) -> 執(zhí)行命令 -> 響應內容(write),這整個過程是由一個線程完成的,至于為什么redis要設計為單線程,主要有以下原因:
- 基于內存。redis命令操作主要都是基于內存,這已經(jīng)足夠快,不需要借助多線程。
- 高效的數(shù)據(jù)結構。redis底層提供了動態(tài)簡單動態(tài)字符串(SDS)、跳表(skiplist)、壓縮列表(ziplist)等數(shù)據(jù)結構來高效訪問數(shù)據(jù)。
- 保持簡單。引入多線程會使redis變得復雜,例如需要考慮多線程并發(fā)訪問資源競爭問題,數(shù)據(jù)結構也會變得復雜,hash就不能是單純的hash,需要像java一樣設計一個ConcurrentHashMap。還需要考慮線程切換帶來的性能損耗,基于第一點,當程序執(zhí)行已經(jīng)足夠快,多線程并不能帶來正面收益。
按照redis官方介紹,單個節(jié)點的redis qps可以達到10w+,已經(jīng)非常優(yōu)秀,如果有更高的要求,則可以通過部署主從、集群方式進一步提升。
單線程不是沒有缺點的,我們需要辯證的看待問題,不然所有的組件都可以使用redis替代了。首先是基于內存的操作有丟失數(shù)據(jù)的風險,盡管你可以配置appendfsync always每次將執(zhí)行請求通過aof文件持久化,但這也會帶來性能的下降。另外單線程的執(zhí)行意味著所有的請求都需要排隊執(zhí)行,如果有一個命令阻塞了,其它命令也都執(zhí)行不了,可以與之比較的是mysql,如果有一條sql語句執(zhí)行比較慢,只要它不完全拖垮數(shù)據(jù)庫,其它請求的sql語句還是可以執(zhí)行。最后,從上面可以看到從接收網(wǎng)絡連接到寫回響應內容,對于網(wǎng)絡請求部分的處理其實是可以多線程執(zhí)行來提升網(wǎng)絡IO效率的。
redis 6.0
從redis 6.0開始,網(wǎng)絡連接(accept) -> 讀取請求內容(read) -> 執(zhí)行命令 -> 響應內容(write) 這個過程中的“執(zhí)行命令”這個步驟依然保持單線程執(zhí)行,而對于網(wǎng)絡IO讀寫是多線程執(zhí)行的了。原因是這部分是網(wǎng)絡IO的解析、響應處理,已經(jīng)不是單純的內存操作,可以充分利用多核CPU的優(yōu)勢提升性能,對于這部分的性能需求其實一直都存在,社區(qū)也有KeyDB這樣的產(chǎn)品,其核心就是在redis的基礎上對多線程的支持,這多redis來說無疑是一種挑戰(zhàn),所有redis6.0開始在網(wǎng)絡IO處理支持多線程就顯得非常必要了。
我們知道redis客戶端連接是可以有很多個的,最多可以有maxclients參數(shù)配置的數(shù)量,默認是10000個,那么redis是如何高效處理這么多連接的呢?以及6.0和之前的版本是如何具體處理從接收連接到響應整個過程的,或者說redis線程模型是怎么樣的,清楚的了解這些有助于我們更好的學習redis,其中的知識在以后學習其它中間件也可以很好的借鑒。
linux IO模型
在學習redis網(wǎng)絡IO模型之前我們必須先了解一下linux的IO模型,以為redis也是基于操作系統(tǒng)去設計的。I/O是Input/Output的縮寫,是指操作系統(tǒng)與外部設備進行讀取、輸出的交互過程,外部設備可以是網(wǎng)卡、磁盤等。操作系統(tǒng)一般都分為內核和用戶空間兩部分,內核負責與底層硬件交互,用戶程序讀寫數(shù)據(jù)都需要經(jīng)過內核空間,也就是數(shù)據(jù)會不斷的在內核-用戶空間進行復制,不同的IO模型在這個復制過程用戶線程有不同的表現(xiàn),有的是阻塞,有的是非阻塞,有的是同步,有的是異步。
以linux為例,常見的IO模型有阻塞IO、非阻塞IO、IO多路復用、信號驅動IO、異步IO 5種,這次我們主要關注前3個,重點是IO多路復用,另外兩個在使用上有一些局限性,實際應用并不多。這5種IO模型我們在這一篇已經(jīng)有詳細的介紹,這里簡單再復習一遍。
以一個最簡單例子,現(xiàn)在有兩個客戶端需要連接、發(fā)送數(shù)據(jù)到我們的服務端,看下服務端在各種IO模型下是如何接收、讀取請求的。
阻塞IO(Blocking IO)

假設服務端只開啟一個線程處理請求,第一個請求到來,開始調用內核read函數(shù),然后就會發(fā)生阻塞,第二個請求到來時服務端將無法處理,只能等第一個請求讀取完成。這種方式的缺點很明顯,每次只能處理一個請求,無法發(fā)揮cpu多核優(yōu)勢,性能低下。

為了解決這個問題,我們可以引入多線程,這樣就可以同時處理多個請求了,但服務端可能同時有成千上萬的請求需要處理,隨之而來的是線程數(shù)膨脹,頻繁創(chuàng)建、銷毀線程帶來的性能影響,當然我們可以使用線程池,但服務能處理的總體數(shù)量就會受限于線程池線程數(shù)量。

非阻塞IO(NON-Blocking IO)

相比阻塞IO,非阻塞IO會立即返回,調用者不會阻塞,此時可以做一些其它事情,例如處理其它請求。但是非阻塞IO需要主動輪詢是否有數(shù)據(jù)需要處理,且這種輪詢需要從用戶態(tài)切換到內核態(tài)這,假如沒有數(shù)據(jù)產(chǎn)生就會有很多空輪詢,白白浪費cpu資源。
阻塞IO、非阻塞IO,要么需要開啟更多線程去處理IO,要么需要從用戶態(tài)切換到內核態(tài)輪詢IO事件,那么有沒有一種機制,用戶程序只需要將請求提交給內核,由內核用少量的線程去監(jiān)聽,有事件就通知用戶程序呢?這就是IO多路復用。
IO多路復用(IO Multiplexing)

IO多路復用機制是指一個線程處理多個IO流,多路是指網(wǎng)絡連接,復用指的是同一個線程。
如果簡單從圖上看IO多路復用相比阻塞IO似乎并沒有什么高明之處,假設服務只處理少量的連接,那么相比阻塞IO確實沒有太大的提升,但如果連接數(shù)非常多,差距就會立竿見影。
首先IO多路復用會提交一批需要監(jiān)聽的文件句柄(socket也是一種文件句柄)到內核,由內核開啟一個線程負責監(jiān)聽,把輪詢工作交給內核,當有事件發(fā)生時,由內核通知用戶程序。這不需要用戶程序開啟更多的線程去處理連接,也不需要用戶程序切換到內核態(tài)去輪詢,用一個線程就能處理大量網(wǎng)絡IO請求。
redis底層采用的就是IO多路復用模型,實際上基本所有中間件在處理網(wǎng)絡IO這一塊都會使用到IO多路復用,如kafka,rocketmq等,所以本次學習之后對其它中間件的理解也是很有幫助的。
select/poll/epoll
這三個函數(shù)是實現(xiàn)linux io多路復用的內核函數(shù),我們簡單了解下。
linux最開始提供的是select函數(shù),方法如下:
select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *timeout)
該方法需要傳遞3個集合,r,e,w分別表示讀、寫、異常事件集合。集合類型是bitmap,通過0/1表示該位置的fd(文件描述符,socket也是其中一種)是否關心對應讀、寫、異常事件。例如我們對fd為1和2的讀事件關心,r參數(shù)的第1,2個bit就設置為1。
用戶進程調用select函數(shù)將關心的事件傳遞給內核系統(tǒng),然后就會阻塞,直到傳遞的事件至少有一個發(fā)生時,方法調用會返回。內核返回時,同樣把發(fā)生的事件用這3個參數(shù)返回回來,如r參數(shù)第1個bit為1表示fd為1的發(fā)生讀事件,第2個bit依然為0,表示fd為2的沒有發(fā)生讀事件。用戶進程調用時傳遞關心的事件,內核返回時返回發(fā)生的事件。
select存在的問題:
- 大小有限制。為1024,由于每次select函數(shù)調用都需要在用戶空間和內核空間傳遞這些參數(shù),為了提升拷貝效率,linux限制最大為1024。
- 這3個集合有相應事件觸發(fā)時,會被內核修改,所以每次調用select方法都需要重新設置這3個集合的內容。
- 當有事件觸發(fā)select方法返回,需要遍歷集合才能找到就緒的文件描述符,例如傳1024個讀事件,只有一個讀事件發(fā)生,需要遍歷1024個才能找到這一個。
- 同樣在內核級別,每次需要遍歷集合查看有哪些事件發(fā)生,效率低下。
poll函數(shù)對select函數(shù)做了一些改進
poll(struct pollfd *fds, int nfds, int timeout)
struct pollfd {
int fd;
short events;
short revents;
}
poll函數(shù)需要傳一個pollfd結構數(shù)組,其中fd表示文件描述符,events表示關心的事件,revents表示發(fā)生的事件,當有事件發(fā)生時,內核通過這個參數(shù)返回回來。
poll相比select的改進:
- 傳不固定大小的數(shù)組,沒有1024的限制了(問題1)
- 將關心的事件和實際發(fā)生的事件分開,不需要每次都重新設置參數(shù)(問題2)。例如poll數(shù)組傳1024個fd和事件,實際只有一個事件發(fā)生,那么只需要重置一下這個fd的revent即可,而select需要重置1024個bit。
poll沒有解決select的問題3和4。另外,雖然poll沒有1024個大小的限制,但每次依然需要在用戶和內核空間傳輸這些內容,數(shù)量大時效率依然較低。
這幾個問題的根本實際很簡單,核心問題是select/poll方法對于內核來說是無狀態(tài)的,內核不會保存用戶調用傳遞的數(shù)據(jù),所以每次都是全量在用戶和內核空間來回拷貝,如果調用時傳給內核就保存起來,有新增文件描述符需要關注就再次調用增量添加,有事件觸發(fā)時就只返回對應的文件描述符,那么問題就迎刃而解了,這就是epoll做的事情。
epoll對應3個方法
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負責創(chuàng)建一個上下文,用于存儲數(shù)據(jù),底層是用紅黑樹,以后的操作就都在這個上下文上進行。
epoll_ctl負責將文件描述和所關心的事件注冊到上下文。
epoll_wait用于等待事件的發(fā)生,當有有事件觸發(fā),就只返回對應的文件描述符了。
reactor模式
前面我們介紹的IO多路復用是操作系統(tǒng)的底層實現(xiàn),借助IO多路復用我們實現(xiàn)了一個線程就可以處理大量網(wǎng)絡IO請求,那么接收到這些請求后該如何高效的響應,這就是reactor要關注的事情,reactor模式是基于事件的一種設計模式。在reactor中分為3中角色:
Reactor:負責監(jiān)聽和分發(fā)事件
Acceptor:負責處理連接事件
Handler:負責處理請求,讀取數(shù)據(jù),寫回數(shù)據(jù)
從線程角度出發(fā),reactor又可以分為單reactor單線程,單reactor多線程,多reactor多線程3種。
單reactor單線程

處理過程:reactor負責監(jiān)聽連接事件,當有連接到來時,通過acceptor處理連接,得到建立好的socket對象,reactor監(jiān)聽scoket對象的讀寫事件,讀寫事件觸發(fā)時,交由handler處理,handler負責讀取請求內容,處理請求內容,響應數(shù)據(jù)。
可以看到這種模式比較簡單,讀取請求數(shù)據(jù),處理請求內容,響應數(shù)據(jù)都是在一個線程內完成的,如果整個過程響應都比較快,可以獲得比較好的結果。缺點是請求都在一個線程內完成,無法發(fā)揮多核cpu的優(yōu)勢,如果處理請求內容這一塊比較慢,就會影響整體性能。
單reactor多線程

既然處理請求這里可能由性能問題,那么這里可以開啟一個線程池來處理,這就是單reactor多線程模式,請求連接、讀寫還是由主線程負責,處理請求內容交由線程池處理,相比之下,多線程模式可以利用cpu多核的優(yōu)勢。單仔細思考這里依然有性能優(yōu)化的點,就是對于請求的讀寫這里依然是在主線程完成的,如果這里也可以多線程,那效率就可以進一步提升。
多reactor多線程

多reactor多線程下,mainReactor接收到請求交由acceptor處理后,mainReactor不再讀取、寫回網(wǎng)絡數(shù)據(jù),直接將請求交給subReactor線程池處理,這樣讀取、寫回數(shù)據(jù)多個請求之間也可以并發(fā)執(zhí)行了。
redis網(wǎng)絡IO模型
redis網(wǎng)絡IO模型底層使用IO多路復用,通過reactor模式實現(xiàn)的,在redis 6.0以前屬于單reactor單線程模式。如圖:

在linux下,IO多路復用程序使用epoll實現(xiàn),負責監(jiān)聽服務端連接、socket的讀取、寫入事件,然后將事件丟到事件隊列,由事件分發(fā)器對事件進行分發(fā),事件分發(fā)器會根據(jù)事件類型,分發(fā)給對應的事件處理器進行處理。我們以一個get key簡單命令為例,一次完整的請求如下:

請求首先要建立TCP連接(TCP3次握手),過程如下:
redis服務啟動,主線程運行,監(jiān)聽指定的端口,將連接事件綁定命令應答處理器。
客戶端請求建立連接,連接事件觸發(fā),IO多路復用程序將連接事件丟入事件隊列,事件分發(fā)器將連接事件交由命令應答處理器處理。
命令應答處理器創(chuàng)建socket對象,將ae_readable事件和命令請求處理器關聯(lián),交由IO多路復用程序監(jiān)聽。
連接建立后,就開始執(zhí)行get key請求了。如下:

客戶端發(fā)送get key命令,socket接收到數(shù)據(jù)變成可讀,IO多路復用程序監(jiān)聽到可讀事件,將讀事件丟到事件隊列,由事件分發(fā)器分發(fā)給上一步綁定的命令請求處理器執(zhí)行。
命令請求處理器接收到數(shù)據(jù)后,對數(shù)據(jù)進行解析,執(zhí)行get命令,從內存查詢到key對應的數(shù)據(jù),并將ae_writeable寫事件和響應處理器關聯(lián)起來,交由IO多路復用程序監(jiān)聽。
客戶端準備好接收數(shù)據(jù),命令請求處理器產(chǎn)生ae_writeable事件,IO多路復用程序監(jiān)聽到寫事件,將寫事件丟到事件隊列,由事件分發(fā)器發(fā)給命令響應處理器進行處理。
命令響應處理器將數(shù)據(jù)寫回socket返回給客戶端。
reids 6.0以前網(wǎng)絡IO的讀寫和請求的處理都在一個線程完成,盡管redis在請求處理基于內存處理很快,不會稱為系統(tǒng)瓶頸,但隨著請求數(shù)的增加,網(wǎng)絡讀寫這一塊存在優(yōu)化空間,所以redis 6.0開始對網(wǎng)絡IO讀寫提供多線程支持。需要知道的是,redis 6.0對多線程的默認是不開啟的,可以通過 io-threads 4 參數(shù)開啟對網(wǎng)絡寫數(shù)據(jù)多線程支持,如果對于讀也要開啟多線程需要額外設置 io-threads-do-reads yes 參數(shù),該參數(shù)默認是no,因為redis認為對于讀開啟多線程幫助不大,但如果你通過壓測后發(fā)現(xiàn)有明顯幫助,則可以開啟。
redis 6.0多線程模型思想上類似單reactor多線程和多reactor多線程,但不完全一樣,這兩者handler對于邏輯處理這一塊都是使用線程池,而redis命令執(zhí)行依舊保持單線程。如下:

可以看到對于網(wǎng)絡的讀寫都是提交給線程池去執(zhí)行,充分利用了cpu多核優(yōu)勢,這樣主線程可以繼續(xù)處理其它請求了。
開啟多線程后多redis進行壓測結果可以參考這里,如下圖可以看到,對于簡單命令qps可以達到20w左右,相比單線程有一倍的提升,性能提升效果明顯,對于生產(chǎn)環(huán)境如果大家使用了新版本的redis,現(xiàn)在7.0也出來了,建議開啟多線程。

總結
本篇我們學習redis單線程具體是如何單線程以及在不同版本的區(qū)別,通過網(wǎng)絡IO模型知道IO多路復用如何用一個線程處理監(jiān)聽多個網(wǎng)絡請求,并詳細了解3種reactor模型,這是在IO多路復用基礎上的一種設計模式。最后學習了redis單線程、多線程版本是如何基于reactor模型處理請求。其中IO多路復用和reactor模型在許多中間件都有使用到,后續(xù)再接觸到就不陌生了。
歡迎關注我的github:https://github.com/jmilktea/jtea

浙公網(wǎng)安備 33010602011771號