細到極致!3w 字帶你詳解 redis 網絡 IO 模型

前言

"redis 是單線程的" 這句話我們耳熟能詳。但它有一定的前提,redis 整個服務不可能只用到一個線程完成所有工作,它還有持久化、key 過期刪除、集羣管理等其它模塊,redis 會通過 fork 子進程或開啓額外的線程去處理。所謂的單線程是指從網絡連接 (accept) -> 讀取請求內容 (read) -> 執行命令 -> 響應內容 (write),這整個過程是由一個線程完成的,至於爲什麼 redis 要設計爲單線程,主要有以下原因:

  1. 基於內存。redis 命令操作主要都是基於內存,這已經足夠快,不需要藉助多線程。

  2. 高效的數據結構。redis 底層提供了動態簡單動態字符串 (SDS)、跳錶(skiplist)、壓縮列表(ziplist) 等數據結構來高效訪問數據。

  3. 保持簡單。引入多線程會使 redis 變得複雜,例如需要考慮多線程併發訪問資源競爭問題,數據結構也會變得複雜,hash 就不能是單純的 hash,需要像 java 一樣設計一個 ConcurrentHashMap。還需要考慮線程切換帶來的性能損耗,基於第一點,當程序執行已經足夠快,多線程並不能帶來正面收益。

按照 redis 官方介紹,單個節點的 redis qps 可以達到 10w+,已經非常優秀,如果有更高的要求,則可以通過部署主從、集羣方式進一步提升。
單線程不是沒有缺點的,我們需要辯證的看待問題,不然所有的組件都可以使用 redis 替代了。首先是基於內存的操作有丟失數據的風險,儘管你可以配置 appendfsync always 每次將執行請求通過 aof 文件持久化,但這也會帶來性能的下降。另外單線程的執行意味着所有的請求都需要排隊執行,如果有一個命令阻塞了,其它命令也都執行不了,可以與之比較的是 mysql,如果有一條 sql 語句執行比較慢,只要它不完全拖垮數據庫,其它請求的 sql 語句還是可以執行。最後,從上面可以看到從接收網絡連接到寫回響應內容,對於網絡請求部分的處理其實是可以多線程執行來提升網絡 IO 效率的。

redis 6.0
從 redis 6.0 開始,網絡連接 (accept) -> 讀取請求內容 (read) -> 執行命令 -> 響應內容 (write) 這個過程中的 “執行命令” 這個步驟依然保持單線程執行,而對於網絡 IO 讀寫是多線程執行的了。原因是這部分是網絡 IO 的解析、響應處理,已經不是單純的內存操作,可以充分利用多核 CPU 的優勢提升性能,對於這部分的性能需求其實一直都存在,社區也有 KeyDB 這樣的產品,其核心就是在 redis 的基礎上對多線程的支持,這多 redis 來說無疑是一種挑戰,所有 redis6.0 開始在網絡 IO 處理支持多線程就顯得非常必要了。

我們知道 redis 客戶端連接是可以有很多個的,最多可以有 maxclients 參數配置的數量,默認是 10000 個,那麼 redis 是如何高效處理這麼多連接的呢?以及 6.0 和之前的版本是如何具體處理從接收連接到響應整個過程的,或者說 redis 線程模型是怎麼樣的,清楚的瞭解這些有助於我們更好的學習 redis,其中的知識在以後學習其它中間件也可以很好的借鑑。

linux IO 模型

在學習 redis 網絡 IO 模型之前我們必須先了解一下 linux 的 IO 模型,以爲 redis 也是基於操作系統去設計的。I/O 是 Input/Output 的縮寫,是指操作系統與外部設備進行讀取、輸出的交互過程,外部設備可以是網卡、磁盤等。操作系統一般都分爲內核和用戶空間兩部分,內核負責與底層硬件交互,用戶程序讀寫數據都需要經過內核空間,也就是數據會不斷的在內核 - 用戶空間進行復制,不同的 IO 模型在這個複製過程用戶線程有不同的表現,有的是阻塞,有的是非阻塞,有的是同步,有的是異步。

