導(dǎo)讀:Redis 6.0將在今年年底發(fā)布,其中引入的最重大的改變就是多線程IO。本文作者深入閱讀并解析了關(guān)鍵代碼,并且做了基準測試,揭示多線程 IO 特性對Redis性能的提升,十分值得一讀。
Redis 作者 Salvatore 在 RedisConf 2019 分享,其中一段展示了 Redis 6 引入的多線程 IO 特性對性能提升至少是一倍以上,內(nèi)心很是激動,迫不及待地去看了一下相關(guān)的代碼實現(xiàn)。
目前對于單線程 Redis 來說,性能瓶頸主要在于網(wǎng)絡(luò)的 IO 消耗, 優(yōu)化主要有兩個方向:
提高網(wǎng)絡(luò) IO 性能,典型的實現(xiàn)像使用 DPDK 來替代內(nèi)核網(wǎng)絡(luò)棧的方式
使用多線程充分利用多核,典型的實現(xiàn)像 Memcached
協(xié)議棧優(yōu)化的這種方式跟 Redis 關(guān)系不大,多線程特性在社區(qū)也被反復(fù)提了很久后終于在 Redis 6 加入多線程,Salvatore 在自己的博客 An update about Redis developments in 2019 也有簡單的說明。但跟 Memcached 這種從 IO 處理到數(shù)據(jù)訪問多線程的實現(xiàn)模式有些差異。Redis 的多線程部分只是用來處理網(wǎng)絡(luò)數(shù)據(jù)的讀寫和協(xié)議解析,執(zhí)行命令仍然是單線程。之所以這么設(shè)計是不想因為多線程而變得復(fù)雜,需要去控制 key、lua(一種輕量級腳本語言)、事務(wù),LPUSH/LPOP(redis語法:將一個或多個值插入到列表頭部(左邊)、移出并獲取列表的第一個元素(左邊)) 等等的并發(fā)問題。整體的設(shè)計大體如下:
代碼實現(xiàn)
多線程 IO 的讀(請求)和寫(響應(yīng))在實現(xiàn)流程是一樣的,只是執(zhí)行讀還是寫操作的差異。同時這些 IO 線程在同一時刻全部是讀或者寫,不會部分讀或部分寫的情況,所以下面以讀流程作為例子。分析過程中的代碼只是為了輔助理解,所以只會覆蓋核心邏輯而不是全部細節(jié)。如果想完全理解細節(jié),建議看完之后再次看一次源碼實現(xiàn)。
加入多線程 IO 之后,整體的讀流程如下:
主線程負責接收建連請求,讀事件到來(收到請求)則放到一個全局等待讀處理隊列
主線程處理完讀事件之后,通過 RR(Round Robin) 將這些連接分配給這些 IO 線程,然后主線程忙等待(spinlock 的效果)狀態(tài)
IO 線程將請求數(shù)據(jù)讀取并解析完成(這里只是讀數(shù)據(jù)和解析并不執(zhí)行)
主線程執(zhí)行所有命令并清空整個請求等待讀處理隊列(執(zhí)行部分串行)
上面的這個過程是完全無鎖的,因為在 IO 線程處理的時主線程會等待全部的 IO 線程完成,所以不會出現(xiàn) data race 的場景。
注意:如果對于代碼實現(xiàn)沒有興趣的可以直接跳過下面內(nèi)容,對了解 Redis 性能提升并沒有傷害。
下面的代碼分析和上面流程是對應(yīng)的,當主線程收到請求的時候會回調(diào) network.c 里面的 readQueryFromClient 函數(shù):
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 之前的實現(xiàn)是負責讀取和解析請求并執(zhí)行命令,加入多線程 IO 之后加入了上面的這行代碼,postponeClientRead 實現(xiàn)如下:
int postponeClientRead(client *c) {
if (io_threads_active && // 多線程 IO 是否在開啟狀態(tài),在待處理請求較少時會停止 IO
多線程
server.io_threads_do_reads && // 讀是否開啟多線程 IO
!(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ))) // 主從庫復(fù)制請求不使用多線程 IO
{
// 連接標識為 CLIENT_PENDING_READ 來控制不會反復(fù)被加隊列,
// 這個標識作用在后面會再次提到
c->flags |= CLIENT_PENDING_READ;
// 連接加入到等待讀處理隊列
listAddNodeHead(server.clients_pending_read,c);
return 1;
} else {
return 0;
}
}
postponeClientRead 判斷如果開啟多線程 IO 且不是主從復(fù)制連接的話就放到隊列然后返回 1,在 readQueryFromClient 函數(shù)會直接返回不進行命令解析和執(zhí)行。接著主線程在處理完讀事件(注意是讀事件不是讀數(shù)據(jù))之后將這些連接通過 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 線程對應(yīng)需要處理的連接,然后主線程將這些連接通過 RR 的方式分配給這些 IO 線程后進入忙等待狀態(tài)(相當于主線程 blocking 住)。IO 處理線程入口是 IOThreadMain 函數(shù):
void *IOThreadMain(void *myid) {
while(1) {
// 遍歷線程 id 獲取線程對應(yīng)的待處理連接列表
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 線程處理根據(jù)全局 io_threads_op 狀態(tài)來控制當前 IO 線程應(yīng)該處理讀還是寫事件,這也是上面提到的全部 IO 線程同一時刻只會執(zhí)行讀或者寫。另外,心細的同學(xué)可能注意到處理線程會調(diào)用 readQueryFromClient 函數(shù),而連接就是由這個回調(diào)函數(shù)加到隊列的,那不就死循環(huán)了?這個的答案在 postponeClientRead 函數(shù),已經(jīng)加到等待處理隊列的連接會被設(shè)置 CLIENT_PENDING_READ 標識。postponeClientRead 函數(shù)不會把連接再次加到隊列,那么 readQueryFromClient 會繼續(xù)執(zhí)行讀取和解析請求。readQueryFromClient 函數(shù)讀取請求數(shù)據(jù)并調(diào)用 processInputBuffer 函數(shù)進行解析命令,processInputBuffer 會判斷當前連接是否來自 IO 線程,如果是的話就只解析不執(zhí)行命令,代碼就不貼了。
大家去看 IOThreadMain 實現(xiàn)會發(fā)現(xiàn)這些 io 線程是沒有任何 sleep 機制,在空閑狀態(tài)也會導(dǎo)致每個線程的 CPU 跑到 100%,但簡單 sleep 則會導(dǎo)致讀寫處理不及時而導(dǎo)致性能更差。Redis 當前的解決方式是通過在等待處理連接比較少的時候關(guān)閉這些 IO 線程。為什么不適用條件變量來控制呢?我也沒想明白,后面可以到社區(qū)提問。
性能對比
Redis Server: 阿里云 Ubuntu 18.04,8 CPU 2.5 GHZ, 8G 內(nèi)存,主機型號 ecs.ic5.2xlarge
Redis Benchmark Client: 阿里云 Ubuntu 18.04,8 2.5 GHZ CPU, 8G 內(nèi)存,主機型號 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 時性能相比單線程是幾乎是翻倍了。另外,這些數(shù)據(jù)只是為了簡單驗證多線程 IO 是否真正帶來性能優(yōu)化,并沒有針對嚴謹?shù)难訒r控制和不同并發(fā)的場景進行壓測。數(shù)據(jù)僅供驗證參考而不能作為線上指標,且只是目前的 unstble分支的性能,不排除后續(xù)發(fā)布的正式版本的性能會更好。
注意: Redis Benchmark 除了 unstable 分支之外都是單線程,對于多線程 IO 版本來說,壓測發(fā)包性能會成為瓶頸,務(wù)必自己編譯 unstable 分支的 redis-benchmark 來壓測,并配置 --threads 開啟多線程壓測。另外,如果發(fā)現(xiàn)編譯失敗也莫慌,這是因為 Redis 用了 Atomic_ 特性,更新版本的編譯工具才支持,比如 GCC 5.0 以上版本。
總結(jié)
Redis 6.0 預(yù)計會在 2019 年底發(fā)布,將在性能、協(xié)議以及權(quán)限控制都會有很大的改進。Salvatore 今年全身心投入在優(yōu)化 Redis 和集群的功能,特別值得期待。另外,今年年底社區(qū)也會同時發(fā)布第一個版本 redis cluster proxy 來解決多語言 SDK 兼容的問題,期待在具備 proxy 功能之后 cluster 能在國內(nèi)有更加廣泛的應(yīng)用。
參考:林添毅 《正式支持多線程!Redis 6.0與老版性能對比評測》