【圖解源碼】Redis究竟是不是單線程的?

本文全部代碼都將以僞代碼或者圖片的形式展現,請各位觀衆老爺放心閱讀(C讀起來仍是有一丟丟費勁哈)javascript

本文基於的 redis5.0 寫做前端

開始以前

有一說一,在看Redis源碼以前,個人C語言水平只能寫寫大學生的課設以及刷OJ題目。在開始閱讀代碼以前,一度擔憂本身會中途放棄,可是,年初立的目標不能再放棄了(已經失敗一個了,一週一更實現不了),便堅持下來了。所幸,學有所得在此分享給你們。java

從小到大我都是一個膽小而又害羞的男孩子,因此,幫忙點個讚唄程序員

編程思惟的精髓

若是我問你編程思惟的精髓是什麼?今天豐富的軟件生態大廈又是得益於什麼思想而落成的?(歡迎你們在評論區分享本身的觀點)面試

就我而言,是抽象和封裝這兩個思想所構成的。舉例來講不管是Java系的JVM虛擬機仍是Node.js的V8引擎本質上都是對操做系統硬件資源的進一步抽象封裝,而且提供了統一的API接口,使得在該引擎上開發的應用能夠在不一樣平臺上運行。redis

問題來了,這跟redis有什麼關係?redis不是用C語言開發的嗎?C語言不是面向過程的嗎?怎麼抽象怎麼封裝?算法

有種觀點很正常的, 大部分人的經歷和我同樣,頂多用C語言來寫五子棋、刷刷算法題而已,少有接觸到生產級別的代碼。回想一下惟一和麪向對象有關係的就是結構體,沒錯,已經很接近答案了。數據庫

爲何要討論抽象和封裝呢?就像懸疑電影中歷來不會出現無用的角色同樣(安利一下《誤殺》)。redis的目標是在多種類型的操做系統上運行,現在操做系統廠商大都各自爲戰,就地球而言目前還沒出現一個統一天下的操做系統,可能有些小夥伴已經明白我要問什麼, 那就是redis是如何作到一套代碼到處編譯的?雖然不是本文的重點,但用C語言實現相似面向對象的功能仍是給我幼小的心靈形成了震撼,至因而如何實現的下文會提到,讓咱們迴歸重點編程

Redis/IO 模型

事件驅動與I/O多路複用

在開始以前,有必要問一下本身真的瞭解事件驅動機制嗎?windows

不知各位讀者大爺是否有過在windows系統用C++開發windows應用程序的經驗,一個win32程序一般會在一個while循環裏面不斷取來自用戶大爺產生的事件,好比正在閱讀本文的讀者大爺,不管是處於職業習慣按下F12,亦或者滑動鼠標滑輪都會產生一個事件,一般來講操做系統會提供相應API函數以便咱們能夠程序能夠獲取到用戶行爲所產生的事件。

其僞代碼以下,想要體驗一下具體代碼的能夠點擊your-first-windows-program

while(true){
    //獲取事件
    let msg = getMessage(); 
    //翻譯消息
    translateMessage(msg);
    //分發消息
    dispatchMessage(msg);
}
複製代碼

如上代碼所示,能夠發現事件驅動機制程序的特色以下

  • 通常都會有一個容器用於存放產生的事件
  • 一個事件循環
  • 有一種獲取事件的方法
  • 獲取到事件以後須要進行處理(不理它也能夠視爲一種處理方式)

瞭解完事件驅動機制以後,咱們再來看看I/O多路複用,這但是大熱門哈!

爲了對比區別,咱先回顧經典C/S結構程序。代碼以下所示:

while(true){
    Socket client = server.accept();
    ClientThread thread = new ClientThread(client);
    thread.start();
}
複製代碼

用一個例子來講就是,孫悟空打妖怪,每碰見一個妖怪都會建立一個分身去和妖怪玩,而孫悟空本人就負責不斷地拔毛建立分身以及保護唐僧。

那麼,問題來了,假設孫悟空是程序員的話,唐僧該怎麼辦?

畢竟唐僧是主角,掛了還怎麼玩,所以我們能夠隨便向某位大仙要一個「鎮妖列表」的法寶,該法寶會將全部的小妖怪存入虛空,大師兄每次均可以從該法寶中獲取感興趣的妖怪(你說猴子對啥妖怪感興趣呢?)與之對線。