以 linux 爲例,常見的 IO 模型有阻塞 IO、非阻塞 IO、IO 多路複用、信號驅動 IO、異步 IO 5 種,這次我們主要關注前 3 個,重點是 IO 多路複用,另外兩個在使用上有一些侷限性,實際應用並不多。這 5 種 IO 模型我們在這一篇已經有詳細的介紹,這裏簡單再複習一遍。

以一個最簡單例子,現在有兩個客戶端需要連接、發送數據到我們的服務端,看下服務端在各種 IO 模型下是如何接收、讀取請求的。

阻塞 IO(Blocking IO)

假設服務端只開啓一個線程處理請求,第一個請求到來,開始調用內核 read 函數,然後就會發生阻塞,第二個請求到來時服務端將無法處理,只能等第一個請求讀取完成。這種方式的缺點很明顯,每次只能處理一個請求,無法發揮 cpu 多核優勢,性能低下。

爲了解決這個問題,我們可以引入多線程,這樣就可以同時處理多個請求了,但服務端可能同時有成千上萬的請求需要處理,隨之而來的是線程數膨脹,頻繁創建、銷燬線程帶來的性能影響,當然我們可以使用線程池,但服務能處理的總體數量就會受限於線程池線程數量。

非阻塞 IO(NON-Blocking IO)

相比阻塞 IO,非阻塞 IO 會立即返回,調用者不會阻塞,此時可以做一些其它事情,例如處理其它請求。但是非阻塞 IO 需要主動輪詢是否有數據需要處理,且這種輪詢需要從用戶態切換到內核態這,假如沒有數據產生就會有很多空輪詢,白白浪費 cpu 資源。

阻塞 IO、非阻塞 IO,要麼需要開啓更多線程去處理 IO,要麼需要從用戶態切換到內核態輪詢 IO 事件,那麼有沒有一種機制,用戶程序只需要將請求提交給內核,由內核用少量的線程去監聽,有事件就通知用戶程序呢?這就是 IO 多路複用。

IO 多路複用 (IO Multiplexing)

IO 多路複用機制是指一個線程處理多個 IO 流,多路是指網絡連接,複用指的是同一個線程。
如果簡單從圖上看 IO 多路複用相比阻塞 IO 似乎並沒有什麼高明之處,假設服務只處理少量的連接,那麼相比阻塞 IO 確實沒有太大的提升,但如果連接數非常多,差距就會立竿見影。
首先 IO 多路複用會提交一批需要監聽的文件句柄(socket 也是一種文件句柄)到內核,由內核開啓一個線程負責監聽,把輪詢工作交給內核,當有事件發生時,由內核通知用戶程序。這不需要用戶程序開啓更多的線程去處理連接,也不需要用戶程序切換到內核態去輪詢,用一個線程就能處理大量網絡 IO 請求。
redis 底層採用的就是 IO 多路複用模型,實際上基本所有中間件在處理網絡 IO 這一塊都會使用到 IO 多路複用,如 kafka,rocketmq 等,所以本次學習之後對其它中間件的理解也是很有幫助的。

select/poll/epoll
這三個函數是實現 linux io 多路複用的內核函數,我們簡單瞭解下。

linux 最開始提供的是 select 函數,方法如下:

select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *timeout)

該方法需要傳遞 3 個集合,r,e,w 分別表示讀、寫、異常事件集合。集合類型是 bitmap,通過 0/1 表示該位置的 fd(文件描述符,socket 也是其中一種) 是否關心對應讀、寫、異常事件。例如我們對 fd 爲 1 和 2 的讀事件關心,r 參數的第 1,2 個 bit 就設置爲 1。

用戶進程調用 select 函數將關心的事件傳遞給內核系統,然後就會阻塞,直到傳遞的事件至少有一個發生時,方法調用會返回。內核返回時,同樣把發生的事件用這 3 個參數返回回來,如 r 參數第 1 個 bit 爲 1 表示 fd 爲 1 的發生讀事件,第 2 個 bit 依然爲 0,表示 fd 爲 2 的沒有發生讀事件。用戶進程調用時傳遞關心的事件,內核返回時返回發生的事件。

