1. 程式人生 > 其它 >Redis之執行緒模型

Redis之執行緒模型

1、前言 Redis4.0版本之後開始使用多執行緒,之前使用的是單執行緒。無論是使用單執行緒模型還是多執行緒模型,這兩個設計上的決定都是為了更好地提升 Redis 的開發效率、執行效能。雖然 Redis 在較新的版本中引入了多執行緒,不過是在部分命令上引入的,其中包括非阻塞的刪除操作,在整體的架構設計上,主處理程式還是單執行緒模型的。 2、執行緒模型 Redis 基於 Reactor 模式開發了自己的網路事件處理器: 這個處理器被稱為檔案事件處理器(file event handler),由套接字、I/O多路複用程式、檔案事件分派器(dispatcher),事件處理器四部分組成。 檔案事件處理器使用 I/O 多路複用(multiplexing)程式來同時監聽多個套接字, 並根據套接字目前執行的任務來為套接字關聯不同的事件處理器。 當被監聽的套接字準備好執行連線應答(accept)、讀取(read)、寫入(write)、關閉(close)等操作時, 與操作相對應的檔案事件就會產生, 這時檔案事件處理器就會呼叫套接字之前關聯好的事件處理器來處理這些事件。 雖然檔案事件處理器以單執行緒方式執行, 但通過使用 I/O 多路複用程式來監聽多個套接字, 檔案事件處理器既實現了高效能的網路通訊模型, 又可以很好地與 redis 伺服器中其他同樣以單執行緒方式執行的模組進行對接, 這保持了 Redis 內部單執行緒設計的簡單性。
Redis服務端通過Socket與客戶端連線。每當一個套接字準備好執行連線應答(accept)、讀取(read)、寫入(write)、關閉(close)等操作時,就會產生一個檔案事件。因為一個伺服器通常會連線多個套接字, 所以多個檔案事件有可能會併發地出現。比如,當socket變得可讀時,socket就會產生一個 AE_READABLE事件;當socket變得可寫的時候,socket會產生一個 AE_WRITABLE事件。 2.1、檔案事件處理器的構成 檔案事件處理器的四個組成部分, 它們分別是套接字、 I/O 多路複用程式、 檔案事件分派器(dispatcher)、 以及事件處理器。 檔案事件是對套接字操作的抽象, 每當一個套接字準備好執行連線應答(accept)、寫入、讀取、關閉等操作時, 就會產生一個檔案事件。 因為一個伺服器通常會連線多個套接字, 所以多個檔案事件有可能會併發地出現。 I/O 多路複用程式負責監聽多個套接字, 並向檔案事件分派器傳送那些產生了事件的套接字。
儘管多個檔案事件可能會併發地出現, 但 I/O 多路複用程式總是會將所有產生事件的套接字都入隊到一個佇列裡面, 然後通過這個佇列, 以有序(sequentially)、同步(synchronously)、每次一個套接字的方式向檔案事件分派器傳送套接字: 當上一個套接字產生的事件被處理完畢之後(該套接字為事件所關聯的事件處理器執行完畢), I/O 多路複用程式才會繼續向檔案事件分派器傳送下一個套接字。 檔案事件分派器接收 I/O 多路複用程式傳來的套接字, 並根據套接字產生的事件的型別, 呼叫相應的事件處理器。伺服器會為執行不同任務的套接字關聯不同的事件處理器, 這些處理器是一個個函式, 它們定義了某個事件發生時, 伺服器應該執行的動作。
2.2、I/O 多路複用程式的實現 Redis 的 I/O 多路複用程式的所有功能都是通過包裝常見的 select 、 epoll 、 evport 和 kqueue 這些 I/O 多路複用函式庫來實現的, 每個 I/O 多路複用函式庫在 Redis 原始碼中都對應一個單獨的檔案, 比如 ae_select.c 、 ae_epoll.c 、 ae_kqueue.c , 諸如此類。因為 Redis 為每個 I/O 多路複用函式庫都實現了相同的 API , 所以 I/O 多路複用程式的底層實現是可以互換的, 如圖所示。 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
2.3、事件的型別與排程 伺服器需要處理兩類事件:檔案事件,時間事件。 (1)檔案事件:Redis伺服器對套接字的操作,當一個套接字準備執行連線、讀、寫、關閉等操作時就會產生一個檔案事件。檔案事件分為AE_READABLE和AE_WRITABLE兩類。
  • 當套接字變得可讀時(客戶端對套接字執行 write 操作,或者執行 close 操作), 或者有新的可應答(acceptable)套接字出現時(客戶端對伺服器的監聽套接字執行 connect 操作), 套接字產生AE_READABLE事件。
  • 當套接字變得可寫時(客戶端對套接字執行 read 操作), 套接字產生AE_WRITABLE事件。