以上就是I/O多路複用模型。開發操做系統大佬們早就爲咱們提供了API能夠獲取本身感興趣的事件,再結合事件驅動模型就是I/O多路複用了。

一個比較嚴格,且學術的描述以下:

I/O 多路複用。在這種形式的併發編程中,應用程序在一個進程的上下文中顯式地調度它們本身地邏輯流。邏輯流別模型化狀態機,數據到達文件描述符後,主程序顯示地從一個狀態轉換到另外一個狀態由於程序是一個單獨地進程,因此全部地流都共享一個地址空間。(《CSAPP》)

換句話說咱們能夠用一個狀態圖來描述I/O多路複用程序。

問題又來了? I/O多路複用非得是單線程的嗎?

肯定以及確定的回答:不是

I/O模型

不難看出redis使用了Reactor的設計模式,換句話說就是使用操做系統提供給咱們的API,使得咱們不須要再爲每一個客戶端都建立新的線程,也就是說redis採用的是單線程的Reactor設計模式,可是那個I/O線程是什麼鬼?

所謂I/O線程,就是負責讀取來自客戶端數據和將響應數據輸出給客戶端的線程。

爲何會有I/O線程以及I/O線程何時會啓動?

首先須要明確的一點是redis雖然能夠採用輪詢的方式獲取數據,可是讀取客戶端數據和往客戶端輸出數據時所調用的函數仍然會產生阻塞(阻塞時間通常超級短,短到你沒法察覺),可是,凡事總有個可是,假設你在一家很是窮的公司,只有一臺redis服務器(且數據不少),某天一個臨時工往redis裏面塞了一部512MB的學習資料set studyresouces 學習資料, 若是繼續採用單線程的模式,不難想象整個redis服務都將被短暫阻塞住。因此此時若是咱們若是有多個I/O線程,核心業務線程能夠將輸入輸出的外包給I/O線程來完成,至於何時啓動I/O線程,咱下邊聊。

所以一個比較合理定義以下

redis負責操做數據的線程確實只有一個,可是負責I/O線程並不僅有一個, 此外redis在執行序列化操做的時候還會開啓線程。

問題又又來了, 爲何redis負責操做數據的線程只有一個?

  • redis的全部操做的數據都是內存數據

  • redis數據操做通常在常數時間內能夠完成

  • 單線程數據操做能夠避免多線程操做所帶來的數據安全性問題(不用加鎖)

主流程

正如你所看到的, redis核心業務線程就一循環在不斷的調用的beforeSleep以及processEvents方法。

aeProcessEvents

首先來看一下aeProcessEvents, 其代碼以下所示。

因爲redis有定時任務須要執行, 若是在輪詢事件時進入長時間的阻塞狀態(redis稱之爲sleep),將致使定時任務長時間沒法獲得執行,所以有必要計算處最大的等待時間。

aeApiPoll() 會使線程進入阻塞狀態,直到有I/O事件產生, 能夠傳入最大阻塞時間,若是超過這個時間以後即便沒有I/O事件也會當即返回

在輪詢到事件以後, 並無當即處理I/O事件,而是執行鉤子函數afterSleep, 至於afterSleep作了什麼,咱下邊聊。

以後即是處理aeApiPoll輪詢到的事件了。

若是你閱讀了代碼不難發現有一個奇怪的變量invert,此變量與配置參數相關AE_BARRIER, 決定了讀寫函數執行順序。

鏈接到redis客戶端(如redis-cli)的讀寫事件處理器都會指向connection.c中的connSocketEventHandlerconnSocketEventHandler,此函數會根據狀況決定調用讀寫事件調用的順序。(invert參數以及輪詢到事件類型都會傳給此函數)

觀察變量fired咱們得出如下結論在一次循環中redis不會同時調用讀寫事件處理函數。且若是

  • AE_BARRIER = 1(即invenrt = true) redis會先處理寫事件,再處理讀事件
  • AE_BARRIER = 0(即invenrt = false) redis會先處理讀事件,再處理寫事件

問題又又又來了 AE_BARRIER此參數到底有什麼用呢?

要想搞清楚這個問題,先搞清楚一個問題什麼叫落盤?

假設正在幼兒園入園考試的你遇到了計算題1+1=,你已經想出了答案是2,可是因爲時間緊迫你沒有寫上去,被人扣了10分與夢想的幼兒園失之交臂。

可見,你想出來了是一回事,但你有沒有寫答案塗答題卡又是另一回事。

類比到操做系統中,也會有這狀況,你覺得你調用了write函數就把數據保存到硬盤中了,實際上數據會在內存中停留一會,等待一個合適的時機將數據保存到硬盤中,假設數據在內存中停留的期間忽然斷電,那數據豈不是就沒了嗎?

爲了不這種狀況,操做系統(Linux)提供了fsync函數來確保數據寫到硬盤上,即確保數據落盤,調用此函數時會產生阻塞,直到數據成功寫到硬盤上。

基於以上狀況的考慮若是redis配置了appednfsync=always, 而且開啓了AOF(AOF是redis數據的一種持久化機制),且知足必定條件的狀況就會使invert=true生效。

什麼條件的狀況下呢?

首先咱們明確一點,通常狀況下輸出數據的地方並非在寫處理器中輸出的,而是在beforeSleep中響應數據輸出給客戶端的。咱們來觀察一下輸出數據時的調用棧驗證一下。

原始截圖以下所示

此外,在通常狀況下,接收到來自客戶端的鏈接以後, redis只在此鏈接上註冊的感興趣事件只有讀事件,只有當安裝寫處理器時纔會註冊對寫事件感興趣。

如今,小朋友你是否有不少問號?我也是。問題是既然在beforeSleep中都已經把數據輸出去了,爲啥還要反置讀寫的數據順序,先寫再讀?

排除全部可能性,剩下的即便再不合理也是真相了。

只有一個可能 -> 數據沒輸出完。😂

觀察如下代碼, 位置在networking.c1373行處

不難看出,在開啓appednfsync=always以及客戶端仍然有待輸出數據的狀況,會爲此客戶端安裝一個寫處理器,而且將此客戶端的invert置爲true。在此狀況下,發生的事件以下所示

  • 1.讀取來自客戶端的命令並處理(aeProcessEvents)
  • 2.執行AOF操做(beforeSleep)
  • 3.輸出響應數據給客戶端,發現數據還有剩餘且appednfsync=always,開啓AE_BARRIER(即invert=true),並安裝寫處理器(beforeSleep)
  • 4.調用寫處理器輸出數據(aeProcessEvents)
  • 5.已輸出完數據移除寫處理器(aeProcessEvents)

通常來講在redis客戶端發出指令以後會阻塞等待來自服務端的響應,在此期間,客戶端不會出其餘數據操做指令(僅限於RESP2協議及如下的協議,採用RESP3協議的客戶端能夠這樣作)

移除寫處理器的代碼在writeToClient中,咱下邊再聊

有必要說明如下一點,以免誤解。以前提到過processReadEvent以及processWriteEvent都指向了connSocketEventHandler。可是,此處connSetWriteHandlerWithBarrier設置的寫處理器sendReplyToClient並非將processWriteEvent指向sendReplyToClient,而是註冊connSocketEventHandler中所調用的寫處理器。看一下代碼可能會更直觀一點。

代碼位於connection.c中

beforeSleep 之睡覺以前你在幹嗎?

在以前的aeMain的代碼能夠看到,在每次進入事件循環時都會調用一下beforeSleep,讓咱們康康redis在睡覺以前都作了啥。

總得來講按照順序來講beforeSleep完成了如下工做:

  • 處理採用安全傳輸層協議(TLS)的客戶端中待處理數據
  • 若是了開啓了集羣功能,則調用clusterBeforeSleep
  • 執行一次快速掃描對數據庫清除已過時的鍵
  • 處理集羣相關的任務
  • 處理因執行阻塞命令陷入阻塞狀態的客戶端(如執行subscribe命令的客戶端)
  • 執行AOF操做
  • 檢查是否須要開啓I/O線程並將數據輸出給客戶端(handleClientsWithPendingWritesUsingThreads)
  • 異步關閉須要關閉的客戶端

