tft每日頭條

 > 科技

 > redis的多線程模式

redis的多線程模式

科技 更新时间:2024-09-10 23:22:43
Redis 是單線程還是多線程

redis 應該是使用頻率最高的組件之一了,不僅在工作中會大量使用,面試的時候也經常會作為考點出現,下面就來深入地了解一下 Redis。

先來探讨一個問題,Redis 使用的到底是多線程還是單線程?

不同版本的 Redis 是不同的,在 4.0 之前 Redis 是單線程運行的,但是單線程并不代表效率低。像 Nginx、Nodejs 也是單線程程序,但它們的效率并不低,因為底層采用了基于 epoll 的 IO 多路複用(後面說)。

此外 Redis 是基于内存操作的,它的瓶頸在于機器的内存、網絡帶寬,而不是 CPU,因為在你 CPU 還沒達到瓶頸時你的内存可能就先滿了、或者帶寬達到瓶頸了。因此 CPU 不是主要原因,那麼自然就采用單線程了。更何況使用多線程還會面臨一些額外的問題,比如共享資源的保護等等,對于一個 CPU 不是主要瓶頸的鍵值對數據庫而言,采用單線程是非常合适的。

簡單來說,Redis 在 4.0 之前使用單線程的模式是因為以下三個原因:

  • 使用單線程模式的 Redis,其開發和維護會更簡單,因為單線程模型方便開發和調試;
  • 即使使用單線程模型也能夠并發地處理多客戶端請求,因為 Redis 内部使用了基于 epoll 的多路複用;
  • 對于 Redis 而言,主要的性能瓶頸是内存或者網絡帶寬,而并非 CPU;

但 Redis 在 4.0 以及之後的版本中引入了惰性删除(也叫異步删除),這是由額外的線程執行的。意思就是我們可以使用異步的方式對 Redis 中的數據執行删除操作了,例如:unlink key, flushdb async, flushall async,舉個例子:

127.0.0.1:6379> set name satori OK 127.0.0.1:6379> get name "satori" # 這裡是異步删除一個 key # 同步的話則是 del name 127.0.0.1:6379> unlink name (integer) 1 127.0.0.1:6379> flushdb async OK 127.0.0.1:6379> flushall async OK

這樣處理的好處就是不會使 Redis 的主線程卡頓,會把這些删除操作交給後台線程來執行。

通常情況下使用 del 指令可以很快地删除數據,但是當被删除的 key 是一個非常大的對象時,例如:删除的是包含了成千上萬個元素的 hash 集合,那麼 del 指令就會造成 Redis 主線程卡頓。而使用惰性删除,可以有效地避免 Redis 卡頓的問題。

除了惰性删除,像持久化、集群數據同步等等,都是由額外的子線程執行的,而 Redis 主線程則專注于網絡 IO 和鍵值對讀寫。

單線程的 Redis 為什麼這麼快

正如上面所說,Redis4.0 之前是單線程的,那既然是單線程為什麼速度還能那麼快?吞吐量還能那麼高?

原因有以下幾點:

1)基于内存操作:Redis 的所有數據都在内存中,因此所有的運算都是内存級别的,所以它的性能比較高;

2)數據結構簡單:Redis 的數據結構是為自身專門量身打造的,而操作這些數據結構的時間複雜度是 O(1);

3)多路複用和非阻塞 I/O:Redis 使用 I/O 多路複用功能來監聽多個 socket 連接客戶端,這樣就可以使用一個線程來處理多個連接,從而減少線程切換帶來的開銷,同時也避免了 I/O 阻塞操作,從而大大地提高了 Redis 的性能;

4)避免上下文切換:因為是單線程模型,因此就避免了不必要的上下文切換和多線程競争,這就省去了多線程切換帶來的時間和性能上的開銷,而且單線程不會導緻死鎖的問題發生;

非阻塞 I/O 和 I/O 多路複用是什麼

首先我們可以使用 get 命令,獲取一個 key 對應的 value,比如:

127.0.0.1:6379> get name "satori"

那麼問題來了,以上對于 Redis 服務端而言,都發生了哪些事情呢?

服務端必須要先監聽客戶端連接(bind/listen),然後當客戶端到來時與其建立連接(accept),從 socket 中讀取客戶端的請求(recv),對請求進行解析(parse)。這裡解析出的請求類型是 get、key 是 "name",再根據 key 獲取對應 value,最後返回給客戶端,也就是向 socket 寫入數據(send)。

redis的多線程模式(屬于單線程還是多線程)1