I/O 多路複用程式允許伺服器同時監聽套接字的AE_READABLE事件和AE_WRITABLE事件, 如果一個套接字同時產生了這兩種事件, 那麼檔案事件分派器會優先處理AE_READABLE事件, 等到 AE_READABLE事件處理完之後, 才處理AE_WRITABLE事件。也就是說, 如果一個套接字又可讀又可寫的話, 那麼伺服器將先讀套接字, 後寫套接字。 (2)時間事件:Redis伺服器中一些需要在給定時間點執行的操作。 伺服器將所有時間事件放在一個無序連結串列中,每當時間事件執行器執行時,它就會遍歷整個連結串列,查詢所有已經到達的時間事件,並呼叫相應的事件處理器。Redis伺服器一般情況下只執行serverCron函式一個時間事件,通過redis.c/serverCron函式定期對自身的資源和狀態進行檢查和調整,主要工作包括:
  • 更新伺服器的各類統計資訊,如時間、記憶體佔用、資料庫佔用情況等。
  • 清理資料庫中的過期鍵值對。
  • 關閉和清理連線失效的客戶端。
  • 嘗試進行AOF或RDB持久化操作。
  • 如果伺服器是主伺服器,那麼對從伺服器進行定期同步。
  • 如果處於叢集模式,對叢集進行定期執行同步和連線測試。
  • Redis2.6版本,伺服器預設serverCron每秒執行10次,平均每隔100毫秒執行一次。Redis2.8版本之後,通過hz調整每秒執行次數。
(3)因為伺服器中同時存在檔案事件和時間事件,所以伺服器必須對這兩種事件進行排程,事件的排程和執行由ae.c/aeProcessEvents函式負責,邏輯如下: 先計算最近的時間事件距離到達還有多少毫秒remaind_ms,根據這個值阻塞並等待檔案事件產生,remaind_ms<=0不阻塞,阻塞期間會不斷處理出現的檔案事件。當時間事件最終到達時,伺服器才會開始處理達到的時間事件。 注意:對檔案事件和時間事件的處理都是同步、有序、原子地執行的,伺服器不會中途中斷事件處理,也不會對事件進行搶佔,因此,不管是檔案事件的處理器,還是時間事件的處理器,它們都會盡可能減少程式的阻塞時間,並在有需要時主動讓出執行權。因為時間事件在檔案事件之後執行,並且事件之間不會出現搶佔,所以時間事件的實際處理時間通常會比設定的到達時間晚一些。 如圖,在時間事件到達前(100ms),伺服器已經等待並處理了兩次檔案事件,又因為處理事件的過程中不會出現搶佔,所以實際處理時間事件的時間比預計慢了30毫秒。 2.4、API ae.c/aeCreateFileEvent 函式接受一個套接字描述符、 一個事件型別、 以及一個事件處理器作為引數, 將給定套接字的給定事件加入到 I/O 多路複用程式的監聽範圍之內, 並對事件和事件處理器進行關聯。 ae.c/aeDeleteFileEvent 函式接受一個套接字描述符和一個監聽事件型別作為引數, 讓 I/O 多路複用程式取消對給定套接字的給定事件的監聽, 並取消事件和事件處理器之間的關聯。 ae.c/aeGetFileEvents 函式接受一個套接字描述符, 返回該套接字正在被監聽的事件型別:
  • 如果套接字沒有任何事件被監聽, 那麼函式返回 AE_NONE 。
  • 如果套接字的讀事件正在被監聽, 那麼函式返回 AE_READABLE 。
  • 如果套接字的寫事件正在被監聽, 那麼函式返回 AE_WRITABLE 。
  • 如果套接字的讀事件和寫事件正在被監聽, 那麼函式返回 AE_READABLE | AE_WRITABLE 。
