小紅書抗住高并發(fā)的背后:Redis 7.0 性能必殺技之 I/O 多線程模型
今天,咱們就詳細的聊下 I/O 多線程模型帶來的效果到底是黛玉騎鬼火,該強強,該弱弱;還是猶如光明頂身懷絕技的的張無忌,招招都是必殺技。
單線程模型真的只有一個線程么?
謝霸哥:“碼哥, Redis 6.0 之前單線程指的是 Redis 只有一個線程干活么?”
非也,我們通常說的單線程模型指的是 Redis 在處理客戶端的請求時,包括獲取 (socket 讀)、解析、執(zhí)行、內(nèi)容返回 (socket 寫) 等都由一個順序串行的主線程處理。
而其他的清理過期鍵值對數(shù)據(jù)、釋放無用連接、內(nèi)存淘汰策略執(zhí)行、BGSAVE 生成 RDB 內(nèi)存快照文件、AOF rewrite 等都是其他線程處理。
命令執(zhí)行階段,每一條命令并不會立馬被執(zhí)行,而是進入一個一個 socket 隊列,當(dāng) socket 事件就緒則交給事件分發(fā)器分發(fā)到對應(yīng)的事件處理器處理,單線程模型的命令處理如下圖所示。
圖片
線程模型的演化
謝霸哥:“為什么 Redis6.0 之前是單線程模型?”
以下是官方關(guān)于為什么 6.0 之前一直使用單線程模型的回答。
- Redis 的性能瓶頸主要在于內(nèi)存和網(wǎng)絡(luò) I/O,CPU 不會是性能瓶頸所在。
- Redis 通過使用 pipelining 每秒可以處理 100 萬個請求,應(yīng)用程序的所時候用的大多數(shù)命令時間復(fù)雜度主要使用 O(N) 或 O(log(N)) 的,它幾乎不會占用太多 CPU。
- 單線程模型的代碼可維護性高。多線程模型雖然在某些方面表現(xiàn)優(yōu)異,但是它卻引入了程序執(zhí)行順序的不確定性,帶來了并發(fā)讀寫的一系列問題,增加了系統(tǒng)復(fù)雜度、同時可能存在線程切換、甚至加鎖解鎖、死鎖造成的性能損耗。
Redis 通過基于 I/O 多路復(fù)用實現(xiàn)的 AE 事件驅(qū)動框架將 I/O 事件和事件事件融合在一起,實現(xiàn)高性能網(wǎng)絡(luò)處理能力,再加上基于內(nèi)存的數(shù)據(jù)處理,沒有引入多線程的必要。
而且單線程機制讓 Redis 內(nèi)部實現(xiàn)的復(fù)雜度大大降低,Hash 的惰性 Rehash、Lpush 等等線程不安全的命令都可以無鎖進行。
謝霸哥:“既然單線程這么好,為什么 6.0 版本引入多線程模型?”
因為隨著底層網(wǎng)絡(luò)硬件性能提升,Redis 的性能瓶頸逐漸體現(xiàn)在網(wǎng)絡(luò) I/O 的讀寫上,單個線程處理網(wǎng)絡(luò)讀寫的速度跟不上底層網(wǎng)絡(luò)硬件執(zhí)行的速度。
因為讀寫網(wǎng)絡(luò)的 read/write 系統(tǒng)調(diào)用占用了 Redis 執(zhí)行期間大部分 CPU 時間。所以 Redis 采用多個 I/O 線程來處理網(wǎng)絡(luò)請求,提高網(wǎng)絡(luò)請求處理的并行度。
需要注意的是,Redis 多 IO 線程模型只用來處理網(wǎng)絡(luò)讀寫請求,對于 Redis 的讀寫命令,依然是單線程處理。
這是因為,網(wǎng)絡(luò) I/O 讀寫是瓶頸,可通過多線程并行處理可提高性能。而繼續(xù)使用單線程執(zhí)行讀寫命令,不需要為了保證 Lua 腳本、事務(wù)、等開發(fā)多線程安全機制,實現(xiàn)更簡單。
謝霸哥:“碼哥,你真是斑馬的腦袋,說的頭頭是道?!?/p>
我謝謝您嘞,主線程與 I/O 多線程共同協(xié)作處理命令的架構(gòu)圖如下所示。
圖片
I/O 多線程模型解讀
謝霸哥:“如何開啟多線程呢?”
Redis 6.0 的多線程默認是禁用的,如需開啟需要修改 redis.conf 配置文件的配置io-threads-do-reads yes。
開啟多線程后,還要設(shè)置線程數(shù)才能生效,同樣是修改 redis.conf配置文件。
io-threads 4
謝霸哥:“碼老師,線程數(shù)是不是越多越好?”
當(dāng)然不是,關(guān)于線程數(shù)的設(shè)置,官方有一個建議:線程數(shù)的數(shù)量最好小于 CPU 核心數(shù),起碼預(yù)留一個空閑核處理,因為 Redis 是主線程處理指令,如果系統(tǒng)出現(xiàn)頻繁上下文切換,效率會降低。
比如 4 核的機器建議設(shè)置為 2 或 3 個線程,8 核的機器建議設(shè)置為 6 個線程,線程數(shù)一定要小于機器核數(shù)。
謝霸哥:“碼老師真厲害,就好像賣盆的進村一套一套的。我什么時候也能像你這樣連貫又有邏輯的掌握 Redis?!?/p>
認真讀 Redis 高手心法,長線放風(fēng)箏慢慢來。
謝霸哥:“主線程與 I/O 線程是如何實現(xiàn)協(xié)作呢?”
圖片
主要流程。
- 主線程負責(zé)接收建立連接請求,通過輪詢將可讀 socket 分配給 I/O 線程綁定的等待隊列。
- 主線程阻塞等待,直到 I/O 線程完成 socket 讀取和解析。
- 主線程執(zhí)行 I/O 線程讀取和解析出來的 Redis 請求命令。
- 主線程阻塞等待 I/O 線程將指令執(zhí)行結(jié)果回寫回 socket完畢。
- 主線程清空等待隊列,等待下一次客戶端后續(xù)的請求。
思路:將主線程 IO 讀寫任務(wù)拆分出來給一組獨立的線程處理,使得多個 socket 讀寫可以并行化,但是 Redis 命令還是主線程串行執(zhí)行。
大家注意第三和第五步,主線程并不是掛起線程讓出 CPU 分片時間。而是通過 for 循環(huán)進行忙等,不斷的檢測所有 I/O 線程處理任務(wù)是否已經(jīng)完成,完成再執(zhí)行下一步。
源碼解析
看完流程圖以及主要步驟,接著跟著源碼走一個。通過 4.3 章節(jié)的學(xué)習(xí),我們知道 Redis 是通過 server.c的main函數(shù)啟動的,經(jīng)過一系列的初始化操作后,調(diào)用 aeMain(server.el);啟動事件驅(qū)動框架,也就是整個 Redis 的核心。
初始化線程
I/O 多線程模型的開端也是由 server.c的main方法中的 InitServerLast來初始化,該方法內(nèi)部會調(diào)用 networking.c 的 initThreadedIO來執(zhí)行實際 I/O 線程初始化工作。
/* networking.c */
void initThreadedIO(void) {
// 設(shè)置成 0 表示激活 I/O 多線程模型
server.io_threads_active = 0;
/* I/O 線程處于空閑狀態(tài) */
io_threads_op = IO_THREADS_OP_IDLE;
/* 如果 redis.conf 的 io-threads 配置為 1 表示使用單線程模型,直接退出 */
if (server.io_threads_num == 1) return;
// 線程數(shù)超過最大值 128,退出程序
if (server.io_threads_num > IO_THREADS_MAX_NUM) {
....省略
exit(1);
}
for (int i = 0; i < server.io_threads_num; i++) {
/* io_threads_list 鏈表,用于存儲該線程要執(zhí)行的 I/O 操作。*/
io_threads_list[i] = listCreate();
// 0 號線程不創(chuàng)建,0 號就是主線程,主線程也會處理任務(wù)邏輯。
if (i == 0) continue;
// 創(chuàng)建線程,主線程先對子線程上鎖,掛起子線程,不讓其進入工作模式
pthread_t tid;
pthread_mutex_init(&io_threads_mutex[i],NULL);
setIOPendingCount(i, 0);
// 掛起子線程,先不進入工作模式,等待主線程發(fā)出干活信號再執(zhí)行任務(wù)。
pthread_mutex_lock(&io_threads_mutex[i]);
// 創(chuàng)建線程,指定I/O線程的入口函數(shù) IOThreadMain
if (pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) != 0) {
serverLog(LL_WARNING,"Fatal: Can't initialize IO thread.");
exit(1);
}
// I/O 線程數(shù)組
io_threads[i] = tid;
}
}
- 檢查是否開啟 I/O 多線程模型:默認不激活 I/O 多線程模型,當(dāng) redis.conf 的 io-threads 配置大于 1 并且小于 IO_THREADS_MAX_NUM(128) 則表示開啟 I/O 多線程模式。
- 創(chuàng)建 io_threads_list 鏈表,用于保存每個線程需要處理的 I/O 任務(wù)。
- 創(chuàng)建子線程,創(chuàng)建的時候先上鎖,掛起子線程不讓其進入工作模式,等初始化工作完成再開啟。
- 指定 I/O 線程的入口函數(shù) IOThreadMain,I/O 線程開始工作。
I/O 線程核心函數(shù)
IOThreadMain 函數(shù)主要負責(zé)等待啟動信號、執(zhí)行特定的 I/O 操作,并在完成操作后重置線程狀態(tài),以便再次等待下一次啟動信號。
void *IOThreadMain(void *myid) {
/* 每個線程創(chuàng)建一個 id */
long id = (unsigned long)myid;
char thdname[16];
.....
// 進入無限循環(huán),等待主線程發(fā)出干活信號
while(1) {
/* 沒有使用 sleep 設(shè)置等待時間實現(xiàn)忙等,而是循環(huán),耗費 CPU*/
for (int j = 0; j < 1000000; j++) {
// 等待待處理的 I/O 操作出現(xiàn),也就是讀寫客戶端數(shù)據(jù)
if (getIOPendingCount(id) != 0) break;
}
/*留機會給主線程上鎖,掛起當(dāng)前子線程 */
if (getIOPendingCount(id) == 0) {
pthread_mutex_lock(&io_threads_mutex[id]);
pthread_mutex_unlock(&io_threads_mutex[id]);
continue;
}
serverAssert(getIOPendingCount(id) != 0);
/* 根據(jù)線程 id 以及待分配列表進行任務(wù)分配 */
listIter li;
listNode *ln;
listRewind(io_threads_list[id],&li);
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
if (io_threads_op == IO_THREADS_OP_WRITE) {
// 將可寫客戶端任務(wù)分配
writeToClient(c,0);
} else if (io_threads_op == IO_THREADS_OP_READ) {
// 讀取客戶端 socket 數(shù)據(jù)
readQueryFromClient(c->conn);
} else {
serverPanic("io_threads_op value is unknown");
}
}
listEmpty(io_threads_list[id]);
setIOPendingCount(id, 0);
}
}
待讀取客戶端任務(wù)分配
Redis 會在主線程 initServer 初始化服務(wù)器的時候會注冊 beforeSleep函數(shù),里面會調(diào)用 handleClientsWithPendingReadsUsingThreads函數(shù)實現(xiàn)待處理任務(wù)分配邏輯。該函數(shù)的主要作用如下。
- 將所有待讀的客戶端平均分配到不同的 I/O 線程的列表中。
- 通過設(shè)置 io_threads_op 和調(diào)用 setIOPendingCount 函數(shù),通知各個 I/O 線程開始處理可讀取的客戶端數(shù)據(jù)。
- 主線程也參與處理客戶端讀取,以確保更好的并發(fā)性能。
- 主線程等待所有 I/O 線程完成讀取 socket 工作。
這種設(shè)計采用了“扇出 -> 扇入”的范式,通過將工作分發(fā)到多個 I/O 線程,再將結(jié)果合并回主線程,以提高并發(fā)性能。
int handleClientsWithPendingReadsUsingThreads(void) {
......
/* 將所有待處理的客戶端平均分配到不同的 I/O 線程的列表中*/
listIter li;
listNode *ln;
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++;
}
/* 通過設(shè)置 `io_threads_op` 和調(diào)用 `setIOPendingCount` 函數(shù),通知各個 I/O 線程開始處理可讀取的客戶端數(shù)據(jù)。 */
io_threads_op = IO_THREADS_OP_READ;
for (int j = 1; j < server.io_threads_num; j++) {
int count = listLength(io_threads_list[j]);
setIOPendingCount(j, count);
}
/* 主線程處理第一個等待隊列任務(wù) */
listRewind(io_threads_list[0],&li);
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
readQueryFromClient(c->conn);
}
listEmpty(io_threads_list[0]);
/* 主線程處理完任務(wù)后,忙等等待所有 I/O 線程完成讀取 socket 工作 */
while(1) {
unsigned long pending = 0;
for (int j = 1; j < server.io_threads_num; j++)
pending += getIOPendingCount(j);
if (pending == 0) break;
}
......
return processed;
}
待寫回客戶端任務(wù)分配
與上面類似, beforeSleep函數(shù)里面會調(diào)用 handleClientsWithPendingWritesUsingThreads函數(shù)實現(xiàn)可寫客戶端處理任務(wù)分配給 I/O 線程,源代碼跟 handleClientsWithPendingReadsUsingThreads類似,不貼了。差別就是這個函數(shù)處理的事情是把響應(yīng)寫回 socket。
- 將所有待寫的客戶端平均分配到不同的 I/O 線程的列表中。
- 設(shè)置 io_threads_op 為 IO_THREADS_OP_READ通知各個 I/O 線程開始處理可寫的客戶端數(shù)據(jù)。
- 主線程也參與處理客戶端讀取,以確保更好的并發(fā)性能。
- 主線程等待所有 I/O 線程完成讀取 socket 工作。
模型缺陷
Redis 的多線程網(wǎng)絡(luò)模型實際上并不是一個標準的 Multi-Reactors/Master-Workers 模型,I/O 線程任務(wù)僅僅是通過 socket 讀取客戶端請求命令并解析,以及把指令執(zhí)行結(jié)果回寫給 socket ,沒有真正去執(zhí)行命令。
所有客戶端命令最后還需要回到主線程去執(zhí)行,因此對多核的利用率并不算高,而且每次主線程都必須在分配完任務(wù)之后忙輪詢等待所有 I/O 線程完成任務(wù)之后才能繼續(xù)執(zhí)行其他邏輯。
在我看來,Redis 目前的多線程方案更像是一個折中的選擇,只是黛玉騎鬼火,還未達到必殺技的階段。