以上所有操作都是由 Redis 主線程依次執行的,但裡面會有潛在的阻塞點,分别是 Accept 和 recv。

當 Redis 監聽到一個客戶端有連接請求、但卻一直未能成功建立連接,那麼主線程會一直阻塞在 accept 函數這裡,導緻其它客戶端無法和 Redis 建立連接。

類似的,當 Redis 通過 recv 從客戶端讀取數據時,如果數據一直沒有到達,那麼 Redis 主線程也會一直阻塞在 recv 這一步,因此這就導緻了 Redis 的效率會變得低下。

非阻塞 I/O

但很明顯,Redis 不會允許這種情況發生,因為以上都是阻塞 I/O 會面臨的情況,而 Redis 采用的是非阻塞 I/O,也就是将 socket 設置成了非阻塞模式。

首先在 socket 模型中,調用 socket() 方法會返回主動套接字,調用 bind() 方法綁定 IP 和 端口,再調用 listen() 方法将主動套接字轉化為監聽套接字,最後監聽套接字調用 accept() 方法等待客戶端連接的到來,當和客戶端建立連接時再返回已連接套接字,而後續就通過已連接套接字來和客戶端進行數據的接收與發送。

但是注意:我們說在 listen() 這一步,會将主動套接字轉化為監聽套接字,而此時的監聽套接字的類型是阻塞的,阻塞類型的監聽套接字在調用 accept() 方法時,如果沒有客戶端來連接的話,就會一直處于阻塞狀态,那麼此時主線程就沒法幹其它事情了。

所以在 listen() 的時候可以将其設置為非阻塞,而非阻塞的監聽套接字在調用 accept() 時,如果沒有客戶端連接到達時,那麼主線程就不會傻傻地等待了,而是會直接返回,然後去做其它的事情。

類似的,我們在創建已連接套接字的時候也可以将其類型設置為非阻塞,因為阻塞類型的已連接套接字在調用 send() / recv() 的時候也會處于阻塞狀态。比如當客戶端一直不發數據的時候,已連接套接字就會一直阻塞在 recv() 這一步。如果是非阻塞類型的已連接套接字,那麼當調用 recv() 但卻收不到數據時,也不用處于阻塞狀态,同樣可以直接返回去做其它事情。

redis的多線程模式(屬于單線程還是多線程)2

但是有兩點需要注意:

1)雖然 accept() 不阻塞了,在沒有客戶端連接時 Redis 主線程可以去做其它事情,但如果後續有客戶端連接,Redis 要如何得知呢?因此必須要有一種機制,能夠繼續在監聽套接字上等待後續連接請求,并在請求到來時通知 Redis。

2)send() / recv() 不阻塞了,相當于 I/O 讀寫流程不再是阻塞的,讀寫方法都會瞬間完成并返回,也就是說它會采用能讀多少就讀多少、能寫多少就寫多少的策略來執行 I/O 操作,這顯然更符合我們對性能的追求。

但這樣會面臨一個問題,那就是當我們執行讀取操作時,有可能隻讀取了一部分數據,剩餘的數據客戶端還沒發過來,那麼這些數據何時可讀呢?同理寫數據也是這種情況,當緩沖區滿了,而我們的數據還沒有寫完,那麼剩下的數據又何時可寫呢?因此同樣要有一種機制,能夠在 Redis 主線程做别的事情的時候繼續監聽已連接套接字,并且在有數據可讀寫的時候通知 Redis。

這樣才能保證 Redis 線程既不會像基本 IO 模型一樣,一直在阻塞點等待,也不會無法處理實際到達的客戶端連接請求和可讀寫的數據,而上面所提到的機制便是 I/O 多路複用。

I/O 多路複用

I/O 多路複用機制是指一個線程處理多個 IO 流,也就是我們經常聽到的 select/poll/epoll,而 Linux 默認采用的是 epoll。

簡單來說,在 Redis 隻運行單線程的情況下,該機制允許内核中同時存在多個監聽套接字和已連接套接字。内核會一直監聽這些套接字上的連接請求或數據請求,一旦有請求到達就會交給 Redis 線程處理,這樣就實現了一個 Redis 線程處理多個 IO 流的效果。

redis的多線程模式(屬于單線程還是多線程)3

上圖就是基于多路複用的 Redis IO 模型,圖中的 FD 就是套接字,可以是監聽套接字、也可以是已連接套接字,Redis 會通過 epoll 機制來讓内核幫忙監聽這些套接字。而此時 Redis 線程或者說主線程,不會阻塞在某一個特定的套接字上,也就是說不會阻塞在某一個特定的客戶端請求處理上。因此 Redis 可以同時和多個客戶端連接并處理請求,從而提升并發性。