ae.c/aeWait 函式接受一個套接字描述符、一個事件型別和一個毫秒數為引數, 在給定的時間內阻塞並等待套接字的給定型別事件產生, 當事件成功產生, 或者等待超時之後, 函式返回。 ae.c/aeApiPoll 函式接受一個 sys/time.h/struct timeval 結構為引數, 並在指定的時間內, 阻塞並等待所有被 aeCreateFileEvent 函式設定為監聽狀態的套接字產生檔案事件, 當有至少一個事件產生, 或者等待超時後, 函式返回。 ae.c/aeProcessEvents 函式是檔案事件分派器, 它先呼叫 aeApiPoll 函式來等待事件產生, 然後遍歷所有已產生的事件, 並呼叫相應的事件處理器來處理這些事件。 ae.c/aeGetApiName 函式返回 I/O 多路複用程式底層所使用的 I/O 多路複用函式庫的名稱: 返回 "epoll" 表示底層為 epoll 函式庫, 返回"select" 表示底層為 select 函式庫, 諸如此類。 2.5、事件的處理器 Redis 為檔案事件編寫了多個處理器, 這些事件處理器分別用於實現不同的網路通訊需求, 比如說:
  • 為了對連線伺服器的各個客戶端進行應答, 伺服器要為監聽套接字關聯連線應答處理器。
  • 為了接收客戶端傳來的命令請求, 伺服器要為客戶端套接字關聯命令請求處理器。
  • 為了向客戶端返回命令的執行結果, 伺服器要為客戶端套接字關聯命令回覆處理器。
  • 當主伺服器和從伺服器進行復制操作時, 主從伺服器都需要關聯特別為複製功能編寫的複製處理器。
在這些事件處理器裡面, 伺服器最常用的要數與客戶端進行通訊的連線應答處理器、 命令請求處理器和命令回覆處理器。 2.6、連線應答處理器 networking.c/acceptTcpHandler 函式是 Redis 的連線應答處理器, 這個處理器用於對連線伺服器監聽套接字的客戶端進行應答, 具體實現為sys/socket.h/accept 函式的包裝。 當 Redis 伺服器進行初始化的時候, 程式會將這個連線應答處理器和伺服器監聽套接字的 AE_READABLE 事件關聯起來, 當有客戶端用sys/socket.h/connect 函式連線伺服器監聽套接字的時候, 套接字就會產生 AE_READABLE 事件, 引發連線應答處理器執行, 並執行相應的套接字應答操作, 如圖所示。 2.7、命令請求處理器 networking.c/readQueryFromClient 函式是 Redis 的命令請求處理器, 這個處理器負責從套接字中讀入客戶端傳送的命令請求內容, 具體實現為 unistd.h/read 函式的包裝。 當一個客戶端通過連線應答處理器成功連線到伺服器之後, 伺服器會將客戶端套接字的 AE_READABLE 事件和命令請求處理器關聯起來, 當客戶端向伺服器傳送命令請求的時候, 套接字就會產生 AE_READABLE 事件, 引發命令請求處理器執行, 並執行相應的套接字讀入操作, 如圖所示。 在客戶端連線伺服器的整個過程中, 伺服器都會一直為客戶端套接字的 AE_READABLE 事件關聯命令請求處理器。 2.8、命令回覆處理器 networking.c/sendReplyToClient 函式是 Redis 的命令回覆處理器, 這個處理器負責將伺服器執行命令後得到的命令回覆通過套接字返回給客戶端, 具體實現為 unistd.h/write 函式的包裝。 當伺服器有命令回覆需要傳送給客戶端的時候, 伺服器會將客戶端套接字的 AE_WRITABLE 事件和命令回覆處理器關聯起來, 當客戶端準備好接收伺服器傳回的命令回覆時, 就會產生 AE_WRITABLE 事件, 引發命令回覆處理器執行, 並執行相應的套接字寫入操作, 如圖所示。 當命令回覆傳送完畢之後, 伺服器就會解除命令回覆處理器與客戶端套接字的 AE_WRITABLE 事件之間的關聯。
  • 注意1:只有當上一個套接字產生的事件被所關聯的事件處理器執行完畢,I/O多路複用程式才會繼續向檔案事件分派器傳送下一個套接字,所以對每個命令的執行時間是有要求的,如果某個命令執行過長,會造成其他命令的阻塞。所以慎用O(n)命令,Redis是面向快速執行場景的資料庫。
  • 注意2:命令的併發性。Redis是單執行緒處理命令,命令會被逐個被執行,假如有3個客戶端命令同時執行,執行順序是不確定的,但能確定不會有兩條命令被同時執行,所以兩條incr命令無論怎麼執行最終結果都是2。