beforeSleep函數中作了不少事情,但就咱們所關心的I/O模型來講,咱們只關心數據的流向,所以重點討論一下handleClientsWithPendingWritesUsingThreads

簡化過的handleClientsWithPendingWritesUsingThreads的代碼以下所示

不難看出主線程給I/O線程分配任務的方式主要是經過任務隊列以及標誌位數組給線程分配任務,而且經過ioThreadOp給線程指示當前任務的類型即IO_THREADS_OP_WRITE執行寫任務或者IO_THREADS_OP_READ執行讀任務。

那麼開啓多線程I/O的任務是什麼呢?能夠看一下stopThreaedIOIfNeed函數。

能夠看出若是知足待處理的任務數量 >= I/O線程數 *2 ,則redis 會開啓多線程IO

不然就會中止I/O線程讓其進入阻塞狀態

根據以上代碼,不可貴出如下結構

問題再一次來,主線程是如何控制I/O線程的狀態?這一個我們須要補充一點點的多線程知識,我們下邊再聊,先來看看睡醒以後redis都幹了啥。

afterSleep 睡醒以後作什麼?

redis睡醒以後(從aeApiPoll返回)就作了一件事情,調用handleClientsWithPendingReadsUsingThreads此函數與上文所描述的handleClientsWithPendingWritesUsingThreads相似只不過ioThreadOp變成了IO_THREADS_OP_READ即I/O線程只處理讀事件。

processTimeEvents 定時任務

processTimeEvents介個兄弟就一循環,遍歷定時任務隊列,若是達到時間就拿出來執行一下,這些任務通常不會太複雜,所以咱們主要關注一下都有哪些定時任務。

註冊定時任務能夠經過aeCreateTimeEvent向事件循環中註冊定時任務

通過定位,你會發現最終只註冊了一個定時任務serverCron(此函數位於server.c)

其在事件循環中註冊定時任務的代碼以下所示,刻意看出serverCron被設置爲每1毫秒觸發一次。

if (aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) {
        serverPanic("Can't create event loop timers.");
        exit(1);
    }
複製代碼

此外,咱們能夠經過以下兩個參數控制serverCron的行爲。

  • server.hz 控制serverCron函數的執行頻率,默認爲10(1秒內執行10次),最大爲500
  • server.cronloops serverCron函數的執行次數

舉個例子,redis中有定時統計數據庫使用狀況功能,其週期爲5000,那麼redis是如何判斷何時該調用它的呢?

觀察如下函數

function shouldRunWithPeriod(ms){
    return ms <= 1000/server.hz || !(server.cronloops % (ms/(1000/server.hz)));
}
複製代碼

能夠得出當server.cronloops = 50 * n(n爲整數), 也就是當server.cronloops爲50的倍數時,會執行統計功能,還記得我們剛剛說過server.hz能夠控制serverCron的執行頻率嗎, 且server.hz = 10 即每100毫秒執行一次serverCron, 不可貴出如下結論而server.cronloops = 5050 * 100 == 5000

整理出來的定時任務以下表所示。(server.hz = 10)

執行頻率指server.cron調用多少次之時執行此任務, *表示每次都執行

任務類型 執行頻率 備註
統計內存使用狀況 *
處理來自操做系統的退出信號 * 非定時任務
統計全部數據庫字典的使用狀況 50*n(n=1,2,...) 每一個數據庫實際上都是一個字典
打印客戶端以及從節點信息 50*n(n=1,2,...) 須要redis以哨兵模式運行
處理鏈接超時客戶端以及客戶端的緩衝區(clientCron) * 非定時任務
數據庫字典漸進式擴容/縮容 * 非定時任務
AOF相關處理 * 非定時任務
若是最近一次AOF寫入失敗,則開啓fsync機制寫入AOF文件 10*n(n=1,2,...)
執行主從複製的定時任務 * 須要開啓主從複製模式
哨兵的定時任務 * 不在本文討論範圍
若是待處理I/O任務太少就中止I/O線程 * 非定時任務,僧多粥少,你說咋辦嗎
執行BGSAVE(序列化數據庫,與AOF不一樣) 須要根據配置文件的值,來定時執行
server.cronloops++

