Redis 所謂的單執行緒並不是所有工作都是隻有一個執行緒在執行,而是指 Redis 的網路 IO 和鍵值對讀寫是由一個執行緒來完成的,Redis 在處理客戶端的請求時包括獲取 (socket 讀)、解析、執行、內容返回 (socket 寫) 等都由一個順序序列的主執行緒處理。
這就是所謂的“單執行緒”。這也是 Redis 對外提供鍵值儲存服務的主要流程。
由於 Redis 在處理命令的時候是單執行緒作業的,所以會有一個 Socket 佇列,每一個到達的服務端命令來了之後都不會馬上被執行,而是進入佇列,然後被執行緒的事件分發器逐個執行。如下圖:
至於 Redis 的其他功能, 比如持久化、非同步刪除、叢集資料同步等等,其實是由額外的執行緒執行的。 可以這麼說,Redis 工作執行緒是單執行緒的。但是在 4.0 之後,對於整個 Redis 服務來說,還是多執行緒運作的。
6.0 之前為什麼要使用單執行緒
在使用 Redis 時,Redis 主要受限是在記憶體和網路上,CPU 幾乎沒有效能瓶頸的問題。
以 Linux 系統為例子,在 Linux 系統上 Redis 透過 pipelining 可以處理 100w 個請求每秒,而應用程式的計算複雜度主要是 O(N) 或 O(log(N)) ,不會消耗太多 CPU。
使用了單執行緒後,提高了可維護性。多執行緒模型在某些方面表現優異,卻增加了程式執行順序的不確定性,並且帶來了併發讀寫的一系列問題,增加了系統複雜度。同時因為執行緒切換、加解鎖,甚至死鎖,造成一定的效能損耗。
Redis 透過 AE 事件模型以及 IO 多路複用等技術,擁有超高的處理效能,因此沒有使用多執行緒的必要
6.0 之後的多執行緒主要解決什麼問題
近年來底層網路硬體效能越來越好,Redis 的效能瓶頸逐漸體現在網路 I/O 的讀寫上,單個執行緒處理網路 I/O 讀寫的速度跟不上底層網路硬體執行的速度。
Redis 在處理網路資料時,呼叫 epoll 的過程是阻塞的,這個過程會阻塞執行緒。如果併發量很高,達到萬級別的 QPS,就會形成瓶頸,影響整體吞吐能力
既然讀寫網路的 read/write 系統呼叫佔用了 Redis 執行期間大部分 CPU 時間,那麼要想真正做到提速,必須改善網路 IO 效能。我們可以從這兩個方面來最佳化:
提高網路 IO 效能,典型實現方式比如使用 DPDK 來替代核心網路棧的方式
使用多執行緒,這樣可以充分利用多核 CPU,同類實現案例比如 Memcached。
協議棧最佳化的這種方式跟 Redis 關係不大,所以最便捷高效的方式就是支援多執行緒。總結起來,redis 支援多執行緒就是以下兩個原因:
可以充分利用伺服器 CPU 的多核資源,而主執行緒明顯只能利用一個
多執行緒任務可以分攤 Redis 同步 IO 讀寫負荷,降低耗時
6.0 版本最佳化之後,主執行緒和多執行緒網路 IO 的執行流程如下:
具體步驟如下:
主執行緒建立連線,並接受資料,並將獲取的 socket 資料放入等待佇列;
透過輪詢的方式將 socket 讀取出來並分配給 IO 執行緒;
之後主執行緒保持阻塞,一直等到 IO 執行緒完成 socket 讀取和解析;
I/O 執行緒讀取和解析完成之後,返回給主執行緒 ,主執行緒開始執行 Redis 命令;
執行完 Redis 命令後,主執行緒阻塞,直到 IO 執行緒完成 結果回寫到 socket 的工作;
主執行緒清空已完成的佇列,等待客戶端新的請求。
本質上是將主執行緒 IO 讀寫的這個操作 獨立出來,單獨交給一個 I/O 執行緒組處理。
這樣多個 socket 讀寫可以並行執行,整體效率也就提高了。同時注意 Redis 命令還是主執行緒序列執行。
利用多核來分擔 I/O 讀寫負荷。在事件處理執行緒每次獲取到可讀事件時,會將所有就緒的讀事件分配給 I/O 執行緒,並進行等待,在所有 I/O 執行緒完成讀操作後,事件處理執行緒開始執行任務處理,在處理結束後,同樣將寫事件分配給 I/O 執行緒,等待所有 I/O 執行緒完成寫操作。
int handleClientsWithPendingReadsUsingThreads(void) { ... /* Distribute the clients across N different lists. */ listIter li; listNode *ln; listRewind(server.clients_pending_read,&li); int item_id = 0; // 將等待處理的客戶端分配給 I/O 執行緒 while((ln = listNext(&li))) { client *c = listNodeValue(ln); int target_id = item_id % server.io_threads_num; listAddNodeTail(io_threads_list[target_id],c); item_id++; } ... /* Wait for all the other threads to end their work. */ // 輪訓等待所有 I/O 執行緒處理完 while(1) { unsigned long pending = 0; for (int j = 1; j < server.io_threads_num; j++) pending += io_threads_pending[j]; if (pending == 0) break; } ... return processed; }
本質上是利用多核的多執行緒讓多個 IO 的讀寫加速。
侷限性
6.0 版本的多執行緒並非徹底的多執行緒,I/O 執行緒只能同時執行讀或者同時執行寫操作,期間事件處理執行緒一直處於等待狀態,並非流水線模型,有很多輪訓等待開銷。