綜上: Redis服務啟動初始化的時候,Redis會將【連線應答處理器】跟 AE_READABLE事件關聯起來; 如果一個Redis客戶端發起連線請求,此時會產生一個 AE_READABLE事件,然後由【連線應答處理器】來處理跟客戶端建立連線,建立客戶端對應的socket,同時將這個socket的 AE_READABLE事件跟【命令請求處理器】關聯起來; 當客戶端向Redis發起命令請求時,首先就會在socket產生一個 AE_READABLE事件,然後由對應的【命令請求處理器】來處理。這個【命令請求處理器】就會從socket中讀取請求相關資料,然後進行執行和處理; 當Redis準備好了給客戶端的響應資料後,就會將socket的 AE_WRITABLE事件跟【命令回覆處理器】關聯起來,當客戶端準備好讀取響應資料時,就會在socket上產生一個 AE_WRITABLE事件,對應的【命令回覆處理器】會將準備好的響應資料寫入socket,供客戶端來讀取; 【命令回覆處理器】寫完之後,就會刪除這個socket的 AE_WRITABLE事件和【命令回覆處理器】的關聯關係。 3、一次完整的客戶端與伺服器連線事件示例 讓我們來追蹤一次 Redis 客戶端與伺服器進行連線併發送命令的整個過程, 看看在過程中會產生什麼事件, 而這些事件又是如何被處理的。
  1. 假設一個 Redis 伺服器正在運作, 那麼這個伺服器的監聽套接字的 AE_READABLE 事件應該正處於監聽狀態之下, 而該事件所對應的處理器為連線應答處理器。
  2. 如果這時有一個 Redis 客戶端向伺服器發起連線, 那麼監聽套接字將產生 AE_READABLE 事件, 觸發連線應答處理器執行: 處理器會對客戶端的連線請求進行應答, 然後建立客戶端套接字, 以及客戶端狀態, 並將客戶端套接字的 AE_READABLE 事件與命令請求處理器進行關聯, 使得客戶端可以向主伺服器傳送命令請求。
  3. 之後, 假設客戶端向主伺服器傳送一個命令請求, 那麼客戶端套接字將產生 AE_READABLE 事件, 引發命令請求處理器執行, 處理器讀取客戶端的命令內容, 然後傳給相關程式去執行。
  4. 執行命令將產生相應的命令回覆, 為了將這些命令回覆傳送回客戶端, 伺服器會將客戶端套接字的 AE_WRITABLE 事件與命令回覆處理器進行關聯: 當客戶端嘗試讀取命令回覆的時候, 客戶端套接字將產生 AE_WRITABLE 事件, 觸發命令回覆處理器執行, 當命令回覆處理器將命令回覆全部寫入到套接字之後, 伺服器就會解除客戶端套接字的 AE_WRITABLE 事件與命令回覆處理器之間的關聯。
