Redis緩存IO模型的演進教程示例精講
前言
redis作為應用最廣泛的nosql數(shù)據(jù)庫之一,大大小小也經(jīng)歷過很多次升級。在4.0版本之前,單線程+IO多路復用使得redis的性能已經(jīng)達到一個非常高的高度了。作者也說過,之所以設計成單線程是因為redis的瓶頸不在cpu上,而且單線程也不需要考慮多線程帶來的鎖開銷問題。
然而隨著時間的推移,單線程越來越不滿足一些應用場景了,比如針對大key刪除會造成主線程阻塞的問題,redis4.0出了一個異步線程。
針對單線程由于無法利用多核cpu的特性而導致無法滿足更高的并發(fā),redis6.0也推出了多線程模式。所以說redis是單線程越來越不準確了。
事件模型
redis本身是個事件驅動程序,通過監(jiān)聽文件事件和時間事件來完成相應的功能。其中文件事件其實就是對socket的抽象,把一個個socket事件抽象成文件事件,redis基于Reactor模式開發(fā)了自己的網(wǎng)絡事件處理器。那么Reactor模式是什么?
通信
思考一個問題,我們的服務器是如何收到我們的數(shù)據(jù)的?首先雙方先要建立TCP連接,連接建立以后,就可以收發(fā)數(shù)據(jù)了。發(fā)送方向socket的緩沖區(qū)發(fā)送數(shù)據(jù),等待系統(tǒng)從緩沖區(qū)把數(shù)據(jù)取走,然后通過網(wǎng)卡把數(shù)據(jù)發(fā)出去,接收方的網(wǎng)卡在收到數(shù)據(jù)之后,會把數(shù)據(jù)copy到socket的緩沖區(qū),然后等待應用程序來取,這是大概的發(fā)收數(shù)據(jù)流程。
copy數(shù)據(jù)的開銷
因為涉及到系統(tǒng)調用,整個過程可以發(fā)現(xiàn)一份數(shù)據(jù)需要先從用戶態(tài)拷貝到內(nèi)核態(tài)的socket,然后又要從內(nèi)核態(tài)的socket拷貝到用戶態(tài)的進程中去,這就是數(shù)據(jù)拷貝的開銷。
數(shù)據(jù)怎么知道發(fā)給哪個socket
內(nèi)核維護的socket那么多,網(wǎng)卡過來的數(shù)據(jù)怎么知道投遞給哪個socket?
答案是端口,socket是一個四元組:
ip(client)+ port(client)+ip(server)+port(server)
注意千萬不要說一臺機器的理論最大并發(fā)是65535個,除了端口,還有ip,應該是端口數(shù)*ip數(shù)
這也是為什么一臺電腦可以同時打開多個軟件的原因。
socket的數(shù)據(jù)怎么通知程序來取
當數(shù)據(jù)已經(jīng)從網(wǎng)卡copy到了對應的socket緩沖區(qū)中,怎么通知程序來???假如socket數(shù)據(jù)還沒到達,這時程序在干嘛?這里其實涉及到cpu對進程的調度的問題。從cpu的角度來看,進程存在運行態(tài)、就緒態(tài)、阻塞態(tài)。
- 就緒態(tài):進程等待被執(zhí)行,資源都已經(jīng)準備好了,剩下的就等待cpu的調度了。
- 運行態(tài):正在運行的進程,cpu正在調度的進程。
- 阻塞態(tài):因為某些情況導致阻塞,不占有cpu,正在等待某些事件的完成。
當存在多個運行態(tài)的進程時,由于cpu的時間片技術,運行態(tài)的進程都會被cpu執(zhí)行一段時間,看著好似同時運行一樣,這就是所謂的并發(fā)。當我們創(chuàng)建一個socket連接時,它大概會這樣:
sockfd = socket(AF_INET, SOCK_STREAM, 0) connect(sockfd, ....) recv(sockfd, ...) doSometing()
操作系統(tǒng)會為每個socket建立一個fd句柄,這個fd就指向我們創(chuàng)建的socket對象,這個對象包含緩沖區(qū)、進程的等待隊列...。對于一個創(chuàng)建socket的進程來說,如果數(shù)據(jù)沒到達,那么他會卡在recv處,這個進程會掛在socket對象的等待隊列中,對cpu來說,這個進程就是阻塞的,它其實不占有cpu,它在等待數(shù)據(jù)的到來。
當數(shù)據(jù)到來時,網(wǎng)卡會告訴cpu,cpu執(zhí)行中斷程序,把網(wǎng)卡的數(shù)據(jù)copy到對應的socket的緩沖區(qū)中,然后喚醒等待隊列中的進程,把這個進程重新放回運行隊列中,當這個進程被cpu運行的時候,它就可以執(zhí)行最后的讀取操作了。這種模式有兩個問題:
recv只能接收一個fd,如果要recv多個fd怎么辦?
通過while循環(huán)效率稍低。
進程除了讀取數(shù)據(jù),還要處理接下里的邏輯,在數(shù)據(jù)沒到達時,進程處于阻塞態(tài),即使用了while循環(huán)來監(jiān)聽多個fd,其它的socket是不是因為其中一個recv阻塞,而導致整個進程的阻塞。
針對上述問題,于是Reactor模式和IO多路復用技術出現(xiàn)了。
Reactor
Reactor是一種高性能處理IO的模式,Reactor模式下主程序只負責監(jiān)聽文件描述符上是否有事件發(fā)生,這一點很重要,主程序并不處理文件描述符的讀寫。那么文件描述符的可讀可寫誰來做?答案是其他的工作程序,當某個socket發(fā)生可讀可寫的事件后,主程序會通知工作程序,真正從socket里面讀取數(shù)據(jù)和寫入數(shù)據(jù)的是工作程序。這種模式的好處就是就是主程序可以扛并發(fā),不阻塞,主程序非常的輕便。事件可以通過隊列的方式等待被工作程序執(zhí)行。通過Reactor模式,我們只需要把事件和事件對應的handler(callback func),注冊到Reactor中就行了,比如:
type Reactor interface{ RegisterHandler(WriteCallback func(), "writeEvent"); RegisterHandler(ReadCallback func(), "readEvent"); }
當一個客戶端向redis發(fā)起set key value的命令,這時候會向socket緩沖區(qū)寫入這樣的命令請求,當Reactor監(jiān)聽到對應的socket緩沖區(qū)有數(shù)據(jù)了,那么此時的socket是可讀的,Reactor就會觸發(fā)讀事件,通過事先注入的ReadCallback回調函數(shù)來完成命令的解析、命令的執(zhí)行。當socket的緩沖區(qū)有足夠的空間可以被寫,那么對應的Reactor就會產(chǎn)生可寫事件,此時就會執(zhí)行事先注入的WriteCallback回調函數(shù)。當發(fā)起的set key value執(zhí)行完畢后,此時工作程序會向socket緩沖區(qū)中寫入OK,最后客戶端會從socket緩沖區(qū)中取走寫入的OK。在redis中不管是ReadCallback,還是WriteCallback,它們都是一個線程完成的,如果它們同時到達那么也得排隊,這就是redis6.0之前的默認模式,也是最廣為流傳的單線程redis。
整個流程下來可以發(fā)現(xiàn)Reactor主程序非???,因為它不需要執(zhí)行真正的讀寫,剩下的都是工作程序干的事:IO的讀寫、命令的解析、命令的執(zhí)行、結果的返回..,這一點很重要。
IO多路復用器
通過上面我們知道Reactor它是一個抽象的理論,是一個模式,如何實現(xiàn)它?如何監(jiān)聽socket事件的到來?。最簡單的辦法就是輪詢,我們既然不知道socket事件什么時候到達,那么我們就一直來問內(nèi)核,假設現(xiàn)在有1w個socket連接,那么我們就得循環(huán)問內(nèi)核1w次,這個開銷明顯很大。
用戶態(tài)到內(nèi)核態(tài)的切換,涉及到上下文的切換(context),cpu需要保護現(xiàn)場,在進入內(nèi)核前需要保存寄存器的狀態(tài),在內(nèi)核返回后還需要從寄存器里恢復狀態(tài),這是個不小的開銷。
由于傳統(tǒng)的輪詢方法開銷過大,于是IO多路復用復用器出現(xiàn)了,IO多路復用器有select、poll、evport、kqueue、epoll。Redis在I/O多路復用程序的實現(xiàn)源碼中用#include宏定義了相應的規(guī)則,程序會在編譯時自動選擇系統(tǒng)中性能最高的I/O多路復用函數(shù)庫來作為Redis的I/O多路復用程序的底層實現(xiàn):
// Include the best multiplexing layer supported by this system. The following should be ordered by performances, descending. # ifdef HAVE_EVPORT # include "ae_evport.c" # else # ifdef HAVE_EPOLL # include "ae_epoll.c" # else # ifdef HAVE_KQUEUE # include "ae_kqueue.c" # else # include "ae_select.c" # endif # endif # endif
我們這里主要介紹兩種非常經(jīng)典的復用器select和epoll,select是IO多路復用器的初代,select是如何解決不停地從用戶態(tài)到內(nèi)核態(tài)的輪詢問題的?
select
既然每次輪詢很麻煩,那么select就把一批socket的fds集合一次性交給內(nèi)核,然后內(nèi)核自己遍歷fds,然后判斷每個fd的可讀可寫狀態(tài),當某個fd的狀態(tài)滿足時,由用戶自己判斷去獲取。
fds = []int{fd1,fd2,...} for { select (fds) for i:= 0; i < len(fds); i++{ if isReady(fds[i]) { read() } } }
select的缺點:當一個進程監(jiān)聽多個socket的時候,通過select會把內(nèi)核中所有的socket的等待隊列都加上本進程(多對一),這樣當其中一個socket有數(shù)據(jù)的時候,它就會把告訴cpu,同時把這個進程從阻塞態(tài)喚醒,等待被cpu的調度,同時會把進程從所有的socket的等待隊列中移除,當cpu運行這個進程的時候,進程因為本身傳進去了一批fds集合,我們并不知道哪個fd來數(shù)據(jù)了,所以只能都遍歷一次,這樣對于沒有數(shù)據(jù)到來的fd來說,就白白浪費了。由于每次select要遍歷socket集合,那么這個socket集合的數(shù)量過大就會影響整體效率,這原因也是select為什么支持最大1024個并發(fā)的。
epoll
如果有一種方法使得不用遍歷所有的socket,當某個socket的消息到來時,只需要觸發(fā)對應的socket fd,而不用盲目的輪詢,那效率是不是會更高。epoll的出現(xiàn)就是為了解決這個問題:
epfd = epoll_create() epoll_ctl(epfd, fd1, fd2...) for { epoll_wait() for fd := range fds { doSomething() } }
- 首先通過epoll_create創(chuàng)建一個epoll對象,它會返回一個fd句柄,和socket的句柄一樣,也是管理在fds集合下。
- 通過epoll_ctl,把需要監(jiān)聽的socket fd和epoll對象綁定。
- 通過epoll_wait來獲取有數(shù)據(jù)的socket fd,當沒有一個socket有數(shù)據(jù)的時候,那么此處會阻塞,有數(shù)據(jù)的話,那么就會返回有數(shù)據(jù)的fds集合。
epoll是怎么做到的?
首先內(nèi)核的socket不在和用戶的進程綁定了,而是和epoll綁定,這樣當socket的數(shù)據(jù)到來時,中斷程序就會給epoll的一個就緒對列添加對應socket fd,這個隊列里都是有數(shù)據(jù)的socket,然后和epoll關聯(lián)的進程也會被喚醒,當cpu運行進程的時候,就可以直接從epoll的就緒隊列中獲取有事件的socket,執(zhí)行接下來的讀。整個流程下來,可以發(fā)現(xiàn)用戶程序不用無腦遍歷,內(nèi)核也不用遍歷,通過中斷做到"誰有數(shù)據(jù)處理誰"的高效表現(xiàn)。
單線程到多線程的演進
單線程
結合Reactor的思想加上高性能epoll IO模式,redis開發(fā)出一套高性能的網(wǎng)絡IO架構:單線程的IO多路復用,IO多路復用器負責接受網(wǎng)絡IO事件,事件最終以隊列的方式排隊等待被處理,這是最原始的單線程模型,為什么使用單線程?因為單線程的redis已經(jīng)可以達到10w qps的負載(如果做一些復雜的集合操作,會降低),滿足絕大部分應用場景了,同時單線程不用考慮多線程帶來的鎖的問題,如果還沒達到你的要求,那么你也可以配置分片模式,讓不同的節(jié)點處理不同的sharding key,這樣你的redis server的負載能力就能隨著節(jié)點的增長而進一步線性增長。
異步線程
在單線程模式下有這樣一個問題,當執(zhí)行刪除某個很大的集合或者hash的時候會很耗時(不是連續(xù)內(nèi)存),那么單線程的表現(xiàn)就是其他還在排隊的命令就得等待。當?shù)却拿钤絹碓蕉啵敲床缓玫氖虑榫蜁l(fā)生。于是redis4.0針對大key刪除的情況,出了個異步線程。用unlink代替del去執(zhí)行刪除,這樣當我們unlink的時候,redis會檢測當刪除的key是否需要放到異步線程去執(zhí)行(比如集合的數(shù)量超過64個...),如果value足夠大,那么就會放到異步線程里去處理,不會影響主線程。同樣的還有flushall、flushdb都支持異步模式。此外redis還支持某些場景下是否需要異步線程來處理的模式(默認是關閉的):
lazyfree-lazy-eviction no lazyfree-lazy-expire no lazyfree-lazy-server-del no replica-lazy-flush no
lazyfree-lazy-eviction
:針對redis有設置內(nèi)存達到maxmemory的淘汰策略時,這時候會啟動異步刪除,此場景異步刪除的缺點就是如果刪除不及時,內(nèi)存不能得到及時釋放。
lazyfree-lazy-expire
:對于有ttl的key,在被redis清理的時候,不執(zhí)行同步刪除,加入異步線程來刪除。
replica-lazy-flush
:在slave節(jié)點加入進來的時候,會執(zhí)行flush清空自己的數(shù)據(jù),如果flush耗時較久,那么復制緩沖區(qū)堆積的數(shù)據(jù)就越多,后面slave同步數(shù)據(jù)較相對慢,開啟replica-lazy-flush后,slave的flush可以交由異步現(xiàn)成來處理,從而提高同步的速度。
lazyfree-lazy-server-del
:這個選項是針對一些指令,比如rename一個字段的時候執(zhí)行RENAME key newkey, 如果這時newkey是b存在的,對于rename來說它就要刪除這個newkey原來的老值,如果這個老值很大,那么就會造成阻塞,當開啟了這個選項時也會交給異步線程來操作,這樣就不會阻塞主線程了。
多線程
redis單線程+異步線程+分片已經(jīng)能滿足了絕大部分應用,然后沒有最好只有更好,redis在6.0還是推出了多線程模式。默認情況下,多線程模式是關閉的。
# io-threads 4 # work線程數(shù) # io-threads-do-reads no # 是否開啟
多線程的作用點?
通過上文我們知道當我們從一個socket中讀取數(shù)據(jù)的時候,需要從內(nèi)核copy到用戶空間,當我們往socket中寫數(shù)據(jù)的時候,需要從用戶空間copy到內(nèi)核。redis本身的計算還是很快的,慢的地方那么主要就是socket IO相關操作了。當我們的qps非常大的時候,單線程的redis無法發(fā)揮多核cpu的好處,那么通過開啟多個線程利用多核cpu來分擔IO操作是個不錯的選擇。
So for instance if you have a four cores boxes, try to use 2 or 3 I/O threads, if you have a 8 cores, try to use 6 threads.
開啟的話,官方建議對于一個4核的機器來說,開2-3個IO線程,如果有8核,那么開6個IO線程即可。
多線程的原理
需要注意的是redis的多線程僅僅只是處理socket IO讀寫是多個線程,真正去運行指令還是一個線程去執(zhí)行的。
- redis server通過EventLoop來監(jiān)聽客戶端的請求,當一個請求到來時,主線程并不會立馬解析執(zhí)行,而是把它放到全局讀隊列clients_pending_read中,并給每個client打上CLIENT_PENDING_READ標識。
- 然后主線程通過RR(Round-robin)策略把所有任務分配給I/O線程和主線程自己。
- 每個線程(包括主線程和子線程)根據(jù)分配到的任務,通過client的CLIENT_PENDING_READ標識只做請求參數(shù)的讀取和解析(這里并不執(zhí)行命令)。
- 主線程會忙輪詢等待所有的IO線程執(zhí)行完,每個IO線程都會維護一個本地的隊列io_threads_list和本地的原子計數(shù)器io_threads_pending,線程之間的任務是隔離的,不會重疊,當IO線程完成任務之后,io_threads_pending[index] = 0,當所有的io_threads_pending都是0的時候,就是任務執(zhí)行完畢之時。
- 當所有read執(zhí)行完畢之后,主線程通過遍歷clients_pending_read隊列,來執(zhí)行真正的exec動作。
- 在完成命令的讀取、解析、執(zhí)行之后,就要把結果響應給客戶端了。主線程會把需要響應的client加入到全局的clients_pending_write隊列中。
- 主線程遍歷clients_pending_write隊列,再通過RR(Round-robin)策略把所有任務分給I/O線程和主線程,讓它們將數(shù)據(jù)回寫給客戶端。
多線程模式下,每個IO線程負責處理自己的隊列,不會互相干擾,IO線程要么同時在讀,要么同時在寫,不會同時讀或寫。主線程也只會在所有的子線程的任務處理完畢之后,才會嘗試再次分配任務。同時最終的命令執(zhí)行還是由主線程自己來完成,整個過程不涉及到鎖。
以上就是Redis線程IO模型的演進教程示例精講的詳細內(nèi)容,更多關于Redis線程IO模型的演進的資料請關注腳本之家其它相關文章!
相關文章
基于Redis實現(xiàn)分布式鎖的方法(lua腳本版)
這篇文章主要介紹了基于Redis實現(xiàn)分布式鎖的方法(lua腳本版),本文給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2021-05-05Redis高并發(fā)情況下并發(fā)扣減庫存項目實戰(zhàn)
本文主要介紹了Redis高并發(fā)情況下并發(fā)扣減庫存項目實戰(zhàn),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2022-04-04