導讀:Redis 6.0將在今年年底發佈,其中引入的最重大的改變就是多線程IO。本文作者深入閱讀並解析了關鍵代碼,並且做了基準測試,揭示多線程 IO 特性對Redis性能的提升,十分值得一讀。
Redis 作者 Salvatore 在 RedisConf 2019 分享,其中一段展示了 Redis 6 引入的多線程 IO 特性對性能提升至少是一倍以上,內心很是激動,迫不及待地去看了一下相關的代碼實現。
目前對於單線程 Redis 來說,性能瓶頸主要在於網絡的 IO 消耗, 優化主要有兩個方向:
提高網絡 IO 性能,典型的實現像使用 DPDK 來替代內核網絡棧的方式
使用多線程充分利用多核,典型的實現像 Memcached
協議棧優化的這種方式跟 Redis 關係不大,多線程特性在社區也被反覆提了很久後終於在 Redis 6 加入多線程,Salvatore 在自己的博客 An update about Redis developments in 2019 也有簡單的說明。但跟 Memcached 這種從 IO 處理到數據訪問多線程的實現模式有些差異。Redis 的多線程部分只是用來處理網絡數據的讀寫和協議解析,執行命令仍然是單線程。之所以這麼設計是不想因爲多線程而變得複雜,需要去控制 key、lua(一種輕量級腳本語言)、事務,LPUSH/LPOP(redis語法:將一個或多個值插入到列表頭部(左邊)、移出並獲取列表的第一個元素(左邊)) 等等的併發問題。整體的設計大體如下:
代碼實現
多線程 IO 的讀(請求)和寫(響應)在實現流程是一樣的,只是執行讀還是寫操作的差異。同時這些 IO 線程在同一時刻全部是讀或者寫,不會部分讀或部分寫的情況,所以下面以讀流程作爲例子。分析過程中的代碼只是爲了輔助理解,所以只會覆蓋核心邏輯而不是全部細節。如果想完全理解細節,建議看完之後再次看一次源碼實現。
加入多線程 IO 之後,整體的讀流程如下:
主線程負責接收建連請求,讀事件到來(收到請求)則放到一個全局等待讀處理隊列
主線程處理完讀事件之後,通過 RR(Round Robin) 將這些連接分配給這些 IO 線程,然後主線程忙等待(spinlock 的效果)狀態
IO 線程將請求數據讀取並解析完成(這裏只是讀數據和解析並不執行)
主線程執行所有命令並清空整個請求等待讀處理隊列(執行部分串行)
上面的這個過程是完全無鎖的,因爲在 IO 線程處理的時主線程會等待全部的 IO 線程完成,所以不會出現 data race 的場景。
注意:如果對於代碼實現沒有興趣的可以直接跳過下面內容,對了解 Redis 性能提升並沒有傷害。
下面的代碼分析和上面流程是對應的,當主線程收到請求的時候會回調 network.c 裏面的 readQueryFromClient 函數:
void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask) {
/* Check if we want to read from the client later when exiting from
* the event loop. This is the case if threaded I/O is enabled. */
if (postponeClientRead(c)) return;
...
}
readQueryFromClient 之前的實現是負責讀取和解析請求並執行命令,加入多線程 IO 之後加入了上面的這行代碼,postponeClientRead 實現如下:
int postponeClientRead(client *c) {
if (io_threads_active && // 多線程 IO 是否在開啓狀態,在待處理請求較少時會停止 IO
多線程
server.io_threads_do_reads && // 讀是否開啓多線程 IO
!(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ))) // 主從庫複製請求不使用多線程 IO
{
// 連接標識爲 CLIENT_PENDING_READ 來控制不會反覆被加隊列,
// 這個標識作用在後面會再次提到
c->flags |= CLIENT_PENDING_READ;
// 連接加入到等待讀處理隊列
listAddNodeHead(server.clients_pending_read,c);
return 1;
} else {
return 0;
}
}
postponeClientRead 判斷如果開啓多線程 IO 且不是主從複製連接的話就放到隊列然後返回 1,在 readQueryFromClient 函數會直接返回不進行命令解析和執行。接着主線程在處理完讀事件(注意是讀事件不是讀數據)之後將這些連接通過 RR 的方式分配給這些 IO 線程:
int handleClientsWithPendingReadsUsingThreads(void) {
...
// 將等待處理隊列的連接按照 RR 的方式分配給多個 IO 線程
listRewind(server.clients_pending_read,&li);
int item_id = 0;
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++;
}
...
// 一直忙等待直到所有的連接請求都被 IO 線程處理完
while(1) {
unsigned long pending = 0;
for (int j = 0; j < server.io_threads_num; j++)
pending += io_threads_pending[j];
if (pending == 0) break;
}
代碼裏面的 io_threads_list 用來存儲每個 IO 線程對應需要處理的連接,然後主線程將這些連接通過 RR 的方式分配給這些 IO 線程後進入忙等待狀態(相當於主線程 blocking 住)。IO 處理線程入口是 IOThreadMain 函數:
void *IOThreadMain(void *myid) {
while(1) {
// 遍歷線程 id 獲取線程對應的待處理連接列表
listRewind(io_threads_list[id],&li);
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
// 通過 io_threads_op 控制線程要處理的是讀還是寫請求
if (io_threads_op == IO_THREADS_OP_WRITE) {
writeToClient(c->fd,c,0);
} else if (io_threads_op == IO_THREADS_OP_READ) {
readQueryFromClient(NULL,c->fd,c,0);
} else {
serverPanic("io_threads_op value is unknown");
}
}
listEmpty(io_threads_list[id]);
io_threads_pending[id] = 0;
}
}
IO 線程處理根據全局 io_threads_op 狀態來控制當前 IO 線程應該處理讀還是寫事件,這也是上面提到的全部 IO 線程同一時刻只會執行讀或者寫。另外,心細的同學可能注意到處理線程會調用 readQueryFromClient 函數,而連接就是由這個回調函數加到隊列的,那不就死循環了?這個的答案在 postponeClientRead 函數,已經加到等待處理隊列的連接會被設置 CLIENT_PENDING_READ 標識。postponeClientRead 函數不會把連接再次加到隊列,那麼 readQueryFromClient 會繼續執行讀取和解析請求。readQueryFromClient 函數讀取請求數據並調用 processInputBuffer 函數進行解析命令,processInputBuffer 會判斷當前連接是否來自 IO 線程,如果是的話就只解析不執行命令,代碼就不貼了。
大家去看 IOThreadMain 實現會發現這些 io 線程是沒有任何 sleep 機制,在空閒狀態也會導致每個線程的 CPU 跑到 100%,但簡單 sleep 則會導致讀寫處理不及時而導致性能更差。Redis 當前的解決方式是通過在等待處理連接比較少的時候關閉這些 IO 線程。爲什麼不適用條件變量來控制呢?我也沒想明白,後面可以到社區提問。
性能對比
Redis Server: 阿里雲 Ubuntu 18.04,8 CPU 2.5 GHZ, 8G 內存,主機型號 ecs.ic5.2xlarge
Redis Benchmark Client: 阿里雲 Ubuntu 18.04,8 2.5 GHZ CPU, 8G 內存,主機型號 ecs.ic5.2xlarge
壓測配置:
多線程 IO 版本剛合併到 unstable 分支一段時間,所以只能使用 unstable 分支來測試多線程 IO,單線程版本是 Redis 5.0.5。多線程 IO 版本需要新增以下配置:
io-threads 4 # 開啓 4 個 IO 線程
io-threads-do-reads yes # 請求解析也是用 IO 線程
壓測命令:
redis-benchmark -h 192.168.0.49 -a foobared -t set,get -n 1000000 -r 100000000 --threads 4 -d ${datasize} -c 256
從上面可以看到 GET/SET 命令在 4 線程 IO 時性能相比單線程是幾乎是翻倍了。另外,這些數據只是爲了簡單驗證多線程 IO 是否真正帶來性能優化,並沒有針對嚴謹的延時控制和不同併發的場景進行壓測。數據僅供驗證參考而不能作爲線上指標,且只是目前的 unstble分支的性能,不排除後續發佈的正式版本的性能會更好。
注意: Redis Benchmark 除了 unstable 分支之外都是單線程,對於多線程 IO 版本來說,壓測發包性能會成爲瓶頸,務必自己編譯 unstable 分支的 redis-benchmark 來壓測,並配置 --threads 開啓多線程壓測。另外,如果發現編譯失敗也莫慌,這是因爲 Redis 用了 Atomic_ 特性,更新版本的編譯工具才支持,比如 GCC 5.0 以上版本。
總結
Redis 6.0 預計會在 2019 年底發佈,將在性能、協議以及權限控制都會有很大的改進。Salvatore 今年全身心投入在優化 Redis 和集羣的功能,特別值得期待。另外,今年年底社區也會同時發佈第一個版本 redis cluster proxy 來解決多語言 SDK 兼容的問題,期待在具備 proxy 功能之後 cluster 能在國內有更加廣泛的應用。
參考:林添毅 《正式支持多線程!Redis 6.0與老版性能對比評測》