但為了在請求到達時能夠通知 Redis 線程,epoll 提供了基于事件的回調機制,即針對不同事件的發生,調用相應的處理函數。

那麼回調機制是怎麼工作的呢?以上圖為例,首先 epoll 一旦監測到 FD 上有請求到達,就會觸發相應的事件。這些事件會被放進一個隊列中,Redis 主線程會對該事件隊列不斷進行處理,這樣一來 Redis 就無需一直輪詢是否有請求發生,從而避免資源的浪費。

同時,Redis 在對事件隊列中的事件進行處理時,會調用相應的處理函數,這就實現了基于事件的回調。因為 Redis 一直在對事件隊列進行處理,所以能及時響應客戶端請求,提升 Redis 的響應性能。

比如連接請求和數據讀取請求分别對應 Accept 事件和 Read 事件,Redis 分别對這兩個事件注冊 accept 和 get 回調函數。當 Linux 内核監聽到有連接請求或數據讀取請求時,就會觸發 Accept 事件或 Read 事件,然後内核就會回調 Redis 注冊的 accept 函數或 get 函數。

就像病人去醫院看病,在醫生實際診斷之前每個病人(類似于請求)都需要先分診、測體溫、登記等等。如果這些工作都由醫生完成,那麼醫生的工作效率就會很低。所以醫院設置了分診台,分診台會一直處理這些診斷前的工作(類似于 Linux 内核監聽請求),然後再轉交給醫生做實際診斷,這樣即使一個醫生(相當于 Redis 的主線程)也能有很高的效率。

需要注意的是,不同的操作系統有着不同的多路複用實現,除了 Linux 的 epoll,還有 FreeBSD 的 kqueue、以及 Solaris 的 evport。

相關視頻推薦

源碼調試:redis io多線程

redis 為什麼是單線程?這裡單線程指什麼?redis單線程為什麼這麼快?

需要C/C Linux服務器架構師學習資料加qun812855908獲取(資料包括C/C ,Linux,golang技術,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK,ffmpeg等),免費分享

redis的多線程模式(屬于單線程還是多線程)4

嗦一下 Redis 6.0 的多線程

Redis 6.0 引入了一些新特性,其中非常受關注的一個特性就是多線程。在 4.0 之前 Redis 是單線程的,因為單線程的優點很明顯,不但降低了 Redis 内部實現的複雜性,也讓所有操作都可以在無鎖的情況下進行,并且不存在死鎖和線程切換帶來的性能以及時間上的消耗。

但是其缺點也很明顯,單線程機制導緻 Redis 的 QPS(Query Per Second,每秒查詢數)很難得到有效的提高(雖然已經夠快了,但人畢竟還是要有更高的追求的)。

然後 Redis 在 4.0 版本引入了多線程,但是此版本的多線程主要用于大數據量的異步删除,對于非删除操作的意義并不是很大。

而 Redis 6.0 中的多線程則是真正為了提高 I/O 的讀寫性能而引入的,它的主要實現思路是将主線程的 I/O 讀寫任務拆分給一組獨立的子線程去執行。也就是說,從 socket 讀數據和寫數據不再由主線程負責,而是交給了多個子線程,這樣就可以使多個 socket 的讀寫并行化了。這麼做的原因就在于,雖然在 Redis 中使用了 I/O 多路複用和非阻塞 I/O,但數據在内核态空間和用戶态空間之間的拷貝是無法避免的,而數據的拷貝這一步是阻塞的,并且當數據量越大時拷貝所需要的時間就越多。

所以 Redis 在 6.0 引入了多線程,用于分攤同步讀寫 I/O 壓力,從而提升 Redis 的 QPS。

但是注意:Redis 的命令本身依舊是由 Redis 主線程串行執行的,隻不過具體的讀寫操作交給獨立的子線程去執行了(一會兒詳細說明 Redis 的主線程和子線程之間是如何協同的)。而這麼做的好處就是不需要為 Lua 腳本、事務的原子性而額外開發多線程互斥機制,這樣一來 Redis 的線程模型實現起來就簡單多了。因為和之前一樣,所有的命令依舊是由主線程串行執行,隻不過具體的讀寫任務交給了子線程。