I/O線程的實現

經過上文,相信給位讀者大爺都瞭解了redis主線程經過給每一個線程分配一個任務隊列、線程狀態標誌位以及共享一個任務類型來控制I/O線程行爲,那麼redis是如何控制線程進入阻塞狀態,以免其空轉而消耗系統資源呢?

話很少說咱上僞代碼瞅一瞅。

能夠看出I/O線程的代碼並不複雜,但有些代碼着實讓人有些迷惑。

好比,咱們能夠看到線程會執行1000000空負載循環, 僅僅爲了判斷線程標誌位是否不爲0

爲何要這樣子設計呢?有併發編程經驗的同窗不難看出,這種行爲其實就是自旋,雖然自旋會消耗必定的資源(但不會太多), 若是線程自旋期間分配到任務,那就不用進入阻塞狀態,再從阻塞狀態恢復過來了,而且自旋的成本小於線程進入阻塞狀態再從阻塞狀態恢復過來的成本。

繼續閱讀代碼我們能夠發現,再獲取到互斥鎖又當即釋放了,這是爲何呢? 其實這是給主線程一個加鎖的機會,畢竟主線程會經過加鎖來讓線程進入阻塞狀態。舉個例子

時間 I/O線程 主線程
t1 加鎖A 運行狀態
t2 嘗試加鎖A而進入阻塞狀態
t3 釋放鎖A 阻塞
t4 進行下一次循環 加鎖A
t5 自旋中 運行狀態
t6 嘗試加鎖A而進入阻塞狀態 運行狀態
t7 阻塞 運行狀態
t8 阻塞 運行狀態

輸出響應數據的時候發生了什麼

閱讀上文以後,不可貴出如下結論, redis可能不會一次性輸出全部響應數據, 而是選擇輸出一部分數據,而後繼續作其餘事情呢?這麼作的緣由,無外乎redis的核心業務線程只有一個,所以不能讓其餘客戶端等過久,若是有個臨時工在終端上執行keys *, 那咱是否是就不用玩了?

更具體一點,我們看看writeToClient中做者寫的註釋以及代碼來分析一下何時會發生不一次性輸出全部數據的狀況。

  • totwritten 指當前已經輸出的數據量,NEXT_MAX_WRITES_PER_EVNET的值爲64KB64*1024
  • server.maxmemory指redis所可使用的最大內存數量,默認值爲0即64位系統不限制內存,32位系統最多使用3GB內存
  • zmalloc_used_moemory能夠獲取當前以分配的內存

由此能夠看出,當server.maxmemory=0時即默認狀況下時redis會將全部響應及時輸出給客戶端以免佔用內存,若是設置了server.maxmemory的狀況下,且知足條件的狀況下則對於超過NEXT_MAX_WRITES_PER_EVNET大小的響應數據不會一次性輸出,下文中會給出實測。

總之,一條不變的原則就是在內存有限或者沒有配置最大內存的狀況下,redis會盡量快的把響應數據輸出給客戶端(響應數據也要佔內存的好吧),若是內存夠用,redis會先輸出一部分數據,剩餘的數據下一次事件循環再輸出。

此外,在確認輸出完用戶數據以後, writeToClient還將清理調本來安裝在redis客戶端上的寫處理器。

除此以外redis還設計兩種類型暫存響應數據緩衝區,以下所示

  • replyBuffer 響應數據緩衝區,類型是字節數組, 用於暫存響應數據
  • replyList 響應數據隊列,類型是clientReplyBlock鏈表

那麼分配規則是什麼呢,我們能夠先看看addReply函數的實現

觀察以上代碼,能夠得出如下結論。

  • 響應數據會被先嚐試加進緩衝區中(緩衝區大小爲 16 *1024 = 16KB),若是響應緩衝區已滿,則將其加入響應隊列中
  • 響應數據會在執行beforSleep時或io線程中被輸出

事件循環抽象

