天天看點

redis_03 _ 高性能IO模型:為什麼單線程Redis能那麼快

今天,我們來探讨一個很多人都很關心的問題:“為什麼單線程的redis能那麼快?”

首先,我要和你厘清一個事實,我們通常說,redis是單線程,主要是指redis的網絡io和鍵值對讀寫是由一個線程來完成的,這也是redis對外提供鍵值存儲服務的主要流程。但redis的其他功能,比如持久化、異步删除、叢集資料同步等,其實是由額外的線程執行的。

是以,嚴格來說,redis并不是單線程,但是我們一般把redis稱為單線程高性能,這樣顯得“酷”些。接下來,我也會把redis稱為單線程模式。而且,這也會促使你緊接着提問:“為什麼用單線程?為什麼單線程能這麼快?”

要弄明白這個問題,我們就要深入地學習下redis的單線程設計機制以及多路複用機制。之後你在調優redis性能時,也能更有針對性地避免會導緻redis單線程阻塞的操作,例如執行複雜度高的指令。

好了,話不多說,接下來,我們就先來學習下redis采用單線程的原因。

要更好地了解redis為什麼用單線程,我們就要先了解多線程的開銷。

日常寫程式時,我們經常會聽到一種說法:“使用多線程,可以增加系統吞吐率,或是可以增加系統擴充性。”的确,對于一個多線程的系統來說,在有合理的資源配置設定的情況下,可以增加系統中處理請求操作的資源實體,進而提升系統能夠同時處理的請求數,即吞吐率。下面的左圖是我們采用多線程時所期待的結果。

但是,請你注意,通常情況下,在我們采用多線程後,如果沒有良好的系統設計,實際得到的結果,其實是右圖所展示的那樣。我們剛開始增加線程數時,系統吞吐率會增加,但是,再進一步增加線程時,系統吞吐率就增長遲緩了,有時甚至還會出現下降的情況。

redis_03 _ 高性能IO模型:為什麼單線程Redis能那麼快

為什麼會出現這種情況呢?一個關鍵的瓶頸在于,系統中通常會存在被多線程同時通路的共享資源,比如一個共享的資料結構。當有多個線程要修改這個共享資源時,為了保證共享資源的正确性,就需要有額外的機制進行保證,而這個額外的機制,就會帶來額外的開銷。

拿redis來說,在上節課中,我提到過,redis有list的資料類型,并提供出隊(lpop)和入隊(lpush)操作。假設redis采用多線程設計,如下圖所示,現在有兩個線程a和b,線程a對一個list做lpush操作,并對隊列長度加1。同時,線程b對該list執行lpop操作,并對隊列長度減1。為了保證隊列長度的正确性,redis需要讓線程a和b的lpush和lpop串行執行,這樣一來,redis可以無誤地記錄它們對list長度的修改。否則,我們可能就會得到錯誤的長度結果。這就是多線程程式設計模式面臨的共享資源的并發通路控制問題。

redis_03 _ 高性能IO模型:為什麼單線程Redis能那麼快

并發通路控制一直是多線程開發中的一個難點問題,如果沒有精細的設計,比如說,隻是簡單地采用一個粗粒度互斥鎖,就會出現不理想的結果:即使增加了線程,大部分線程也在等待擷取通路共享資源的互斥鎖,并行變串行,系統吞吐率并沒有随着線程的增加而增加。

而且,采用多線程開發一般會引入同步原語來保護共享資源的并發通路,這也會降低系統代碼的易調試性和可維護性。為了避免這些問題,redis直接采用了單線程模式。

講到這裡,你應該已經明白了“redis為什麼用單線程”,那麼,接下來,我們就來看看,為什麼單線程redis能獲得高性能。

通常來說,單線程的處理能力要比多線程差很多,但是redis卻能使用單線程模型達到每秒數十萬級别的處理能力,這是為什麼呢?其實,這是redis多方面設計選擇的一個綜合結果。

一方面,redis的大部分操作在記憶體上完成,再加上它采用了高效的資料結構,例如哈希表和跳表,這是它實作高性能的一個重要原因。另一方面,就是redis采用了多路複用機制,使其在網絡io操作中能并發處理大量的用戶端請求,實作高吞吐率。接下來,我們就重點學習下多路複用機制。

首先,我們要弄明白網絡操作的基本io模型和潛在的阻塞點。畢竟,redis采用單線程進行io,如果線程被阻塞了,就無法進行多路複用了。

你還記得我在第一節課介紹的具有網絡架構的simplekv嗎?

以get請求為例,simplekv為了處理一個get請求,需要監聽用戶端請求(bind/listen),和用戶端建立連接配接(accept),從socket中讀取請求(recv),解析用戶端發送請求(parse),根據請求類型讀取鍵值資料(get),最後給用戶端傳回結果,即向socket中寫回資料(send)。

下圖顯示了這一過程,其中,bind/listen、accept、recv、parse和send屬于網絡io處理,而get屬于鍵值資料操作。既然redis是單線程,那麼,最基本的一種實作是在一個線程中依次執行上面說的這些操作。

redis_03 _ 高性能IO模型:為什麼單線程Redis能那麼快

但是,在這裡的網絡io操作中,有潛在的阻塞點,分别是accept()和recv()。當redis監聽到一個用戶端有連接配接請求,但一直未能成功建立起連接配接時,會阻塞在accept()函數這裡,導緻其他用戶端無法和redis建立連接配接。類似的,當redis通過recv()從一個用戶端讀取資料時,如果資料一直沒有到達,redis也會一直阻塞在recv()。