select 存在的問題:

  1. 大小有限制。爲 1024,由於每次 select 函數調用都需要在用戶空間和內核空間傳遞這些參數,爲了提升拷貝效率,linux 限制最大爲 1024。

  2. 這 3 個集合有相應事件觸發時,會被內核修改,所以每次調用 select 方法都需要重新設置這 3 個集合的內容。

  3. 當有事件觸發 select 方法返回,需要遍歷集合才能找到就緒的文件描述符,例如傳 1024 個讀事件,只有一個讀事件發生,需要遍歷 1024 個才能找到這一個。

  4. 同樣在內核級別,每次需要遍歷集合查看有哪些事件發生,效率低下。

poll 函數對 select 函數做了一些改進

poll(struct pollfd *fds, int nfds, int timeout)

struct pollfd {
	int fd;
	short events;
	short revents;
}

poll 函數需要傳一個 pollfd 結構數組,其中 fd 表示文件描述符,events 表示關心的事件,revents 表示發生的事件,當有事件發生時,內核通過這個參數返回回來。

poll 相比 select 的改進:

  1. 傳不固定大小的數組,沒有 1024 的限制了(問題 1)

  2. 將關心的事件和實際發生的事件分開,不需要每次都重新設置參數(問題 2)。例如 poll 數組傳 1024 個 fd 和事件,實際只有一個事件發生,那麼只需要重置一下這個 fd 的 revent 即可,而 select 需要重置 1024 個 bit。

poll 沒有解決 select 的問題 3 和 4。另外,雖然 poll 沒有 1024 個大小的限制,但每次依然需要在用戶和內核空間傳輸這些內容,數量大時效率依然較低。

這幾個問題的根本實際很簡單,核心問題是 select/poll 方法對於內核來說是無狀態的,內核不會保存用戶調用傳遞的數據,所以每次都是全量在用戶和內核空間來回拷貝,如果調用時傳給內核就保存起來,有新增文件描述符需要關注就再次調用增量添加,有事件觸發時就只返回對應的文件描述符,那麼問題就迎刃而解了,這就是 epoll 做的事情。

epoll 對應 3 個方法

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll_create 負責創建一個上下文,用於存儲數據,底層是用紅黑樹,以後的操作就都在這個上下文上進行。
epoll_ctl 負責將文件描述和所關心的事件註冊到上下文。
epoll_wait 用於等待事件的發生,當有有事件觸發,就只返回對應的文件描述符了。

reactor 模式

前面我們介紹的 IO 多路複用是操作系統的底層實現,藉助 IO 多路複用我們實現了一個線程就可以處理大量網絡 IO 請求,那麼接收到這些請求後該如何高效的響應,這就是 reactor 要關注的事情,reactor 模式是基於事件的一種設計模式。在 reactor 中分爲 3 中角色:
Reactor:負責監聽和分發事件
Acceptor:負責處理連接事件
Handler:負責處理請求,讀取數據,寫回數據

從線程角度出發,reactor 又可以分爲單 reactor 單線程,單 reactor 多線程,多 reactor 多線程 3 種。

單 reactor 單線程

處理過程:reactor 負責監聽連接事件,當有連接到來時,通過 acceptor 處理連接,得到建立好的 socket 對象,reactor 監聽 scoket 對象的讀寫事件,讀寫事件觸發時,交由 handler 處理,handler 負責讀取請求內容,處理請求內容,響應數據。
可以看到這種模式比較簡單,讀取請求數據,處理請求內容,響應數據都是在一個線程內完成的,如果整個過程響應都比較快,可以獲得比較好的結果。缺點是請求都在一個線程內完成,無法發揮多核 cpu 的優勢,如果處理請求內容這一塊比較慢,就會影響整體性能。

單 reactor 多線程

既然處理請求這裏可能由性能問題,那麼這裏可以開啓一個線程池來處理,這就是單 reactor 多線程模式,請求連接、讀寫還是由主線程負責,處理請求內容交由線程池處理,相比之下,多線程模式可以利用 cpu 多核的優勢。單仔細思考這裏依然有性能優化的點,就是對於請求的讀寫這裏依然是在主線程完成的,如果這裏也可以多線程,那效率就可以進一步提升。

多 reactor 多線程