AeEventLoop是redis事件循環的實現,AeApi是對操做系統的I/O多路複用API接口的抽象,並提供了不一樣操做系統下不一樣實現。

  • aeMain 是事件循環的主函數,在redis服務器啓動啓動以後會調用此函數
  • 能夠經過aeCreateFileEvent以及aeDeleteFileEvent增長或刪除此事件循環中感興趣的I/O事件(調用AeApi.aeApiAddEventAeApi.aeApiDelEvent)
  • 能夠經過aeCreateTimeEvent以及aeDeleteTimeEvent增長或刪除定時任務
  • setBeforeSleepHook能夠設置在進入事件輪詢(即調用AeApi.aeApiPoll)前調用的函數(見上文的beforeSleep)
  • setAfterSleepHook 能夠設置在事件輪詢完成以後調用的函數(見上文的afterSleep)
  • setDontWait 可使在執行事件輪詢時,不進入等待狀態,當即返回當前可處理事件,若是沒有事件能夠處理也當即返回。

如上圖所示,爲了適應不一樣的操做系統生態,redis設計了一套統一的事件輪詢API接口AeApi並提供了不一樣的實現,該API主要提供註冊感興趣的I/O事件、刪除感興趣I/O事件、輪詢事件的功能。

不一樣AeApi之間區別以下表所示。

名稱 底層實現 性能 操做系統 描述
AeEpoll epoll Linux 監視的描述符數量(客戶端數量)不受限制,IO的效率不會隨着監視fd的數量的增加而降低
AeApiEvport evports 不曉得,沒用過不下結論 Solaris(sun公司發行的系統,我是沒見過😅) 實現比較複雜,仍是epoll好用
AeApiKqueue kqueue 不曉得,沒用過不下結論 FreeBSD、Unix系統 相似於epoll
AeSelect select 最差 不一樣操做系統都有實現,做爲保底方案 能處理的文件描述符(客戶端數量)符存在限制,最大爲1024

參考資料

不服跑個分?

單看代碼,老是有點幹,我們來當一回臨時工,試一下redis在不一樣環境下的表現。

運行環境以下所示:

  • centos7
  • 1 CPU
  • 2G RAM
  • server.maxmemory = 10485760 即10M

臨時工的騷操做

假設在一家比較窮的公司,臨時工小柯不當心在線上數據庫執行了keys *操做, 那麼會發生什麼呢?

測試開始以前我們先打上兩個斷點,分別是addReply以及writeToClient

開啓一個redis-cli執行keys *命令

觀察addReply的調用

能夠看出因爲數據太大響應數據沒有加入緩衝區而是加入響應隊列,而且因爲是執行全表掃描命令而執行了屢次的addReply調用,以下圖所示。

輸出的客戶端相同,但響應數據不一樣

再次觀察咱們發現writeToClient確實有從響應隊列中取出響應數據的行爲

接着咱們來觀察writeToClient的反應,調用棧以下圖所示

對於 writeClient函數咱們主要驗證redis的輸出數據限制是否會生效。

對於handleClientsWithPendingWrites咱們主要驗證寫處理器是否會被安裝。

能夠看出因爲數據沒有輸出乾淨,redis確實爲咱們的客戶端安裝了寫處理器,接下來咱們放行程序不出意外我們將在writeToClient再次相遇, 而這次調用writeToClient的方法將變爲aeProcessEvents即在事件循環中輸出數據而不是在beforeSleep中,其調用棧以下圖所示。

啓發

  • u1s1, js寫代碼確實爽,不知道啥時候出個多線程版本的JavaScript(本文假設我使用的是多線程版本的js)
  • 不要執行keys *全表掃描操做,在你沒有配置I/O線程或者最大使用內存的狀況下
  • 該配置的參數都給配上了嗷(雖然運維基本上都會配,可是仍是有了解的必要)
  • 輸出文章或者教會別人確實有益於整理思路
  • 面試時只須要記住一點redis確實不是單線程的,更確切地說法是redis的核心業務線程只有一個,可是能夠配置多個I/O線程除此以外還有執行RDB序列化操做的時候也會開啓線程
  • 爲啥要js來做爲僞代碼?潛水掘金多年,發現仍是前端大爺們的熱情比較高哈😜
  • 因此,給我點贊!!!下次更新redis調試心得,一樣用你們看得懂的語言,順便複習一下C語言唄,畢竟踩了很多坑。

最後,用一張圖來描述一條redis命令通過的內存區域和函數。

相關文章
相關標籤/搜索