4、執行命令請求過程 對於每個與伺服器進行連線的客戶端,伺服器都為這些客戶端建立了相應的redis.h/redisClient結構(客戶端狀態)。 Redis伺服器狀態結構的clients屬性是一個連結串列,這個連結串列儲存了所有與伺服器連線的客戶端的狀態結構。對客戶端執行批量操作,或者查詢某個指定的客戶端,都可以通過遍歷clients連結串列來完成。
//客戶端狀態的關鍵屬性:
typedef struct redisClient {
    //……
    sds querybuf;
    robj **argv;
    int argc;
    struct redisCommand *cmd;
    char buf[REDIS_REPLY_CHUNK_BYTES];
    int bufpos;
    list *reply;
    //……
} redisClient;
一個命令請求從傳送到獲得回覆的過程: 1、傳送命令請求: 當客戶端使用connect函式連線到伺服器時,伺服器就會呼叫連線事件處理器,為客戶端建立相應的客戶端狀態,並將這個新的客戶端狀態新增到伺服器狀態結構clients連結串列的末尾。 當用戶在客戶端中鍵入一個命令請求時,客戶端會將這個命令請求轉換成協議格式,然後通過連線到伺服器的套接字,將協議格式的命令請求傳送給伺服器。 2、讀取命令請求: 當客戶端與伺服器之間的連線套接字因為客戶端的寫入而變得可讀時,伺服器將呼叫命令請求處理器來執行以下操作:
  • 讀取套接字中協議格式的命令請求,並將其儲存到客戶端狀態的輸入緩衝區querybuf。
  • 對輸入緩衝區中的命令請求進行分析,提取出命令請求中包含的命令引數,以及命令引數的個數,然後分別將引數和引數個數儲存到客戶端狀態的argv和argc屬性。
  • 呼叫命令執行器,執行客戶端指定的命令。
3、執行命令
  • 命令執行器要做的第一件事就是根據客戶端狀態的argv[0]引數,在命令表中查詢引數所指定的命令,並將找到的命令儲存到客戶端狀態的cmd屬性中。
  • 到目前為止,伺服器已經將執行命令所需的命令實現函式(cmd)、引數(argv)、引數個數(argc)都收集齊了,但在真正執行命令前,程式還需要進行一些預備操作,從而確保命令可以正確、順利地被執行:檢查客戶端狀態的cmd指標是否執行NULL、檢查命令請求所給定的引數個數是否正確、檢查客戶端是否已經通過了身份驗證...
  • 被呼叫的命令實現函式會執行指定的操作,併產生相應的命令回覆,這些回覆會被儲存在客戶端狀態的輸出緩衝區中(buf屬性和reply屬性),之後實現函式還會為客戶端的套接字關聯命令回覆處理器,這個處理器負責將命令回覆返回給客戶端。
4、在執行完實現函式之後,伺服器還需要執行一些後續工作:
  • 如果伺服器開啟了慢查詢日誌功能,那麼慢查詢日誌模組會檢查是否需要為剛剛執行完的命令請求新增一條新的慢查詢日誌。
  • 根據剛剛執行命令所耗費的時長,更新被執行命令的redisCommand結構的milliseconds屬性,並將命令的redisCommand結構的calls計數器的值加1。
  • 如果伺服器開啟AOF持久化功能,那麼AOF持久化模組會將剛剛執行的命令請求寫入到AOF緩衝區。
  • 如果有其他伺服器正在複製當前這個伺服器,那麼伺服器會將剛剛執行的命令傳播給所有從伺服器。
當以上操作都執行完之後,伺服器對當前命令的執行到此告一段落,之後伺服器就可以繼續從檔案事件處理器中取出並處理下一個命令請求了。 5、將命令回覆傳送給客戶端
  • 當客戶端套接字變為可寫狀態時,伺服器就會執行命令回覆處理器,將儲存在客戶端輸出緩衝區中的命令回覆傳送給客戶端。當命令回覆傳送完畢之後,回覆處理器會清空客戶端狀態的輸出緩衝區,為處理下一個命令請求做好準備。
  • 當客戶端接收到協議格式的命令回覆之後,它會將這些回覆轉換成人們可以識別的可讀的格式。
