Redis6新特性之多線程IO


一、Redis單線程

在Redis6.0版本之前,Redis可以被認為是單線程服務(除少量的后台定時任務、文件異步操作、惰性刪除外),它的處理過程主要包括:

1接收命令。通過TCP或者UDP接收到命令
2解析命令。將命令取出來
3執行命令。到對應的地方將value讀出來
4返回結果。將結果返回給客戶端

Redis在啟動后會產生一個死循環aeMain,在這個循環里通過IO多路復用(linux系統采用epoll方式)等待事件發生。

事件分為IO事件和timer事件,timer事件即開頭提到的后台定時任務,如expire key等。IO事件即來自客戶端的命令轉化的事件,由epoll處理。

epoll多路復用的處理結構如下圖所示

即多個網絡連接復用一個IO線程。由於單個線程可以記錄每個socket的狀態來同時管理多個IO流,所以能夠提高服務的吞吐能力。

 

二、單線程IO的瓶頸

前面簡單的介紹了單線程IO的處理過程以及高性能的原因,官方壓測的結果是10萬QPS,我們現網使用經驗在3~4萬QPS。

雖然這個性能指標已經挺高了,但是單線程IO模型有幾個明顯的缺陷:

1、只能使用一個CPU核(忽略后台線程和子線程)

2、如果涉及到大key,Redis的QPS會下降的厲害

3、由於解析和返回的限制,QPS難以進一步提高

針對以上的缺陷,又為了避免多線程執行命令帶來的控制key、lua腳本、事務等並發復雜問題,Redis作者選擇了多線程IO模型,即執行命令仍采用單線程,解析和返回等步驟采用多線程。

 

三、多線程IO

多線程IO的設計思路大體如下圖

 

整體的讀流程包括:

1、主線程負責接收建連請求,讀事件到來(收到請求)則放到一個全局等待讀處理隊列
2、主線程處理完讀事件之后,通過輪詢將這些連接分配給這些 IO 線程,然后主線程忙等待(spinlock 的效果)狀態
3、IO 線程將請求數據讀取並解析完成(這里只是讀數據和解析並不執行)
4、主線程執行所有命令並清空整個請求等待讀處理隊列(執行部分串行)

我們通過源碼來確認上面的處理流程。主線程收到請求時會回調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 函數會直接返回不進行命令解析和執行。
接着主線程在處理完讀事件之后將這些連接通過輪詢的方式分配給這些 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 線程對應需要處理的連接,然后主線程將這些連接分配給這些 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 線程同一時刻只會執行讀或者寫。另外,已經加到等待處理隊列的連接會被設置 CLIENT_PENDING_READ 標識。postponeClientRead 函數不會把連接再次加到隊列,readQueryFromClient 會繼續執行讀取和解析請求。readQueryFromClient 函數讀取請求數據並調用 processInputBuffer 函數進行解析命令,processInputBuffer 會判斷當前連接是否來自 IO 線程,如果是的話就只解析不執行命令。IOThreadMain 線程是沒有任何 sleep 機制,在空閑狀態也會導致每個線程的 CPU 跑到 100%,但簡單 sleep 則會導致讀寫處理不及時而導致性能更差。Redis 當前的解決方式是通過在等待處理連接比較少的時候關閉這些 IO 線程來避免長期CPU跑滿。

 

四、性能對比

本次性能壓測采用了控制單變量法多次測試,變量包括線程數、request請求數、客戶端連接數。一共測試了set、get、incr、hset4種命令。每組參數測試5次取平均值為最終測試結果。詳細測試數據見附件Excel

由於壓測結果顯示QPS與客戶端連接數關系不大。故作圖數據采用get命令,50個客戶端為例,繪制了QPS與線程數及請求數的關系。

結論:

1、隨着IO線程數增多,每個IO線程可支持約4.5萬QPS,呈線性關系,4IO線程時,可達到20萬QPS。但是從4線程IO之后,線程數對QPS的提升效果逐漸降低。

2、隨着IO線程數增多,QPS隨着request的增加有一定的增加,但是差距不明顯。

 

五、建議

由於Redis6支持綁定IO線程、持久化子線程及后台線程,對於QPS高的業務線,可通過Redis配置文件綁定多核開啟多線程IO提高CPU利用率來提高QPS。

相關配置參數如下

io-threads 4
# Setting io-threads to 1 will just use the main thread as usually.
# When I/O threads are enabled, we only use threads for writes, that is
# to thread the write(2) syscall and transfer the client buffers to the
# socket. However it is also possible to enable threading of reads and
# protocol parsing using the following configuration directive, by setting
# it to yes:
 
io-threads-do-reads yes
# Jemalloc background thread for purging will be enabled by default
jemalloc-bg-thread yes

# It is possible to pin different threads and processes of Redis to specific
# CPUs in your system, in order to maximize the performances of the server.
# This is useful both in order to pin different Redis threads in different
# CPUs, but also in order to make sure that multiple Redis instances running
# in the same host will be pinned to different CPUs.
#
# Normally you can do this using the "taskset" command, however it is also
# possible to this via Redis configuration directly, both in Linux and FreeBSD.
#
# You can pin the server/IO threads, bio threads, aof rewrite child process, and
# the bgsave child process. The syntax to specify the cpu list is the same as
# the taskset command:
#
# Set redis server/io threads to cpu affinity 0,2,4,6:
server_cpulist 7-15
#
# Set bio threads to cpu affinity 1,3:
# bio_cpulist 1,3
#
# Set aof rewrite child process to cpu affinity 8,9,10,11:
# aof_rewrite_cpulist 8-11
#
# Set bgsave child process to cpu affinity 1,10,11
# bgsave_cpulist 1,10-11

 

附件:Redis多線程壓測.xlsx


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM