1. 程式人生 > >理解Redis的單執行緒模式

理解Redis的單執行緒模式

0.概述

本文基於的Redis版本為4.0以下,在Redis更高版本中並不是完全的單執行緒了,增加了BIO執行緒,本文主要講述主工作執行緒的單執行緒模式。

通過本文將瞭解到以下內容:

  1. Redis伺服器採用單執行緒模型的原因
  2. Redis單執行緒處理檔案事件和時間事件
  3. Redis事件的執行和排程

1.Redis的單執行緒模式

  • 單執行緒的現狀

本質上Redis並不是單純的單執行緒服務模型,一些輔助工作比如持久化刷盤、惰性刪除等任務是由BIO執行緒來完成的,這裡說的單執行緒主要是說與客戶端互動完成命令請求和回覆的工作執行緒。

  • 單執行緒的原因

至於Antirez大佬當時是怎麼想的設計為單執行緒不得而知,只能從幾個角度來分析,來確定單執行緒模型的選擇原因:

  • CPU並非瓶頸

多執行緒模型主要是為了充分利用多核CPU,讓執行緒在IO阻塞時被掛起讓出CPU使用權交給其他執行緒,充分提高CPU的使用率,但是這個場景在Redis並不明顯,因為CPU並不是Redis的瓶頸,Redis的所有操作都是基於記憶體的,處理事件極快,因此使用多執行緒來切換執行緒提高CPU利用率的需求並不強烈;

  • 記憶體才是瓶頸

單個Redis例項對單核的利用已經很好了,但是Redis的瓶頸在於記憶體,設想64核的機器假如記憶體只有16GB,那麼多執行緒Redis有什麼用武之地?

  • 複雜的Value型別

Redis有豐富的資料結構,並不是簡單的Key-Value型的NoSQL,這也是Redis備受歡迎的原因,其中常用的Hash、Zset、List等結構在value很大時,CURD的操作會很複雜,

如果採用多執行緒模式在進行相同key操作時就需要加鎖來進行同步,這樣就可能造成死鎖問題。

這時候你會問:將key做hash分配給相同的執行緒來處理就可以解決呀,確實是這樣的,這樣的話就需要在Redis中增加key的hash處理以及多執行緒負載均衡的處理,

從而Redis的實現就成為多執行緒模式了,好像確實也沒有什麼問題,但是Antirez並沒有這麼做,大神這麼做肯定是有原因的,果不其然,我們見到了叢集化的Redis;

  • 叢集化擴充套件

目前的機器都是多核的,但是記憶體一般128GB/64GB算是比較普遍了,但是Redis在使用記憶體60%以上穩定性就不如50%的效能了(至少筆者在使用叢集化Redis時超過70%時,叢集failover的頻率會更高),

因此在資料較大時,當Redis作為主存,就必須使用多臺機器構建叢集化的Redis資料庫系統,這樣以來Redis的單執行緒模式又被叢集化的處理所擴充套件了;

  • 軟體工程角度

單執行緒無論從開發和維護都比多執行緒要容易非常多,並且也能提高服務的穩定性,無鎖化處理讓單執行緒的Redis在開發和維護上都具備相當大的優勢;

  • 類Redis系統:

Redis的設計秉承實用第一和工程化,雖然有很多理論上優秀的設計模式,但是並不一定適用自己,軟體設計過程就是權衡的過程。

業內也有許多類Redis的NoSQL,比如360基礎架構組開發的Pika系統,基於SSD和Rocks儲存引擎,上層封裝一層協議轉換,來實現Redis所有功能的模擬,感興趣的可以研究和使用。

 

2.單執行緒的檔案事件和時間事件

Redis作為單執行緒服務要處理的工作一點也不少,Redis是事件驅動的伺服器,主要的事件型別就是:

  1. 檔案事件型別
  2. 時間事件型別

其中,時間事件是理解單執行緒邏輯模型的關鍵。

  • 時間事件

Redis的時間事件分為兩類:

  1. 定時事件:任務在等待指定大小的等待時間之後就執行,執行完成就不再執行,只觸發一次;
  2. 週期事件:任務每隔一定時間就執行,執行完成之後等待下一次執行,會週期性的觸發;
  3. 週期性時間事件

Redis中大部分是週期事件,週期事件主要是伺服器定期對自身執行情況進行檢測和調整,從而保證穩定性HA,這項工作主要是ServerCron函式來完成的,週期事件的內容主要包括:

  1. 刪除資料庫的key
  2. 觸發RDB和AOF持久化
  3. 主從同步
  4. 叢集化保活
  5. 關閉清理死客戶端連結
  6. 統計更新伺服器的記憶體、key數量等資訊

可見 Redis的週期性事件雖然主要處理輔助任務,但是對整個服務的穩定執行,起到至關重要的作用。

  • 時間事件的無序連結串列

Redis的每個時間事件分為三個部分:

  1. 事件ID 全域性唯一 依次遞增
  2. 觸發時間戳 ms級精度
  3. 事件處理函式 事件回撥函式

時間事件Time_Event結構:

Redis的時間事件是儲存在連結串列中的,並且是按照ID儲存的,新事件在頭部舊事件在尾部,但是並不是按照即將被執行的順序儲存的。

也就是第一個元素50ms後執行,但是第三個可能30ms後執行,這樣的話Redis每次從連結串列中獲取最近要執行的事件時,都需要進行O(N)遍歷,