補充: querybuf最大不能超過1GB。 argv屬性是一個數組,其中argv[0]是要執行的命令,而之後的其他項則是傳給命令的引數。argc屬性負責記錄argv陣列的長度。 命令表是一個字典,字典的鍵是一個個命令名字,比如"set"、"get"、"del"等等;而字典的值則是一個個redisCommand結構體,每個redisCommand結構體記錄了一個Redis命令的實現資訊,包括命令的實現函式、命令的標誌、命令應該給定的引數個數、命令的總執行次數和總消耗時長等統計資訊。命令表使用的時大小寫無關的查詢演算法。 buf是一個大小為REDIS_REPLY_CHUNK_BYTES位元組的位元組陣列,而bufpos屬性則記錄了buf陣列目前已使用的位元組數量。REDIS_REPLY_CHUNK_BYTES常量目前的預設值為16*1024,也即是說,buf陣列的預設大小為16KB。當buf陣列的空間已經用完,或者回復因為太大而無法裝進buf數組裡面,伺服器就會開始使用可變大小緩衝區reply,可變大小緩衝區是連結串列結構。 伺服器處理完客戶端的命令請求後,命令回覆只是暫時快取在client結構體的buf緩衝區,待客戶端的可寫事件發生時,才會真正往客戶端傳送命令回覆。 5、初始化伺服器 1、初始化伺服器狀態 初始化伺服器的第一步就是建立一個struct redisServer型別的例項變數server作為伺服器的狀態,併為結構中的各個屬性設定預設值。由redis.c/initServerConfig函式完成。 2、載入伺服器配置 伺服器在用initServerConfig函式初始化完server變數之後,就會開始載入使用者給定的配置引數和配置檔案,並根據使用者設定的配置,對server變數相關屬性的值進行修改。
  • 如果使用者為這些屬性的相應選項指定了新的值,那麼伺服器就使用使用者指定的值來更新相應的屬性。
  • 如果使用者沒有為屬性的相應選項設定新值,那麼伺服器就沿用為initServerConfig函式為屬性設定的預設值。
3、初始化伺服器資料結構 呼叫initServer函式初始化資料結構。開啟伺服器的監聽埠,併為監聽套接字關聯連線應答事件處理器,等待伺服器正式執行時接受客戶端的連線。為serverCron函式建立時間事件,等待伺服器正式執行時執行serverCron函式。如果AOF持久化功能已經開啟,那麼開啟現有的AOF檔案,如果AOF檔案不存在,那麼建立並開啟一個新的AOF檔案,為AOF寫入做好準備。 4、還原資料庫狀態 在完成了對伺服器狀態server變數的初始化之後,伺服器需要載入RDB檔案或AOF檔案,並根據檔案記錄的內容來還原伺服器的資料庫狀態。
  • 如果伺服器啟用了AOF持久化功能,那麼伺服器使用AOF檔案來還原資料庫狀態
  • 相反地,如果伺服器沒有啟用AOF持久化功能,那麼伺服器使用RDB檔案來還原資料庫狀態
當伺服器完成資料庫狀態還原工作之後,伺服器將在日誌中打印出載入檔案並還原資料庫狀態所耗費的時長: [5244] 21 Nov 22:43:49.084 * DB loaded from disk: 0.068 seconds 5、執行事件迴圈 在初始化最後一步,伺服器將打印出日誌: [5244] 21 Nov 22:43:49.084 * The server is now ready to accept connections on port 6379 並開始執行伺服器的事件迴圈(loop)。至此,伺服器的初始化工作圓滿完成,伺服器現在開始可以接受客戶端的連線請求,並處理客戶端發來的命令請求了。 補充: 當伺服器接收到一條命令請求時,需要從命令表中查詢命令,而redisCommandTable命令表是一個數組,意味著查詢命令的時間複雜度為O(N),效率低下。因此Redis在伺服器初始化時,會將redisCommandTable轉換為一個字典儲存在redisServer物件的commands欄位,key為命令名稱,value為命令redisCommand物件。 6、單執行緒與高併發 為什麼Redis使用單執行緒模型會達到每秒萬級別的處理能力呢? 第一,純記憶體訪問,Redis將所有資料放在記憶體中,記憶體的響應時長大約為100納秒,這是Redis達到每秒萬級別訪問的重要基礎。 第二,非阻塞I/O多路複用的實現。 第三,單執行緒避免了執行緒切換和競態產生的消耗。 一、二是重點,非阻塞是指I/O多路複用程式監聽到事件不處理就直接壓到佇列裡,讓事件處理器去處理,又因為是記憶體訪問,效率很高。如果是阻塞I/O,要等一個請求處理完得到響應才能處理下一個請求。