除了引入多線程,還可以将内核網絡協議棧換成用戶态網絡協議棧(DPDK),讓網絡請求不在内核裡進行,直接在用戶态完成。但 Redis 并沒有采用這種做法,雖然替換協議棧可以避免頻繁地讓内核參與網絡請求處理,提升請求處理效率。原因是該做法要求 Redis 添加對用戶态網絡協議棧的支持,需要修改 Redis 源碼中和網絡相關的部分,這會帶來很多額外的開發工作量;而且新增代碼還可能引入 bug,導緻 Redis 程序不穩定,因此 Redis 6.0 沒有采用這種做法。

請再具體嗦一下 Redis 6.0 的主線程和子線程之間是如何協同的?

整體可以分為四個階段。

階段一:服務端和客戶端建立 socket 連接,并分配子線程(處理線程)

首先,主線程負責建立連接請求,當有客戶端請求到達時,主線程會創建和客戶端的 scoket 連接,該 socket 連接就是用來和客戶端進行數據傳輸的。隻不過這一步不由主線程來做,主線程要做的事情是将該 socket 放入到全局等待隊列中,然後通過輪詢的方式選擇子線程,并将隊列中的 socket 連接分配給它。

所以無論是從客戶端讀數據還是向客戶端寫數據,都由子線程來做。因為我們說 Redis 6.0 引入的多線程就是為了緩解主線程的 I/O 讀寫壓力,而 I/O 讀寫這一步是阻塞的,所以應該交給子線程并行操作。

階段二:子線程讀取并解析請求

主線程一旦把 socket 連接分配給子線程,那麼會進入阻塞狀态,等待子線程完成客戶端請求的讀取和解析,得到具體的命令操作。由于可以有多個子線程,所以這個操作很快就能完成。

階段三:主線程執行命令操作

等到子線程讀取到客戶端請求并解析完畢之後,再由主線程以單線程的方式執行命令操作,所以 I/O 讀寫雖然交給了子線程,但命令本身還是由 Redis 主線程執行的。

階段四:子線程回寫 socket、主線程清空全局隊列

當主線程執行完命令操作時,還需要将結果寫入緩沖區,而這一步顯然要由子線程來做,因為是 I/O 讀寫。此時主線程會再次陷入阻塞,直到子線程将這些結果寫回 socket 并返回給客戶端。

和讀取一樣,子線程将數據寫回 socket 時,也是多個線程在并行執行,所以寫回 socket 的速度也很快。之後主線程會清空全局隊列,等待客戶端的後續請求。

redis的多線程模式(屬于單線程還是多線程)5

在 Redis 6.0 中如何開啟多線程?

在了解了 Redis 6.0 的多線程機制之後,我們要如何開啟多線程呢?在 Redis 6.0 中,多線程機制默認是關閉的,如果想啟動的話,需要修改 redis.conf 裡的兩個配置。

# 設置 io-thread-do-reads 配置項為 yes # 表示啟用多線程 io-thread-do-reads yes # 通過 io-threads 設置子線程的數量 io-threads 3

上述配置表示開啟 3 個子線程,需要注意的是,線程數并不是越大越好,應該小于機器的 CPU 核數。關于線程數的設置,官方的建議是:如果 4 核的 CPU,那麼設置子線程數為 2 或 3;如果 8 核的 CPU,那麼設置子線程數為 6。

如果你在實際應用中,發現 Redis 實例的 CPU 開銷不大,吞吐量卻沒有提升。那麼可以考慮使用 Redis 6.0 的多線程機制,加速 IO 讀寫處理,進而提升實例的吞吐量。

最後關于 Redis 的性能,Redis 的作者在 2019 的 RedisConf 大會上提到,Redis6.0 引入的多線程 I/O 特性對性能的提升至少是一倍以上。國内也有人在阿裡雲使用 4 個線程的 Redis 版本和單線程的 Redis 版本進行比較測試,發現測試的結果和 Redis 作者說的一緻,性能基本可以提高一倍。

小結

以上我們就介紹了 Redis 在 4.0 之前明明采用單線程但卻依然快的原因:基于内存操作、量身打造的數據結構、I/O 多路複用和非阻塞 I/O、避免了不必要的線程上下文切換。

并且在 Redis4.0 開始支持多線程,主要體現在大數據的異步删除上面,例如:unlink key、flushdb async、flushall async 等。

而 Redis6.0 的多線程則增加了對 I/O 讀寫的并發能力,因為數據在用戶态和内核态之間穿梭是需要進行拷貝的,而這一步會阻塞,所以通過多個線程并行操作能更好地提升 Redis 的性能。

,

更多精彩资讯请关注tft每日頭條,我们将持续为您更新最新资讯!

查看全部

相关科技资讯推荐

热门科技资讯推荐

网友关注

Copyright 2023-2024 - www.tftnews.com All Rights Reserved