Redis 多線程網絡模型全面揭祕react
在目前的技術選型中,Redis 儼然已經成爲了系統高性能緩存方案的事實標準,所以如今 Redis 也成爲了後端開發的基本技能樹之一,Redis 的底層原理也瓜熟蒂落地成爲了必須學習的知識。linux
Redis 從本質上來說是一個網絡服務器,而對於一個網絡服務器來講,網絡模型是它的精華,搞懂了一個網絡服務器的網絡模型,你也就搞懂了它的本質。git
本文經過層層遞進的方式,介紹了 Redis 網絡模型的版本變動歷程,剖析了其從單線程進化到多線程的工做原理,此外,還一併分析並解答了 Redis 的網絡模型的不少抉擇背後的思考,幫助讀者能更深入地理解 Redis 網絡模型的設計。github
根據官方的 benchmark,一般來講,在一臺普通硬件配置的 Linux 機器上跑單個 Redis 實例,處理簡單命令(時間複雜度 O(N) 或者 O(log(N))),QPS 能夠達到 8w+,而若是使用 pipeline 批處理功能,則 QPS 至高能達到 100w。redis
僅從性能層面進行評判,Redis 徹底能夠被稱之爲高性能緩存方案。shell
Redis 的高性能得益於如下幾個基礎:數據庫
Redis 的核心網絡模型選擇用單線程來實現,這在一開始就引發了不少人的不解,Redis 官方的對於此的回答是:編程
It's not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.
核心意思就是,對於一個 DB 來講,CPU 一般不會是瓶頸,由於大多數請求不會是 CPU 密集型的,而是 I/O 密集型。具體到 Redis 的話,若是不考慮 RDB/AOF 等持久化方案,Redis 是徹底的純內存操做,執行速度是很是快的,所以這部分操做一般不會是性能瓶頸,Redis 真正的性能瓶頸在於網絡 I/O,也就是客戶端和服務端之間的網絡傳輸延遲,所以 Redis 選擇了單線程的 I/O 多路複用來實現它的核心網絡模型。後端
上面是比較籠統的官方答案,實際上更加具體的選擇單線程的緣由能夠概括以下:設計模式
多線程調度過程當中必然須要在 CPU 之間切換線程上下文 context,而上下文的切換又涉及程序計數器、堆棧指針和程序狀態字等一系列的寄存器置換、程序堆棧重置甚至是 CPU 高速緩存、TLB 快表的汰換,若是是進程內的多線程切換還好一些,由於單一進程內多線程共享進程地址空間,所以線程上下文比之進程上下文要小得多,若是是跨進程調度,則須要切換掉整個進程地址空間。
若是是單線程則能夠規避進程內頻繁的線程切換開銷,由於程序始終運行在進程中單個線程內,沒有多線程切換的場景。
若是 Redis 選擇多線程模型,又由於 Redis 是一個數據庫,那麼勢必涉及到底層數據同步的問題,則必然會引入某些同步機制,好比鎖,而咱們知道 Redis 不只僅提供了簡單的 key-value 數據結構,還有 list、set 和 hash 等等其餘豐富的數據結構,而不一樣的數據結構對同步訪問的加鎖粒度又不盡相同,可能會致使在操做數據過程當中帶來不少加鎖解鎖的開銷,增長程序複雜度的同時還會下降性能。
Redis 的做者 Salvatore Sanfilippo (別稱 antirez) 對 Redis 的設計和代碼有着近乎偏執的簡潔性理念,你能夠在閱讀 Redis 的源碼或者給 Redis 提交 PR 的之時感覺到這份偏執。所以代碼的簡單可維護性必然是 Redis 早期的核心準則之一,而引入多線程必然會致使代碼的複雜度上升和可維護性降低。
事實上,多線程編程也不是那麼盡善盡美,首先多線程的引入會使得程序再也不保持代碼邏輯上的串行性,代碼執行的順序將變成不可預測的,稍不注意就會致使程序出現各類併發編程的問題;其次,多線程模式也使得程序調試更加複雜和麻煩。網絡上有一幅頗有意思的圖片,生動形象地描述了併發編程面臨的窘境。
你指望的多線程編程 VS 實際上的多線程編程:
前面咱們提到引入多線程必須的同步機制,若是 Redis 使用多線程模式,那麼全部的底層數據結構都必須實現成線程安全的,這無疑又使得 Redis 的實現變得更加複雜。
總而言之,Redis 選擇單線程能夠說是多方博弈以後的一種權衡:在保證足夠的性能表現之下,使用單線程保持代碼的簡單和可維護性。
在討論這個問題以前,咱們要先明確『單線程』這個概念的邊界:它的覆蓋範圍是核心網絡模型,抑或是整個 Redis?若是是前者,那麼答案是確定的,在 Redis 的 v6.0 版本正式引入多線程以前,其網絡模型一直是單線程模式的;若是是後者,那麼答案則是否認的,Redis 早在 v4.0 就已經引入了多線程。
所以,當咱們討論 Redis 的多線程之時,有必要對 Redis 的版本劃出兩個重要的節點:
咱們首先來剖析一下 Redis 的核心網絡模型,從 Redis 的 v1.0 到 v6.0 版本以前,Redis 的核心網絡模型一直是一個典型的單 Reactor 模型:利用 epoll/select/kqueue 等多路複用技術,在單線程的事件循環中不斷去處理事件(客戶端請求),最後回寫響應數據到客戶端:
這裏有幾個核心的概念須要學習:
封裝的套接字鏈接 -- *conn
,當前選擇的數據庫指針 -- *db
,讀入緩衝區 -- querybuf
,寫出緩衝區 -- buf
,寫出數據鏈表 -- reply
等。accept
接受來自客戶端的新鏈接,併爲新鏈接註冊綁定命令讀取處理器,以備後續處理新的客戶端 TCP 鏈接;除了這個處理器,還有對應的 acceptUnixHandler
負責處理 Unix Domain Socket 以及 acceptTLSHandler
負責處理 TLS 加密鏈接。client->buf
或者 client->reply
(後面會解釋爲何這裏須要兩個緩衝區)中的響應寫回到客戶端,持久化 AOF 緩衝區的數據到磁盤等,相對應的還有一個 afterSleep 函數,在 aeApiPoll 以後執行。Redis 內部實現了一個高性能的事件庫 --- AE,基於 epoll/select/kqueue/evport 四種事件驅動技術,實現 Linux/MacOS/FreeBSD/Solaris 多平臺的高性能事件循環模型。Redis 的核心網絡模型正式構築在 AE 之上,包括 I/O 多路複用、各種處理器的註冊綁定,都是基於此才得以運行。
至此,咱們能夠描繪出客戶端向 Redis 發起請求命令的工做原理:
acceptTcpHandler
鏈接應答處理器到用戶配置的監聽端口對應的文件描述符,等待新鏈接到來;acceptTcpHandler
被調用,主線程使用 AE 的 API 將 readQueryFromClient
命令讀取處理器綁定到新鏈接對應的文件描述符上,並初始化一個 client
綁定這個客戶端鏈接;readQueryFromClient
經過 socket 讀取客戶端發送過來的命令存入 client->querybuf
讀入緩衝區;processInputBuffer
,在其中使用 processInlineBuffer
或者 processMultibulkBuffer
根據 Redis 協議解析命令,最後調用 processCommand
執行命令;addReply
函數族的一系列函數將響應數據寫入到對應 client
的寫出緩衝區:client->buf
或者 client->reply
,client->buf
是首選的寫出緩衝區,固定大小 16KB,通常來講能夠緩衝足夠多的響應數據,可是若是客戶端在時間窗口內須要響應的數據很是大,那麼則會自動切換到 client->reply
鏈表上去,使用鏈表理論上可以保存無限大的數據(受限於機器的物理內存),最後把 client
添加進一個 LIFO 隊列 clients_pending_write
;beforeSleep
--> handleClientsWithPendingWrites
,遍歷 clients_pending_write
隊列,調用 writeToClient
把 client
的寫出緩衝區裏的數據回寫到客戶端,若是寫出緩衝區還有數據遺留,則註冊 sendReplyToClient
命令回覆處理器到該鏈接的寫就緒事件,等待客戶端可寫時在事件循環中再繼續回寫殘餘的響應數據。對於那些想利用多核優點提高性能的用戶來講,Redis 官方給出的解決方案也很是簡單粗暴:在同一個機器上多跑幾個 Redis 實例。事實上,爲了保證高可用,線上業務通常不太可能會是單機模式,更加常見的是利用 Redis 分佈式集羣多節點和數據分片負載均衡來提高性能和保證高可用。
以上即是 Redis 的核心網絡模型,這個單線程網絡模型一直到 Redis v6.0 才改形成多線程模式,但這並不意味着整個 Redis 一直都只是單線程。
Redis 在 v4.0 版本的時候就已經引入了的多線程來作一些異步操做,此舉主要針對的是那些很是耗時的命令,經過將這些命令的執行進行異步化,避免阻塞單線程的事件循環。
咱們知道 Redis 的 DEL
命令是用來刪除掉一個或多個 key 儲存的值,它是一個阻塞的命令,大多數狀況下你要刪除的 key 裏存的值不會特別多,最多也就幾十上百個對象,因此能夠很快執行完,可是若是你要刪的是一個超大的鍵值對,裏面有幾百萬個對象,那麼這條命令可能會阻塞至少好幾秒,又由於事件循環是單線程的,因此會阻塞後面的其餘事件,致使吞吐量降低。
Redis 的做者 antirez 爲了解決這個問題進行了不少思考,一開始他想的辦法是一種漸進式的方案:利用定時器和數據遊標,每次只刪除一小部分的數據,好比 1000 個對象,最終清除掉全部的數據,可是這種方案有個致命的缺陷,若是同時還有其餘客戶端往某個正在被漸進式刪除的 key 裏繼續寫入數據,並且刪除的速度跟不上寫入的數據,那麼將會無止境地消耗內存,雖而後來經過一個巧妙的辦法解決了,可是這種實現使 Redis 變得更加複雜,而多線程看起來彷佛是一個水到渠成的解決方案:簡單、易理解。因而,最終 antirez 選擇引入多線程來實現這一類非阻塞的命令。更多 antirez 在這方面的思考能夠閱讀一下他發表的博客:Lazy Redis is better Redis。
因而,在 Redis v4.0 以後增長了一些的非阻塞命令如 UNLINK
、FLUSHALL ASYNC
、FLUSHDB ASYNC
。
UNLINK
命令其實就是 DEL
的異步版本,它不會同步刪除數據,而只是把 key 從 keyspace 中暫時移除掉,而後將任務添加到一個異步隊列,最後由後臺線程去刪除,不過這裏須要考慮一種狀況是若是用 UNLINK
去刪除一個很小的 key,用異步的方式去作反而開銷更大,因此它會先計算一個開銷的閥值,只有當這個值大於 64 纔會使用異步的方式去刪除 key,對於基本的數據類型如 List、Set、Hash 這些,閥值就是其中存儲的對象數量。
前面提到 Redis 最初選擇單線程網絡模型的理由是:CPU 一般不會成爲性能瓶頸,瓶頸每每是內存和網絡,所以單線程足夠了。那麼爲何如今 Redis 又要引入多線程呢?很簡單,就是 Redis 的網絡 I/O 瓶頸已經愈來愈明顯了。
隨着互聯網的飛速發展,互聯網業務系統所要處理的線上流量愈來愈大,Redis 的單線程模式會致使系統消耗不少 CPU 時間在網絡 I/O 上從而下降吞吐量,要提高 Redis 的性能有兩個方向:
後者依賴於硬件的發展,暫時無解。因此只能從前者下手,網絡 I/O 的優化又能夠分爲兩個方向:
零拷貝技術有其侷限性,沒法徹底適配 Redis 這一類複雜的網絡 I/O 場景,更多網絡 I/O 對 CPU 時間的消耗和 Linux 零拷貝技術,能夠閱讀個人另外一篇文章:Linux I/O 原理和 Zero-copy 技術全面揭祕。而 DPDK 技術經過旁路網卡 I/O 繞過內核協議棧的方式又太過於複雜以及須要內核甚至是硬件的支持。
所以,利用多核優點成爲了優化網絡 I/O 性價比最高的方案。
6.0 版本以後,Redis 正式在覈心網絡模型中引入了多線程,也就是所謂的 I/O threading,至此 Redis 真正擁有了多線程模型。前一小節,咱們瞭解了 Redis 在 6.0 版本以前的單線程事件循環模型,實際上就是一個很是經典的 Reactor 模型:
目前 Linux 平臺上主流的高性能網絡庫/框架中,大都採用 Reactor 模式,好比 netty、libevent、libuv、POE(Perl)、Twisted(Python)等。
Reactor 模式本質上指的是使用 I/O 多路複用(I/O multiplexing) + 非阻塞 I/O(non-blocking I/O)
的模式。
更多關於 Reactor 模式的細節能夠參考我以前的文章:Go netpoller 原生網絡模型之源碼全面揭祕,Reactor 網絡模型那一小節,這裏再也不贅述。
Redis 的核心網絡模型在 6.0 版本以前,一直是單 Reactor 模式:全部事件的處理都在單個線程內完成,雖然在 4.0 版本中引入了多線程,可是那個更像是針對特定場景(刪除超大 key 值等)而打的補丁,並不能被視做核心網絡模型的多線程。
一般來講,單 Reactor 模式,引入多線程以後會進化爲 Multi-Reactors 模式,基本工做模式以下:
區別於單 Reactor 模式,這種模式再也不是單線程的事件循環,而是有多個線程(Sub Reactors)各自維護一個獨立的事件循環,由 Main Reactor 負責接收新鏈接並分發給 Sub Reactors 去獨立處理,最後 Sub Reactors 回寫響應給客戶端。
Multiple Reactors 模式一般也能夠等同於 Master-Workers 模式,好比 Nginx 和 Memcached 等就是採用這種多線程模型,雖然不一樣的項目實現細節略有區別,但整體來講模式是一致的。
Redis 雖然也實現了多線程,可是卻不是標準的 Multi-Reactors/Master-Workers 模式,這其中的原因咱們後面會分析,如今咱們先看一下 Redis 多線程網絡模型的整體設計:
acceptTcpHandler
鏈接應答處理器到用戶配置的監聽端口對應的文件描述符,等待新鏈接到來;acceptTcpHandler
被調用,主線程使用 AE 的 API 將 readQueryFromClient
命令讀取處理器綁定到新鏈接對應的文件描述符上,並初始化一個 client
綁定這個客戶端鏈接;client
放入一個 LIFO 隊列 clients_pending_read
;beforeSleep
-->handleClientsWithPendingReadsUsingThreads
,利用 Round-Robin 輪詢負載均衡策略,把 clients_pending_read
隊列中的鏈接均勻地分配給 I/O 線程各自的本地 FIFO 任務隊列 io_threads_list[id]
和主線程本身,I/O 線程經過 socket 讀取客戶端的請求命令,存入 client->querybuf
並解析第一個命令,但不執行命令,主線程忙輪詢,等待全部 I/O 線程完成讀取任務;clients_pending_read
隊列,執行全部客戶端鏈接的請求命令,先調用 processCommandAndResetClient
執行第一條已經解析好的命令,而後調用 processInputBuffer
解析並執行客戶端鏈接的全部命令,在其中使用 processInlineBuffer
或者 processMultibulkBuffer
根據 Redis 協議解析命令,最後調用 processCommand
執行命令;addReply
函數族的一系列函數將響應數據寫入到對應 client
的寫出緩衝區:client->buf
或者 client->reply
,client->buf
是首選的寫出緩衝區,固定大小 16KB,通常來講能夠緩衝足夠多的響應數據,可是若是客戶端在時間窗口內須要響應的數據很是大,那麼則會自動切換到 client->reply
鏈表上去,使用鏈表理論上可以保存無限大的數據(受限於機器的物理內存),最後把 client
添加進一個 LIFO 隊列 clients_pending_write
;beforeSleep
--> handleClientsWithPendingWritesUsingThreads
,利用 Round-Robin 輪詢負載均衡策略,把 clients_pending_write
隊列中的鏈接均勻地分配給 I/O 線程各自的本地 FIFO 任務隊列 io_threads_list[id]
和主線程本身,I/O 線程經過調用 writeToClient
把 client
的寫出緩衝區裏的數據回寫到客戶端,主線程忙輪詢,等待全部 I/O 線程完成寫出任務;clients_pending_write
隊列,若是 client
的寫出緩衝區還有數據遺留,則註冊 sendReplyToClient
到該鏈接的寫就緒事件,等待客戶端可寫時在事件循環中再繼續回寫殘餘的響應數據。這裏大部分邏輯和以前的單線程模型是一致的,變更的地方僅僅是把讀取客戶端請求命令和回寫響應數據的邏輯異步化了,交給 I/O 線程去完成,這裏須要特別注意的一點是:I/O 線程僅僅是讀取和解析客戶端命令而不會真正去執行命令,客戶端命令的執行最終仍是要在主線程上完成。
如下全部代碼基於目前最新的 Redis v6.0.10 版本。
void initThreadedIO(void) { server.io_threads_active = 0; /* We start with threads not active. */ // 若是用戶只配置了一個 I/O 線程,則不會建立新線程(效率低),直接在主線程裏處理 I/O。 if (server.io_threads_num == 1) return; if (server.io_threads_num > IO_THREADS_MAX_NUM) { serverLog(LL_WARNING,"Fatal: too many I/O threads configured. " "The maximum number is %d.", IO_THREADS_MAX_NUM); exit(1); } // 根據用戶配置的 I/O 線程數,啓動線程。 for (int i = 0; i < server.io_threads_num; i++) { // 初始化 I/O 線程的本地任務隊列。 io_threads_list[i] = listCreate(); if (i == 0) continue; // 線程 0 是主線程。 // 初始化 I/O 線程並啓動。 pthread_t tid; // 每一個 I/O 線程會分配一個本地鎖,用來休眠和喚醒線程。 pthread_mutex_init(&io_threads_mutex[i],NULL); // 每一個 I/O 線程分配一個原子計數器,用來記錄當前遺留的任務數量。 io_threads_pending[i] = 0; // 主線程在啓動 I/O 線程的時候會默認先鎖住它,直到有 I/O 任務才喚醒它。 pthread_mutex_lock(&io_threads_mutex[i]); // 啓動線程,進入 I/O 線程的主邏輯函數 IOThreadMain。 if (pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) != 0) { serverLog(LL_WARNING,"Fatal: Can't initialize IO thread."); exit(1); } io_threads[i] = tid; } }
initThreadedIO
會在 Redis 服務器啓動時的初始化工做的末尾被調用,初始化 I/O 多線程並啓動。
Redis 的多線程模式默認是關閉的,須要用戶在 redis.conf
配置文件中開啓:
io-threads 4 io-threads-do-reads yes
當客戶端發送請求命令以後,會觸發 Redis 主線程的事件循環,命令處理器 readQueryFromClient
被回調,在之前的單線程模型下,這個方法會直接讀取解析客戶端命令並執行,可是多線程模式下,則會把 client
加入到 clients_pending_read
任務隊列中去,後面主線程再分配到 I/O 線程去讀取客戶端請求命令:
void readQueryFromClient(connection *conn) { client *c = connGetPrivateData(conn); int nread, readlen; size_t qblen; // 檢查是否開啓了多線程,若是是則把 client 加入異步隊列以後返回。 if (postponeClientRead(c)) return; // 省略代碼,下面的代碼邏輯和單線程版本幾乎是同樣的。 ... } int postponeClientRead(client *c) { // 當多線程 I/O 模式開啓、主線程沒有在處理阻塞任務時,將 client 加入異步隊列。 if (server.io_threads_active && server.io_threads_do_reads && !ProcessingEventsWhileBlocked && !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ))) { // 給 client 打上 CLIENT_PENDING_READ 標識,表示該 client 須要被多線程處理, // 後續在 I/O 線程中會在讀取和解析完客戶端命令以後判斷該標識並放棄執行命令,讓主線程去執行。 c->flags |= CLIENT_PENDING_READ; listAddNodeHead(server.clients_pending_read,c); return 1; } else { return 0; } }
接着主線程會在事件循環的 beforeSleep()
方法中,調用 handleClientsWithPendingReadsUsingThreads
:
int handleClientsWithPendingReadsUsingThreads(void) { if (!server.io_threads_active || !server.io_threads_do_reads) return 0; int processed = listLength(server.clients_pending_read); if (processed == 0) return 0; if (tio_debug) printf("%d TOTAL READ pending clients\n", processed); // 遍歷待讀取的 client 隊列 clients_pending_read, // 經過 RR 輪詢均勻地分配給 I/O 線程和主線程本身(編號 0)。 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++; } // 設置當前 I/O 操做爲讀取操做,給每一個 I/O 線程的計數器設置分配的任務數量, // 讓 I/O 線程能夠開始工做:只讀取和解析命令,不執行。 io_threads_op = IO_THREADS_OP_READ; for (int j = 1; j < server.io_threads_num; j++) { int count = listLength(io_threads_list[j]); io_threads_pending[j] = count; } // 主線程本身也會去執行讀取客戶端請求命令的任務,以達到最大限度利用 CPU。 listRewind(io_threads_list[0],&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln); readQueryFromClient(c->conn); } listEmpty(io_threads_list[0]); // 忙輪詢,累加全部 I/O 線程的原子任務計數器,直到全部計數器的遺留任務數量都是 0, // 表示全部任務都已經執行完成,結束輪詢。 while(1) { unsigned long pending = 0; for (int j = 1; j < server.io_threads_num; j++) pending += io_threads_pending[j]; if (pending == 0) break; } if (tio_debug) printf("I/O READ All threads finshed\n"); // 遍歷待讀取的 client 隊列,清除 CLIENT_PENDING_READ 和 CLIENT_PENDING_COMMAND 標記, // 而後解析並執行全部 client 的命令。 while(listLength(server.clients_pending_read)) { ln = listFirst(server.clients_pending_read); client *c = listNodeValue(ln); c->flags &= ~CLIENT_PENDING_READ; listDelNode(server.clients_pending_read,ln); if (c->flags & CLIENT_PENDING_COMMAND) { c->flags &= ~CLIENT_PENDING_COMMAND; // client 的第一條命令已經被解析好了,直接嘗試執行。 if (processCommandAndResetClient(c) == C_ERR) { /* If the client is no longer valid, we avoid * processing the client later. So we just go * to the next. */ continue; } } processInputBuffer(c); // 繼續解析並執行 client 命令。 // 命令執行完成以後,若是 client 中有響應數據須要回寫到客戶端,則將 client 加入到待寫出隊列 clients_pending_write if (!(c->flags & CLIENT_PENDING_WRITE) && clientHasPendingReplies(c)) clientInstallWriteHandler(c); } /* Update processed count on server */ server.stat_io_reads_processed += processed; return processed; }
這裏的核心工做是:
client
隊列 clients_pending_read
,經過 RR 策略把全部任務分配給 I/O 線程和主線程去讀取和解析客戶端命令。clients_pending_read
,執行全部 client
的命令。完成命令的讀取、解析以及執行以後,客戶端命令的響應數據已經存入 client->buf
或者 client->reply
中了,接下來就須要把響應數據回寫到客戶端了,仍是在 beforeSleep
中, 主線程調用 handleClientsWithPendingWritesUsingThreads
:
int handleClientsWithPendingWritesUsingThreads(void) { int processed = listLength(server.clients_pending_write); if (processed == 0) return 0; /* Return ASAP if there are no clients. */ // 若是用戶設置的 I/O 線程數等於 1 或者當前 clients_pending_write 隊列中待寫出的 client // 數量不足 I/O 線程數的兩倍,則不用多線程的邏輯,讓全部 I/O 線程進入休眠, // 直接在主線程把全部 client 的相應數據回寫到客戶端。 if (server.io_threads_num == 1 || stopThreadedIOIfNeeded()) { return handleClientsWithPendingWrites(); } // 喚醒正在休眠的 I/O 線程(若是有的話)。 if (!server.io_threads_active) startThreadedIO(); if (tio_debug) printf("%d TOTAL WRITE pending clients\n", processed); // 遍歷待寫出的 client 隊列 clients_pending_write, // 經過 RR 輪詢均勻地分配給 I/O 線程和主線程本身(編號 0)。 listIter li; listNode *ln; listRewind(server.clients_pending_write,&li); int item_id = 0; while((ln = listNext(&li))) { client *c = listNodeValue(ln); c->flags &= ~CLIENT_PENDING_WRITE; /* Remove clients from the list of pending writes since * they are going to be closed ASAP. */ if (c->flags & CLIENT_CLOSE_ASAP) { listDelNode(server.clients_pending_write, ln); continue; } int target_id = item_id % server.io_threads_num; listAddNodeTail(io_threads_list[target_id],c); item_id++; } // 設置當前 I/O 操做爲寫出操做,給每一個 I/O 線程的計數器設置分配的任務數量, // 讓 I/O 線程能夠開始工做,把寫出緩衝區(client->buf 或 c->reply)中的響應數據回寫到客戶端。 io_threads_op = IO_THREADS_OP_WRITE; for (int j = 1; j < server.io_threads_num; j++) { int count = listLength(io_threads_list[j]); io_threads_pending[j] = count; } // 主線程本身也會去執行讀取客戶端請求命令的任務,以達到最大限度利用 CPU。 listRewind(io_threads_list[0],&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln); writeToClient(c,0); } listEmpty(io_threads_list[0]); // 忙輪詢,累加全部 I/O 線程的原子任務計數器,直到全部計數器的遺留任務數量都是 0。 // 表示全部任務都已經執行完成,結束輪詢。 while(1) { unsigned long pending = 0; for (int j = 1; j < server.io_threads_num; j++) pending += io_threads_pending[j]; if (pending == 0) break; } if (tio_debug) printf("I/O WRITE All threads finshed\n"); // 最後再遍歷一次 clients_pending_write 隊列,檢查是否還有 client 的寫出緩衝區中有殘留數據, // 若是有,那就爲 client 註冊一個命令回覆器 sendReplyToClient,等待客戶端寫就緒再繼續把數據回寫。 listRewind(server.clients_pending_write,&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln); // 檢查 client 的寫出緩衝區是否還有遺留數據。 if (clientHasPendingReplies(c) && connSetWriteHandler(c->conn, sendReplyToClient) == AE_ERR) { freeClientAsync(c); } } listEmpty(server.clients_pending_write); /* Update processed count on server */ server.stat_io_writes_processed += processed; return processed; }
這裏的核心工做是:
client
隊列 clients_pending_write
,經過 RR 策略把全部任務分配給 I/O 線程和主線程去將響應數據寫回到客戶端。clients_pending_write
,爲那些還殘留有響應數據的 client
註冊命令回覆處理器 sendReplyToClient
,等待客戶端可寫以後在事件循環中繼續回寫殘餘的響應數據。void *IOThreadMain(void *myid) { /* The ID is the thread number (from 0 to server.iothreads_num-1), and is * used by the thread to just manipulate a single sub-array of clients. */ long id = (unsigned long)myid; char thdname[16]; snprintf(thdname, sizeof(thdname), "io_thd_%ld", id); redis_set_thread_title(thdname); // 設置 I/O 線程的 CPU 親和性,儘量將 I/O 線程(以及主線程,不在這裏設置)綁定到用戶配置的 // CPU 列表上。 redisSetCpuAffinity(server.server_cpulist); makeThreadKillable(); while(1) { // 忙輪詢,100w 次循環,等待主線程分配 I/O 任務。 for (int j = 0; j < 1000000; j++) { if (io_threads_pending[id] != 0) break; } // 若是 100w 次忙輪詢以後若是仍是沒有任務分配給它,則經過嘗試加鎖進入休眠, // 等待主線程分配任務以後調用 startThreadedIO 解鎖,喚醒 I/O 線程去執行。 if (io_threads_pending[id] == 0) { pthread_mutex_lock(&io_threads_mutex[id]); pthread_mutex_unlock(&io_threads_mutex[id]); continue; } serverAssert(io_threads_pending[id] != 0); if (tio_debug) printf("[%ld] %d to handle\n", id, (int)listLength(io_threads_list[id])); // 注意:主線程分配任務給 I/O 線程之時, // 會把任務加入每一個線程的本地任務隊列 io_threads_list[id], // 可是當 I/O 線程開始執行任務以後,主線程就不會再去訪問這些任務隊列,避免數據競爭。 listIter li; listNode *ln; listRewind(io_threads_list[id],&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln); // 若是當前是寫出操做,則把 client 的寫出緩衝區中的數據回寫到客戶端。 if (io_threads_op == IO_THREADS_OP_WRITE) { writeToClient(c,0); // 若是當前是讀取操做,則socket 讀取客戶端的請求命令並解析第一條命令。 } else if (io_threads_op == IO_THREADS_OP_READ) { readQueryFromClient(c->conn); } else { serverPanic("io_threads_op value is unknown"); } } listEmpty(io_threads_list[id]); // 全部任務執行完以後把本身的計數器置 0,主線程經過累加全部 I/O 線程的計數器 // 判斷是否全部 I/O 線程都已經完成工做。 io_threads_pending[id] = 0; if (tio_debug) printf("[%ld] Done\n", id); } }
I/O 線程啓動以後,會先進入忙輪詢,判斷原子計數器中的任務數量,若是是非 0 則表示主線程已經給它分配了任務,開始執行任務,不然就一直忙輪詢一百萬次等待,忙輪詢結束以後再查看計數器,若是仍是 0,則嘗試加本地鎖,由於主線程在啓動 I/O 線程之時就已經提早鎖住了全部 I/O 線程的本地鎖,所以 I/O 線程會進行休眠,等待主線程喚醒。
主線程會在每次事件循環中嘗試調用 startThreadedIO
喚醒 I/O 線程去執行任務,若是接收到客戶端請求命令,則 I/O 線程會被喚醒開始工做,根據主線程設置的 io_threads_op
標識去執行命令讀取和解析或者回寫響應數據的任務,I/O 線程在收到主線程通知以後,會遍歷本身的本地任務隊列 io_threads_list[id]
,取出一個個 client
執行任務:
writeToClient
,經過 socket 把 client->buf
或者 client->reply
裏的響應數據回寫到客戶端。readQueryFromClient
,經過 socket 讀取客戶端命令,存入 client->querybuf
,而後調用 processInputBuffer
去解析命令,這裏最終只會解析到第一條命令,而後就結束,不會去執行命令。void processInputBuffer(client *c) { // 省略代碼 ... while(c->qb_pos < sdslen(c->querybuf)) { /* Return if clients are paused. */ if (!(c->flags & CLIENT_SLAVE) && clientsArePaused()) break; /* Immediately abort if the client is in the middle of something. */ if (c->flags & CLIENT_BLOCKED) break; /* Don't process more buffers from clients that have already pending * commands to execute in c->argv. */ if (c->flags & CLIENT_PENDING_COMMAND) break; /* Multibulk processing could see a <= 0 length. */ if (c->argc == 0) { resetClient(c); } else { // 判斷 client 是否具備 CLIENT_PENDING_READ 標識,若是是處於多線程 I/O 的模式下, // 那麼此前已經在 readQueryFromClient -> postponeClientRead 中爲 client 打上該標識, // 則馬上跳出循環結束,此時第一條命令已經解析完成,可是不執行命令。 if (c->flags & CLIENT_PENDING_READ) { c->flags |= CLIENT_PENDING_COMMAND; break; } // 執行客戶端命令 if (processCommandAndResetClient(c) == C_ERR) { /* If the client is no longer valid, we avoid exiting this * loop and trimming the client buffer later. So we return * ASAP in that case. */ return; } } } ... }
這裏須要額外關注 I/O 線程初次啓動時會設置當前線程的 CPU 親和性,也就是綁定當前線程到用戶配置的 CPU 上,在啓動 Redis 服務器主線程的時候一樣會設置 CPU 親和性,Redis 的核心網絡模型引入多線程以後,加上以前的多線程異步任務、多進程(BGSAVE、AOF、BIO、Sentinel 腳本任務等),Redis 現現在的系統併發度已經很大了,而 Redis 自己又是一個對吞吐量和延遲極度敏感的系統,因此用戶須要 Redis 對 CPU 資源有更細粒度的控制,這裏主要考慮的是兩方面:CPU 高速緩存和 NUMA 架構。
首先是 CPU 高速緩存(這裏討論的是 L1 Cache 和 L2 Cache 都集成在 CPU 中的硬件架構),這裏想象一種場景:Redis 主進程正在 CPU-1 上運行,給客戶端提供數據服務,此時 Redis 啓動了子進程進行數據持久化(BGSAVE 或者 AOF),系統調度以後子進程搶佔了主進程的 CPU-1,主進程被調度到 CPU-2 上去運行,致使以前 CPU-1 的高速緩存裏的相關指令和數據被汰換掉,CPU-2 須要從新加載指令和數據到本身的本地高速緩存裏,浪費 CPU 資源,下降性能。
所以,Redis 經過設置 CPU 親和性,能夠將主進程/線程和子進程/線程綁定到不一樣的核隔離開來,使之互不干擾,能有效地提高系統性能。
其次是基於 NUMA 架構的考慮,在 NUMA 體系下,內存控制器芯片被集成處處理器內部,造成 CPU 本地內存,訪問本地內存只需經過內存通道而無需通過系統總線,訪問時延大大下降,而多個處理器之間經過 QPI 數據鏈路互聯,跨 NUMA 節點的內存訪問開銷遠大於本地內存的訪問:
所以,Redis 經過設置 CPU 親和性,讓主進程/線程儘量在固定的 NUMA 節點上的 CPU 上運行,更多地使用本地內存而不須要跨節點訪問數據,一樣也能大大地提高性能。
關於 NUMA 相關知識請讀者自行查閱,篇幅所限這裏就再也不展開,之後有時間我再單獨寫一篇文章介紹。
最後還有一點,閱讀過源碼的讀者可能會有疑問,Redis 的多線程模式下,彷佛並無對數據進行鎖保護,事實上 Redis 的多線程模型是全程無鎖(Lock-free)的,這是經過原子操做+交錯訪問來實現的,主線程和 I/O 線程之間共享的變量有三個:io_threads_pending
計數器、io_threads_op
I/O 標識符和 io_threads_list
線程本地任務隊列。
io_threads_pending
是原子變量,不須要加鎖保護,io_threads_op
和 io_threads_list
這兩個變量則是經過控制主線程和 I/O 線程交錯訪問來規避共享數據競爭問題:I/O 線程啓動以後會經過忙輪詢和鎖休眠等待主線程的信號,在這以前它不會去訪問本身的本地任務隊列 io_threads_list[id]
,而主線程會在分配完全部任務到各個 I/O 線程的本地隊列以後纔去喚醒 I/O 線程開始工做,而且主線程以後在 I/O 線程運行期間只會訪問本身的本地任務隊列 io_threads_list[0]
而不會再去訪問 I/O 線程的本地隊列,這也就保證了主線程永遠會在 I/O 線程以前訪問 io_threads_list
而且以後再也不訪問,保證了交錯訪問。io_threads_op
同理,主線程會在喚醒 I/O 線程以前先設置好 io_threads_op
的值,而且在 I/O 線程運行期間不會再去訪問這個變量。
Redis 將核心網絡模型改形成多線程模式追求的固然是最終性能上的提高,因此最終仍是要以 benchmark 數據見真章:
測試數據代表,Redis 在使用多線程模式以後性能大幅提高,達到了一倍。更詳細的性能壓測數據能夠參閱這篇文章:Benchmarking the experimental Redis Multi-Threaded I/O。
如下是美圖技術團隊實測的新舊 Redis 版本性能對比圖,僅供參考:
首先第一個就是我前面提到過的,Redis 的多線程網絡模型實際上並非一個標準的 Multi-Reactors/Master-Workers 模型,和其餘主流的開源網絡服務器的模式有所區別,最大的不一樣就是在標準的 Multi-Reactors/Master-Workers 模式下,Sub Reactors/Workers 會完成 網絡讀 -> 數據解析 -> 命令執行 -> 網絡寫
整套流程,Main Reactor/Master 只負責分派任務,而在 Redis 的多線程方案中,I/O 線程任務僅僅是經過 socket 讀取客戶端請求命令並解析,卻沒有真正去執行命令,全部客戶端命令最後還須要回到主線程去執行,所以對多核的利用率並不算高,並且每次主線程都必須在分配完任務以後忙輪詢等待全部 I/O 線程完成任務以後才能繼續執行其餘邏輯。
Redis 之因此如此設計它的多線程網絡模型,我認爲主要的緣由是爲了保持兼容性,由於之前 Redis 是單線程的,全部的客戶端命令都是在單線程的事件循環裏執行的,也所以 Redis 裏全部的數據結構都是非線程安全的,如今引入多線程,若是按照標準的 Multi-Reactors/Master-Workers 模式來實現,則全部內置的數據結構都必須重構成線程安全的,這個工做量無疑是巨大且麻煩的。
因此,在我看來,Redis 目前的多線程方案更像是一個折中的選擇:既保持了原系統的兼容性,又能利用多核提高 I/O 性能。
其次,目前 Redis 的多線程模型中,主線程和 I/O 線程的通訊過於簡單粗暴:忙輪詢和鎖,由於經過自旋忙輪詢進行等待,致使 Redis 在啓動的時候以及運行期間偶爾會有短暫的 CPU 空轉引發的高佔用率,並且這個通訊機制的最終實現看起來很是不直觀和不簡潔,但願後面 Redis 能對目前的方案加以改進。
Redis 做爲緩存系統的事實標準,它的底層原理值得開發者去深刻學習,Redis 自 2009 年發佈初版以後,其單線程網絡模型的選擇在社區中從未中止過討論,多年來一直有呼聲但願 Redis 能引入多線程從而利用多核優點,可是做者 antirez 是一個追求大道至簡的開發者,對 Redis 加入任何新功能都異常謹慎,因此在 Redis 第一版發佈的十年後才最終將 Redis 的核心網絡模型改形成多線程模式,這期間甚至誕生了一些 Redis 多線程的替代項目。雖然 antirez 一直在推遲多線程的方案,但卻從未中止思考多線程的可行性,Redis 多線程網絡模型的改造不是一朝一夕的事情,這其中牽扯到項目的方方面面,因此咱們能夠看到 Redis 的最終方案也並不完美,沒有采用主流的多線程模式設計。
讓咱們來回顧一下 Redis 多線程網絡模型的設計方案:
通讀本文以後,相信讀者們應該可以瞭解到一個優秀的網絡系統的實現所涉及到的計算機領域的各類技術:設計模式、網絡 I/O、併發編程、操做系統底層,甚至是計算機硬件。另外還須要對項目迭代和重構的謹慎,對技術方案的深刻思考,毫不僅僅是寫好代碼這一個難點。