您好,登錄后才能下訂單哦!
Redis緩存IO模型的示例分析,針對這個問題,這篇文章詳細介紹了相對應的分析和解答,希望可以幫助更多想解決這個問題的小伙伴找到更簡單易行的方法。
redis作為應用最廣泛的nosql數據庫之一,大大小小也經歷過很多次升級。在4.0版本之前,單線程+IO多路復用使得redis的性能已經達到一個非常高的高度了。作者也說過,之所以設計成單線程是因為redis的瓶頸不在cpu上,而且單線程也不需要考慮多線程帶來的鎖開銷問題。
然而隨著時間的推移,單線程越來越不滿足一些應用場景了,比如針對大key刪除會造成主線程阻塞的問題,redis4.0出了一個異步線程。
針對單線程由于無法利用多核cpu的特性而導致無法滿足更高的并發,redis6.0也推出了多線程模式。所以說redis是單線程越來越不準確了。
redis本身是個事件驅動程序,通過監聽文件事件和時間事件來完成相應的功能。其中文件事件其實就是對socket的抽象,把一個個socket事件抽象成文件事件,redis基于Reactor模式開發了自己的網絡事件處理器。那么Reactor模式是什么?
思考一個問題,我們的服務器是如何收到我們的數據的?首先雙方先要建立TCP連接,連接建立以后,就可以收發數據了。發送方向socket的緩沖區發送數據,等待系統從緩沖區把數據取走,然后通過網卡把數據發出去,接收方的網卡在收到數據之后,會把數據copy到socket的緩沖區,然后等待應用程序來取,這是大概的發收數據流程。
因為涉及到系統調用,整個過程可以發現一份數據需要先從用戶態拷貝到內核態的socket,然后又要從內核態的socket拷貝到用戶態的進程中去,這就是數據拷貝的開銷。
內核維護的socket那么多,網卡過來的數據怎么知道投遞給哪個socket?
答案是端口,socket是一個四元組:
ip(client)+ port(client)+ip(server)+port(server)
注意千萬不要說一臺機器的理論最大并發是65535個,除了端口,還有ip,應該是端口數*ip數
這也是為什么一臺電腦可以同時打開多個軟件的原因。
當數據已經從網卡copy到了對應的socket緩沖區中,怎么通知程序來取?假如socket數據還沒到達,這時程序在干嘛?這里其實涉及到cpu對進程的調度的問題。從cpu的角度來看,進程存在運行態、就緒態、阻塞態。
就緒態:進程等待被執行,資源都已經準備好了,剩下的就等待cpu的調度了。
運行態:正在運行的進程,cpu正在調度的進程。
阻塞態:因為某些情況導致阻塞,不占有cpu,正在等待某些事件的完成。
當存在多個運行態的進程時,由于cpu的時間片技術,運行態的進程都會被cpu執行一段時間,看著好似同時運行一樣,這就是所謂的并發。當我們創建一個socket連接時,它大概會這樣:
sockfd = socket(AF_INET, SOCK_STREAM, 0) connect(sockfd, ....) recv(sockfd, ...) doSometing()
操作系統會為每個socket建立一個fd句柄,這個fd就指向我們創建的socket對象,這個對象包含緩沖區、進程的等待隊列...。對于一個創建socket的進程來說,如果數據沒到達,那么他會卡在recv處,這個進程會掛在socket對象的等待隊列中,對cpu來說,這個進程就是阻塞的,它其實不占有cpu,它在等待數據的到來。
當數據到來時,網卡會告訴cpu,cpu執行中斷程序,把網卡的數據copy到對應的socket的緩沖區中,然后喚醒等待隊列中的進程,把這個進程重新放回運行隊列中,當這個進程被cpu運行的時候,它就可以執行最后的讀取操作了。這種模式有兩個問題:
recv只能接收一個fd,如果要recv多個fd怎么辦?
通過while循環效率稍低。
進程除了讀取數據,還要處理接下里的邏輯,在數據沒到達時,進程處于阻塞態,即使用了while循環來監聽多個fd,其它的socket是不是因為其中一個recv阻塞,而導致整個進程的阻塞。
針對上述問題,于是Reactor模式和IO多路復用技術出現了。
Reactor是一種高性能處理IO的模式,Reactor模式下主程序只負責監聽文件描述符上是否有事件發生,這一點很重要,主程序并不處理文件描述符的讀寫。那么文件描述符的可讀可寫誰來做?答案是其他的工作程序,當某個socket發生可讀可寫的事件后,主程序會通知工作程序,真正從socket里面讀取數據和寫入數據的是工作程序。這種模式的好處就是就是主程序可以扛并發,不阻塞,主程序非常的輕便。事件可以通過隊列的方式等待被工作程序執行。通過Reactor模式,我們只需要把事件和事件對應的handler(callback func),注冊到Reactor中就行了,比如:
type Reactor interface{ RegisterHandler(WriteCallback func(), "writeEvent"); RegisterHandler(ReadCallback func(), "readEvent"); }
當一個客戶端向redis發起set key value的命令,這時候會向socket緩沖區寫入這樣的命令請求,當Reactor監聽到對應的socket緩沖區有數據了,那么此時的socket是可讀的,Reactor就會觸發讀事件,通過事先注入的ReadCallback回調函數來完成命令的解析、命令的執行。當socket的緩沖區有足夠的空間可以被寫,那么對應的Reactor就會產生可寫事件,此時就會執行事先注入的WriteCallback回調函數。當發起的set key value執行完畢后,此時工作程序會向socket緩沖區中寫入OK,最后客戶端會從socket緩沖區中取走寫入的OK。在redis中不管是ReadCallback,還是WriteCallback,它們都是一個線程完成的,如果它們同時到達那么也得排隊,這就是redis6.0之前的默認模式,也是最廣為流傳的單線程redis。
整個流程下來可以發現Reactor主程序非常快,因為它不需要執行真正的讀寫,剩下的都是工作程序干的事:IO的讀寫、命令的解析、命令的執行、結果的返回..,這一點很重要。
通過上面我們知道Reactor它是一個抽象的理論,是一個模式,如何實現它?如何監聽socket事件的到來?。最簡單的辦法就是輪詢,我們既然不知道socket事件什么時候到達,那么我們就一直來問內核,假設現在有1w個socket連接,那么我們就得循環問內核1w次,這個開銷明顯很大。
用戶態到內核態的切換,涉及到上下文的切換(context),cpu需要保護現場,在進入內核前需要保存寄存器的狀態,在內核返回后還需要從寄存器里恢復狀態,這是個不小的開銷。
由于傳統的輪詢方法開銷過大,于是IO多路復用復用器出現了,IO多路復用器有select、poll、evport、kqueue、epoll。Redis在I/O多路復用程序的實現源碼中用#include宏定義了相應的規則,程序會在編譯時自動選擇系統中性能最高的I/O多路復用函數庫來作為Redis的I/O多路復用程序的底層實現:
// 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
我們這里主要介紹兩種非常經典的復用器select和epoll,select是IO多路復用器的初代,select是如何解決不停地從用戶態到內核態的輪詢問題的?
既然每次輪詢很麻煩,那么select就把一批socket的fds集合一次性交給內核,然后內核自己遍歷fds,然后判斷每個fd的可讀可寫狀態,當某個fd的狀態滿足時,由用戶自己判斷去獲取。
fds = []int{fd1,fd2,...} for { select (fds) for i:= 0; i < len(fds); i++{ if isReady(fds[i]) { read() } } }
select的缺點:當一個進程監聽多個socket的時候,通過select會把內核中所有的socket的等待隊列都加上本進程(多對一),這樣當其中一個socket有數據的時候,它就會把告訴cpu,同時把這個進程從阻塞態喚醒,等待被cpu的調度,同時會把進程從所有的socket的等待隊列中移除,當cpu運行這個進程的時候,進程因為本身傳進去了一批fds集合,我們并不知道哪個fd來數據了,所以只能都遍歷一次,這樣對于沒有數據到來的fd來說,就白白浪費了。由于每次select要遍歷socket集合,那么這個socket集合的數量過大就會影響整體效率,這原因也是select為什么支持最大1024個并發的。
如果有一種方法使得不用遍歷所有的socket,當某個socket的消息到來時,只需要觸發對應的socket fd,而不用盲目的輪詢,那效率是不是會更高。epoll的出現就是為了解決這個問題:
epfd = epoll_create() epoll_ctl(epfd, fd1, fd2...) for { epoll_wait() for fd := range fds { doSomething() } }
首先通過epoll_create創建一個epoll對象,它會返回一個fd句柄,和socket的句柄一樣,也是管理在fds集合下。
通過epoll_ctl,把需要監聽的socket fd和epoll對象綁定。
通過epoll_wait來獲取有數據的socket fd,當沒有一個socket有數據的時候,那么此處會阻塞,有數據的話,那么就會返回有數據的fds集合。
首先內核的socket不在和用戶的進程綁定了,而是和epoll綁定,這樣當socket的數據到來時,中斷程序就會給epoll的一個就緒對列添加對應socket fd,這個隊列里都是有數據的socket,然后和epoll關聯的進程也會被喚醒,當cpu運行進程的時候,就可以直接從epoll的就緒隊列中獲取有事件的socket,執行接下來的讀。整個流程下來,可以發現用戶程序不用無腦遍歷,內核也不用遍歷,通過中斷做到"誰有數據處理誰"的高效表現。
結合Reactor的思想加上高性能epoll IO模式,redis開發出一套高性能的網絡IO架構:單線程的IO多路復用,IO多路復用器負責接受網絡IO事件,事件最終以隊列的方式排隊等待被處理,這是最原始的單線程模型,為什么使用單線程?因為單線程的redis已經可以達到10w qps的負載(如果做一些復雜的集合操作,會降低),滿足絕大部分應用場景了,同時單線程不用考慮多線程帶來的鎖的問題,如果還沒達到你的要求,那么你也可以配置分片模式,讓不同的節點處理不同的sharding key,這樣你的redis server的負載能力就能隨著節點的增長而進一步線性增長。
在單線程模式下有這樣一個問題,當執行刪除某個很大的集合或者hash的時候會很耗時(不是連續內存),那么單線程的表現就是其他還在排隊的命令就得等待。當等待的命令越來越多,那么不好的事情就會發生。于是redis4.0針對大key刪除的情況,出了個異步線程。用unlink代替del去執行刪除,這樣當我們unlink的時候,redis會檢測當刪除的key是否需要放到異步線程去執行(比如集合的數量超過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有設置內存達到maxmemory的淘汰策略時,這時候會啟動異步刪除,此場景異步刪除的缺點就是如果刪除不及時,內存不能得到及時釋放。
lazyfree-lazy-expire
:對于有ttl的key,在被redis清理的時候,不執行同步刪除,加入異步線程來刪除。
replica-lazy-flush
:在slave節點加入進來的時候,會執行flush清空自己的數據,如果flush耗時較久,那么復制緩沖區堆積的數據就越多,后面slave同步數據較相對慢,開啟replica-lazy-flush后,slave的flush可以交由異步現成來處理,從而提高同步的速度。
lazyfree-lazy-server-del
:這個選項是針對一些指令,比如rename一個字段的時候執行RENAME key newkey, 如果這時newkey是b存在的,對于rename來說它就要刪除這個newkey原來的老值,如果這個老值很大,那么就會造成阻塞,當開啟了這個選項時也會交給異步線程來操作,這樣就不會阻塞主線程了。
redis單線程+異步線程+分片已經能滿足了絕大部分應用,然后沒有最好只有更好,redis在6.0還是推出了多線程模式。默認情況下,多線程模式是關閉的。
# io-threads 4 # work線程數 # io-threads-do-reads no # 是否開啟
通過上文我們知道當我們從一個socket中讀取數據的時候,需要從內核copy到用戶空間,當我們往socket中寫數據的時候,需要從用戶空間copy到內核。redis本身的計算還是很快的,慢的地方那么主要就是socket IO相關操作了。當我們的qps非常大的時候,單線程的redis無法發揮多核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讀寫是多個線程,真正去運行指令還是一個線程去執行的。
redis server通過EventLoop來監聽客戶端的請求,當一個請求到來時,主線程并不會立馬解析執行,而是把它放到全局讀隊列clients_pending_read中,并給每個client打上CLIENT_PENDING_READ標識。
然后主線程通過RR(Round-robin)策略把所有任務分配給I/O線程和主線程自己。
每個線程(包括主線程和子線程)根據分配到的任務,通過client的CLIENT_PENDING_READ標識只做請求參數的讀取和解析(這里并不執行命令)。
主線程會忙輪詢等待所有的IO線程執行完,每個IO線程都會維護一個本地的隊列io_threads_list和本地的原子計數器io_threads_pending,線程之間的任務是隔離的,不會重疊,當IO線程完成任務之后,io_threads_pending[index] = 0,當所有的io_threads_pending都是0的時候,就是任務執行完畢之時。
當所有read執行完畢之后,主線程通過遍歷clients_pending_read隊列,來執行真正的exec動作。
在完成命令的讀取、解析、執行之后,就要把結果響應給客戶端了。主線程會把需要響應的client加入到全局的clients_pending_write隊列中。
主線程遍歷clients_pending_write隊列,再通過RR(Round-robin)策略把所有任務分給I/O線程和主線程,讓它們將數據回寫給客戶端。
多線程模式下,每個IO線程負責處理自己的隊列,不會互相干擾,IO線程要么同時在讀,要么同時在寫,不會同時讀或寫。主線程也只會在所有的子線程的任務處理完畢之后,才會嘗試再次分配任務。同時最終的命令執行還是由主線程自己來完成,整個過程不涉及到鎖。
關于Redis緩存IO模型的示例分析問題的解答就分享到這里了,希望以上內容可以對大家有一定的幫助,如果你還有很多疑惑沒有解開,可以關注億速云行業資訊頻道了解更多相關知識。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。