多 reactor 多線程下,mainReactor 接收到請求交由 acceptor 處理後,mainReactor 不再讀取、寫回網絡數據,直接將請求交給 subReactor 線程池處理,這樣讀取、寫回數據多個請求之間也可以併發執行了。

redis 網絡 IO 模型

redis 網絡 IO 模型底層使用 IO 多路複用,通過 reactor 模式實現的,在 redis 6.0 以前屬於單 reactor 單線程模式。如圖:

在 linux 下,IO 多路複用程序使用 epoll 實現,負責監聽服務端連接、socket 的讀取、寫入事件,然後將事件丟到事件隊列,由事件分發器對事件進行分發,事件分發器會根據事件類型,分發給對應的事件處理器進行處理。我們以一個 get key 簡單命令爲例,一次完整的請求如下:

請求首先要建立 TCP 連接 (TCP3 次握手),過程如下:
redis 服務啓動,主線程運行,監聽指定的端口,將連接事件綁定命令應答處理器。
客戶端請求建立連接,連接事件觸發,IO 多路複用程序將連接事件丟入事件隊列,事件分發器將連接事件交由命令應答處理器處理。
命令應答處理器創建 socket 對象,將 ae_readable 事件和命令請求處理器關聯,交由 IO 多路複用程序監聽。

連接建立後,就開始執行 get key 請求了。如下:

客戶端發送 get key 命令,socket 接收到數據變成可讀,IO 多路複用程序監聽到可讀事件,將讀事件丟到事件隊列,由事件分發器分發給上一步綁定的命令請求處理器執行。
命令請求處理器接收到數據後,對數據進行解析,執行 get 命令,從內存查詢到 key 對應的數據,並將 ae_writeable 寫事件和響應處理器關聯起來,交由 IO 多路複用程序監聽。
客戶端準備好接收數據,命令請求處理器產生 ae_writeable 事件,IO 多路複用程序監聽到寫事件,將寫事件丟到事件隊列,由事件分發器發給命令響應處理器進行處理。
命令響應處理器將數據寫回 socket 返回給客戶端。

reids 6.0 以前網絡 IO 的讀寫和請求的處理都在一個線程完成,儘管 redis 在請求處理基於內存處理很快,不會稱爲系統瓶頸,但隨着請求數的增加,網絡讀寫這一塊存在優化空間,所以 redis 6.0 開始對網絡 IO 讀寫提供多線程支持。需要知道的是,redis 6.0 對多線程的默認是不開啓的,可以通過 io-threads 4 參數開啓對網絡寫數據多線程支持,如果對於讀也要開啓多線程需要額外設置 io-threads-do-reads yes 參數,該參數默認是 no,因爲 redis 認爲對於讀開啓多線程幫助不大,但如果你通過壓測後發現有明顯幫助,則可以開啓。

redis 6.0 多線程模型思想上類似單 reactor 多線程和多 reactor 多線程,但不完全一樣,這兩者 handler 對於邏輯處理這一塊都是使用線程池,而 redis 命令執行依舊保持單線程。如下:

可以看到對於網絡的讀寫都是提交給線程池去執行,充分利用了 cpu 多核優勢,這樣主線程可以繼續處理其它請求了。
開啓多線程後多 redis 進行壓測結果可以參考這裏,如下圖可以看到,對於簡單命令 qps 可以達到 20w 左右,相比單線程有一倍的提升,性能提升效果明顯,對於生產環境如果大家使用了新版本的 redis,現在 7.0 也出來了,建議開啓多線程。

總結

本篇我們學習 redis 單線程具體是如何單線程以及在不同版本的區別,通過網絡 IO 模型知道 IO 多路複用如何用一個線程處理監聽多個網絡請求,並詳細瞭解 3 種 reactor 模型,這是在 IO 多路複用基礎上的一種設計模式。最後學習了 redis 單線程、多線程版本是如何基於 reactor 模型處理請求。其中 IO 多路複用和 reactor 模型在許多中間件都有使用到,後續再接觸到就不陌生了。

原文鏈接:
https://www.cnblogs.com/jtea/p/16969386.html

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/TYJYXM6tDg62oWf-fvz6zQ