顯然效能不是最好的,最好的情況肯定是類似於最小棧MinStack的思路,然而Antirez大佬卻選擇了無序連結串列的方式。

選擇無序連結串列也是適合Redis場景的,因為Redis中的時間事件數量並不多,即使進行O(N)遍歷效能損失也微乎其微,也就不必每次插入新事件時進行連結串列重排。

Redis儲存時間事件的無序連結串列如圖:

3.單執行緒下事件的排程和執行

Redis服務中因為包含了時間事件和檔案事件,事情也就變得複雜了,伺服器要決定何時處理檔案事件、何時處理時間事件、並且還要明確知道處理時間的時間長度,因此事件的執行和排程就成為重點。

Redis伺服器會輪流處理檔案事件和時間事件,這兩種事件的處理都是同步、有序、原子地執行的,伺服器也不會終止正在執行的事件,也不會對事件進行搶佔。

這個排程過程還是比較有意思的,我們來一起看下:

  • 事件執行排程規則

檔案事件是隨機出現的,如果處理完成一次檔案事件後,仍然沒有其他檔案事件到來,伺服器將繼續等待,

在檔案事件的不斷執行中,時間會逐漸向最早的時間事件所設定的到達時間逼近並最終來到到達時間,

這時伺服器就可以開始處理到達的時間事件了。由於時間事件在檔案事件之後執行,並且事件之間不會出現搶佔,

所以時間事件的實際處理時間一般會比設定的時間稍晚一些。

  • 事件執行排程的程式碼實現

Redis原始碼ae.c中對事件排程和執行的詳細過程在aeProcessEvents中實現的,具體的程式碼如下:

int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
  int processed = 0, numevents;
  if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS))
    return 0;

  if (eventLoop->maxfd != -1 ||
    ((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
    int j;
    aeTimeEvent *shortest = NULL;
    struct timeval tv, *tvp;

    if (flags & AE_TIME_EVENTS && !(flags & AE_DONT_WAIT))
      shortest = aeSearchNearestTimer(eventLoop);
    if (shortest) {
      long now_sec, now_ms;
      aeGetTime(&now_sec, &now_ms);
      tvp = &tv;
      long long ms =
        (shortest->when_sec - now_sec)*1000 +
        shortest->when_ms - now_ms;

      if (ms > 0) {
        tvp->tv_sec = ms/1000;
        tvp->tv_usec = (ms % 1000)*1000;
      } else {
        tvp->tv_sec = 0;
        tvp->tv_usec = 0;
      }
    } else {
      if (flags & AE_DONT_WAIT) {
        tv.tv_sec = tv.tv_usec = 0;
        tvp = &tv;
      } else {
        tvp = NULL; /* wait forever */
      }
    }
    numevents = aeApiPoll(eventLoop, tvp);
    if (eventLoop->aftersleep != NULL && flags & AE_CALL_AFTER_SLEEP)
      eventLoop->aftersleep(eventLoop);

    for (j = 0; j < numevents; j++) {
      aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
      int mask = eventLoop->fired[j].mask;
      int fd = eventLoop->fired[j].fd;
      int fired = 0;
      int invert = fe->mask & AE_BARRIER;
      if (!invert && fe->mask & mask & AE_READABLE) {
        fe->rfileProc(eventLoop,fd,fe->clientData,mask);
        fired++;
      }
      if (fe->mask & mask & AE_WRITABLE) {
        if (!fired || fe->wfileProc != fe->rfileProc) {
          fe->wfileProc(eventLoop,fd,fe->clientData,mask);
          fired++;
        }
      }
      if (invert && fe->mask & mask & AE_READABLE) {
        if (!fired || fe->wfileProc != fe->rfileProc) {
          fe->rfileProc(eventLoop,fd,fe->clientData,mask);
          fired++;
        }
      }
      processed++;
    }
  }
  /* Check time events */
  if (flags & AE_TIME_EVENTS)
    processed += processTimeEvents(eventLoop);
  return processed;
}
  • 事件執行和排程的偽碼

上面的原始碼可能讀起來並不直觀,在《Redis設計與實現》書中給出了虛擬碼實現:

def aeProcessEvents()
  #獲取當前最近的待執行的時間事件
  time_event = aeGetNearestTimer()
  #計算最近執行事件與當前時間的差值
  remain_gap_time = time_event.when - uinx_time_now()
  #判斷時間事件是否已經到期 則重置 馬上執行
  if remain_gap_time < 0:
    remain_gap_time = 0
  #阻塞等待檔案事件 具體的阻塞等待時間由remain_gap_time決定
  #如果remain_gap_time為0 那麼不阻塞立刻返回
  aeApiPoll(remain_gap_time)
  #處理所有檔案事件
  ProcessAllFileEvent()
  #處理所有時間事件
  ProcessAllTimeEvent()

可以看到Redis伺服器是邊阻塞邊執行的,具體的阻塞事件由最近待執行時間事件的等待時間決定的,在阻塞該最小等待時間返回之後,開始處理事件任務,

並且先執行檔案事件、再執行時間事件,所有即使時間事件要即刻執行,也需要等待檔案事件完成之後再執行時間事件,所以比預期的稍晚。

  • 事件排程和執行流程

 

 

4.參考資料

    • 深入瞭解Redis之事件原理和實現
    • 《Redis設計與實現》黃健巨集