首次支持多線程:Redis 6.0與老版性能對比評測

導讀:Redis 6.0將在今年年底發佈,其中引入的最重大的改變就是多線程IO。本文作者深入閱讀並解析了關鍵代碼,並且做了基準測試,揭示多線程 IO 特性對Redis性能的提升,十分值得一讀。

Redis 作者 Salvatore 在 RedisConf 2019 分享,其中一段展示了 Redis 6 引入的多線程 IO 特性對性能提升至少是一倍以上,內心很是激動,迫不及待地去看了一下相關的代碼實現。

目前對於單線程 Redis 來說,性能瓶頸主要在於網絡的 IO 消耗, 優化主要有兩個方向:

  1. 提高網絡 IO 性能,典型的實現像使用 DPDK 來替代內核網絡棧的方式

  2. 使用多線程充分利用多核,典型的實現像 Memcached

協議棧優化的這種方式跟 Redis 關係不大,多線程特性在社區也被反覆提了很久後終於在 Redis 6 加入多線程,Salvatore 在自己的博客 An update about Redis developments in 2019 也有簡單的說明。但跟 Memcached 這種從 IO 處理到數據訪問多線程的實現模式有些差異。Redis 的多線程部分只是用來處理網絡數據的讀寫和協議解析,執行命令仍然是單線程。之所以這麼設計是不想因爲多線程而變得複雜,需要去控制 key、lua(一種輕量級腳本語言)、事務,LPUSH/LPOP(redis語法:將一個或多個值插入到列表頭部(左邊)、移出並獲取列表的第一個元素(左邊)) 等等的併發問題。整體的設計大體如下:

代碼實現

多線程 IO 的讀(請求)和寫(響應)在實現流程是一樣的,只是執行讀還是寫操作的差異。同時這些 IO 線程在同一時刻全部是讀或者寫,不會部分讀或部分寫的情況,所以下面以讀流程作爲例子。分析過程中的代碼只是爲了輔助理解,所以只會覆蓋核心邏輯而不是全部細節。如果想完全理解細節,建議看完之後再次看一次源碼實現。

加入多線程 IO 之後,整體的讀流程如下:

  1. 主線程負責接收建連請求,讀事件到來(收到請求)則放到一個全局等待讀處理隊列

  2. 主線程處理完讀事件之後,通過 RR(Round Robin) 將這些連接分配給這些 IO 線程,然後主線程忙等待(spinlock 的效果)狀態

  3. IO 線程將請求數據讀取並解析完成(這裏只是讀數據和解析並不執行)

  4. 主線程執行所有命令並清空整個請求等待讀處理隊列(執行部分串行)

上面的這個過程是完全無鎖的,因爲在 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與老版性能對比評測》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章