這就導緻redis整個線程阻塞,無法處理其他用戶端請求,效率很低。不過,幸運的是,socket網絡模型本身支援非阻塞模式。

socket網絡模型的非阻塞模式設定,主要展現在三個關鍵的函數調用上,如果想要使用socket非阻塞模式,就必須要了解這三個函數的調用傳回類型和設定模式。接下來,我們就重點學習下它們。

在socket模型中,不同操作調用後會傳回不同的套接字類型。socket()方法會傳回主動套接字,然後調用listen()方法,将主動套接字轉化為監聽套接字,此時,可以監聽來自用戶端的連接配接請求。最後,調用accept()方法接收到達的用戶端連接配接,并傳回已連接配接套接字。

redis_03 _ 高性能IO模型:為什麼單線程Redis能那麼快

針對監聽套接字,我們可以設定非阻塞模式:當redis調用accept()但一直未有連接配接請求到達時,redis線程可以傳回處理其他操作,而不用一直等待。但是,你要注意的是,調用accept()時,已經存在監聽套接字了。

雖然redis線程可以不用繼續等待,但是總得有機制繼續在監聽套接字上等待後續連接配接請求,并在有請求時通知redis。

類似的,我們也可以針對已連接配接套接字設定非阻塞模式:redis調用recv()後,如果已連接配接套接字上一直沒有資料到達,redis線程同樣可以傳回處理其他操作。我們也需要有機制繼續監聽該已連接配接套接字,并在有資料達到時通知redis。

這樣才能保證redis線程,既不會像基本io模型中一直在阻塞點等待,也不會導緻redis無法處理實際到達的連接配接請求或資料。

到此,linux中的io多路複用機制就要登場了。

linux中的io多路複用機制是指一個線程處理多個io流,就是我們經常聽到的select/epoll機制。簡單來說,在redis隻運作單線程的情況下,該機制允許核心中,同時存在多個監聽套接字和已連接配接套接字。核心會一直監聽這些套接字上的連接配接請求或資料請求。一旦有請求到達,就會交給redis線程處理,這就實作了一個redis線程處理多個io流的效果。

下圖就是基于多路複用的redis io模型。圖中的多個fd就是剛才所說的多個套接字。redis網絡架構調用epoll機制,讓核心監聽這些套接字。此時,redis線程不會阻塞在某一個特定的監聽或已連接配接套接字上,也就是說,不會阻塞在某一個特定的用戶端請求處理上。正因為此,redis可以同時和多個用戶端連接配接并處理請求,進而提升并發性。

redis_03 _ 高性能IO模型:為什麼單線程Redis能那麼快

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

那麼,回調機制是怎麼工作的呢?其實,select/epoll一旦監測到fd上有請求到達時,就會觸發相應的事件。

這些事件會被放進一個事件隊列,redis單線程對該事件隊列不斷進行處理。這樣一來,redis無需一直輪詢是否有請求實際發生,這就可以避免造成cpu資源浪費。同時,redis在對事件隊列中的事件進行處理時,會調用相應的處理函數,這就實作了基于事件的回調。因為redis一直在對事件隊列進行處理,是以能及時響應用戶端請求,提升redis的響應性能。

為了友善你了解,我再以連接配接請求和讀資料請求為例,具體解釋一下。

這兩個請求分别對應accept事件和read事件,redis分别對這兩個事件注冊accept和get回調函數。當linux核心監聽到有連接配接請求或讀資料請求時,就會觸發accept事件和read事件,此時,核心就會回調redis相應的accept和get函數進行處理。

這就像病人去醫院瞧病。在醫生實際診斷前,每個病人(等同于請求)都需要先分診、測體溫、登記等。如果這些工作都由醫生來完成,醫生的工作效率就會很低。是以,醫院都設定了分診台,分診台會一直處理這些診斷前的工作(類似于linux核心監聽請求),然後再轉交給醫生做實際診斷。這樣即使一個醫生(相當于redis單線程),效率也能提升。

不過,需要注意的是,即使你的應用場景中部署了不同的作業系統,多路複用機制也是适用的。因為這個機制的實作有很多種,既有基于linux系統下的select和epoll實作,也有基于freebsd的kqueue實作,以及基于solaris的evport實作,這樣,你可以根據redis實際運作的作業系統,選擇相應的多路複用實作。

今天,我們重點學習了redis線程的三個問題:“redis真的隻有單線程嗎?”“為什麼用單線程?”“單線程為什麼這麼快?”

現在,我們知道了,redis單線程是指它對網絡io和資料讀寫的操作采用了一個線程,而采用單線程的一個核心原因是避免多線程開發的并發控制問題。單線程的redis也能獲得高性能,跟多路複用的io模型密切相關,因為這避免了accept()和send()/recv()潛在的網絡io操作阻塞點。

搞懂了這些,你就走在了很多人的前面。如果你身邊還有不清楚這幾個問題的朋友,歡迎你分享給他/她,解決他們的困惑。

另外,我也劇透下,可能你也注意到了,2020年5月,redis 6.0的穩定版釋出了,redis 6.0中提出了多線程模型。那麼,這個多線程模型和這節課所說的io模型有什麼關聯?會引入複雜的并發控制問題嗎?會給redis 6.0帶來多大提升?關于這些問題,我會在後面的課程中和你具體介紹。

這節課,我給你提個小問題,在“redis基本io模型”圖中,你覺得還有哪些潛在的性能瓶頸嗎?歡迎在留言區寫下你的思考和答案,我們一起交流讨論。

繼續閱讀