一、概述
眾所周知,redis是一個高性能的數據存儲框架,在高并發的系統設計中,redis也是一個比較關鍵的組件,是我們提升系統性能的一大利器。深入去理解redis高性能的原理顯得越發重要,當然redis的高性能設計是一個系統性的工程,涉及到很多內容,本文重點關注redis的io模型,以及基于io模型的線程模型。
我們從io的起源開始,講述了阻塞io、非阻塞io、多路復用io。基于多路復用io,我們也梳理了幾種不同的reactor模型,并分析了幾種reactor模型的優缺點。基于reactor模型我們開始了redis的io模型和線程模型的分析,并總結出redis線程模型的優點、缺點,以及后續的redis多線程模型方案。本文的重點是對redis線程模型設計思想的梳理,捋順了設計思想,就是一通百通的事了。
注:本文的代碼都是偽代碼,主要是為了示意,不可用于生產環境。
二、網絡io模型發展史
我們常說的網絡io模型,主要包含阻塞io、非阻塞io、多路復用io、信號驅動io、異步io,本文重點關注跟redis相關的內容,所以我們重點分析阻塞io、非阻塞io、多路復用io,幫助大家后續更好的理解redis網絡模型。
我們先看下面這張圖;
2.1 阻塞io
我們經常說的阻塞io其實分為兩種,一種是單線程阻塞,一種是多線程阻塞。這里面其實有兩個概念,阻塞和線程。
- 阻塞:指調用結果返回之前,當前線程會被掛起,調用線程只有在得到結果之后才會返回;
- 線程:系統調用的線程個數。
像建立連接、讀、寫都涉及到系統調用,本身是一個阻塞的操作。
2.1.1 單線程阻塞
服務端單線程來處理,當客戶端請求來臨時,服務端用主線程來處理連接、讀取、寫入等操作。
以下用代碼模擬了單線程的阻塞模式;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
import java.net.socket; public class biotest { public static void main(string[] args) throws ioexception { serversocket server= new serversocket( 8081 ); while ( true ) { socket socket=server.accept(); system.out.println( "accept port:" +socket.getport()); bufferedreader in= new bufferedreader( new inputstreamreader(socket.getinputstream())); string indata= null ; try { while ((indata = in.readline()) != null ) { system.out.println( "client port:" +socket.getport()); system.out.println( "input data:" +indata); if ( "close" .equals(indata)) { socket.close(); } } } catch (ioexception e) { e.printstacktrace(); } finally { try { socket.close(); } catch (ioexception e) { e.printstacktrace(); } } } } } |
我們準備用兩個客戶端同時發起連接請求、來模擬單線程阻塞模式的現象。同時發起連接,通過服務端日志,我們發現此時服務端只接受了其中一個連接,主線程被阻塞在上一個連接的read方法上。
我們嘗試關閉第一個連接,看第二個連接的情況,我們希望看到的現象是,主線程返回,新的客戶端連接被接受。
從日志中發現,在第一個連接被關閉后,第二個連接的請求被處理了,也就是說第二個連接請求在排隊,直到主線程被喚醒,才能接收下一個請求,符合我們的預期。
此時不僅要問,為什么呢?
主要原因在于accept、read、write三個函數都是阻塞的,主線程在系統調用的時候,線程是被阻塞的,其他客戶端的連接無法被響應。
通過以上流程,我們很容易發現這個過程的缺陷,服務器每次只能處理一個連接請求,cpu沒有得到充分利用,性能比較低。如何充分利用cpu的多核特性呢?自然而然的想到了——多線程邏輯。
2.1.2 多線程阻塞
對工程師而言,代碼解釋一切,直接上代碼。
bio多線程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
package net.io.bio; import java.io.bufferedreader; import java.io.ioexception; import java.io.inputstreamreader; import java.net.serversocket; import java.net.socket; public class biotest { public static void main(string[] args) throws ioexception { final serversocket server= new serversocket( 8081 ); while ( true ) { new thread( new runnable() { public void run() { socket socket= null ; try { socket = server.accept(); system.out.println( "accept port:" +socket.getport()); bufferedreader in= new bufferedreader( new inputstreamreader(socket.getinputstream())); string indata= null ; while ((indata = in.readline()) != null ) { system.out.println( "client port:" +socket.getport()); system.out.println( "input data:" +indata); if ( "close" .equals(indata)) { socket.close(); } } } catch (ioexception e) { e.printstacktrace(); } finally { } } }).start(); } } } |
同樣,我們并行發起兩個請求;
兩個請求,都被接受,服務端新增兩個線程來處理客戶端的連接和后續請求。
我們用多線程解決了,服務器同時只能處理一個請求的問題,但同時又帶來了一個問題,如果客戶端連接比較多時,服務端會創建大量的線程來處理請求,但線程本身是比較耗資源的,創建、上下文切換都比較耗資源,又如何去解決呢?
2.2 非阻塞
如果我們把所有的socket(文件句柄,后續用socket來代替fd的概念,盡量減少概念,減輕閱讀負擔)都放到隊列里,只用一個線程來輪訓所有的socket的狀態,如果準備好了就把它拿出來,是不是就減少了服務端的線程數呢?
一起看下代碼,單純非阻塞模式,我們基本上不用,為了演示邏輯,我們模擬了相關代碼如下;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
package net.io.bio; import java.io.bufferedreader; import java.io.ioexception; import java.io.inputstreamreader; import java.net.serversocket; import java.net.socket; import java.net.sockettimeoutexception; import java.util.arraylist; import java.util.list; import org.apache.commons.collections4.collectionutils; public class niotest { public static void main(string[] args) throws ioexception { final serversocket server= new serversocket( 8082 ); server.setsotimeout( 1000 ); list<socket> sockets= new arraylist<socket>(); while ( true ) { socket socket = null ; try { socket = server.accept(); socket.setsotimeout( 500 ); sockets.add(socket); system.out.println( "accept client port:" +socket.getport()); } catch (sockettimeoutexception e) { system.out.println( "accept timeout" ); } //模擬非阻塞:輪詢已連接的socket,每個socket等待10ms,有數據就處理,無數據就返回,繼續輪詢 if (collectionutils.isnotempty(sockets)) { for (socket sockettemp:sockets ) { try { bufferedreader in= new bufferedreader( new inputstreamreader(sockettemp.getinputstream())); string indata= null ; while ((indata = in.readline()) != null ) { system.out.println( "input data client port:" +sockettemp.getport()); system.out.println( "input data client port:" +sockettemp.getport() + "data:" +indata); if ( "close" .equals(indata)) { sockettemp.close(); } } } catch (sockettimeoutexception e) { system.out.println( "input client loop" +sockettemp.getport()); } } } } } } |
系統初始化,等待連接;
發起兩個客戶端連接,線程開始輪詢兩個連接中是否有數據。
兩個連接分別輸入數據后,輪詢線程發現有數據準備好了,開始相關的邏輯處理(單線程、多線程都可)。
再用一張流程圖輔助解釋下(系統實際采用文件句柄,此時用socket來代替,方便大家理解)。
服務端專門有一個線程來負責輪詢所有的socket,來確認操作系統是否完成了相關事件,如果有則返回處理,如果無繼續輪詢,大家一起來思考下?此時又帶來了什么問題呢。
cpu的空轉、系統調用(每次輪詢到涉及到一次系統調用,通過內核命令來確認數據是否準備好),造成資源的浪費,那有沒有一種機制,來解決這個問題呢?
2.3 io多路復用
server端有沒專門的線程來做輪詢操作(應用程序端非內核),而是由事件來觸發,當有相關讀、寫、連接事件到來時,主動喚起服務端線程來進行相關邏輯處理。模擬了相關代碼如下;
io多路復用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
import java.net.inetsocketaddress; import java.nio.bytebuffer; import java.nio.channels.selectionkey; import java.nio.channels.selector; import java.nio.channels.serversocketchannel; import java.nio.channels.socketchannel; import java.nio.charset.charset; import java.util.iterator; import java.util.set; public class nioserver { private static charset charset = charset.forname( "utf-8" ); public static void main(string[] args) { try { selector selector = selector.open(); serversocketchannel chanel = serversocketchannel.open(); chanel.bind( new inetsocketaddress( 8083 )); chanel.configureblocking( false ); chanel.register(selector, selectionkey.op_accept); while ( true ){ int select = selector.select(); if (select == 0 ){ system.out.println( "select loop" ); continue ; } system.out.println( "os data ok" ); set<selectionkey> selectionkeys = selector.selectedkeys(); iterator<selectionkey> iterator = selectionkeys.iterator(); while (iterator.hasnext()){ selectionkey selectionkey = iterator.next(); if (selectionkey.isacceptable()){ serversocketchannel server = (serversocketchannel)selectionkey.channel(); socketchannel client = server.accept(); client.configureblocking( false ); client.register(selector, selectionkey.op_read); //繼續可以接收連接事件 selectionkey.interestops(selectionkey.op_accept); } else if (selectionkey.isreadable()){ //得到socketchannel socketchannel client = (socketchannel)selectionkey.channel(); //定義緩沖區 bytebuffer buffer = bytebuffer.allocate( 1024 ); stringbuilder content = new stringbuilder(); while (client.read(buffer) > 0 ){ buffer.flip(); content.append(charset.decode(buffer)); } system.out.println( "client port:" +client.getremoteaddress().tostring()+ ",input data: " +content.tostring()); //清空緩沖區 buffer.clear(); } iterator.remove(); } } } catch (exception e) { e.printstacktrace(); } } } |
同時創建兩個連接;
兩個連接無阻塞的被創建;
無阻塞的接收讀寫;
再用一張流程圖輔助解釋下(系統實際采用文件句柄,此時用socket來代替,方便大家理解)。
當然操作系統的多路復用有好幾種實現方式,我們經常使用的select(),epoll模式這里不做過多的解釋,有興趣的可以查看相關文檔,io的發展后面還有異步、事件等模式,我們在這里不過多的贅述,我們更多的是為了解釋redis線程模式的發展。
三、nio線程模型解釋
我們一起來聊了阻塞、非阻塞、io多路復用模式,那redis采用的是哪種呢?
redis采用的是io多路復用模式,所以我們重點來了解下多路復用這種模式,如何在更好的落地到我們系統中,不可避免的我們要聊下reactor模式。
首先我們做下相關的名詞解釋;
reactor:類似nio編程中的selector,負責i/o事件的派發;
acceptor:nio中接收到事件后,處理連接的那個分支邏輯;
handler:消息讀寫處理等操作類。
3.1 單reactor單線程模型
處理流程
- reactor監聽連接事件、socket事件,當有連接事件過來時交給acceptor處理,當有socket事件過來時交個對應的handler處理。
優點
- 模型比較簡單,所有的處理過程都在一個連接里;
- 實現上比較容易,模塊功能也比較解耦,reactor負責多路復用和事件分發處理,acceptor負責連接事件處理,handler負責scoket讀寫事件處理。
缺點
- 只有一個線程,連接處理和業務處理共用一個線程,無法充分利用cpu多核的優勢。
- 在流量不是特別大、業務處理比較快的時候系統可以有很好的表現,當流量比較大、讀寫事件比較耗時情況下,容易導致系統出現性能瓶頸。
怎么去解決上述問題呢?既然業務處理邏輯可能會影響系統瓶頸,那我們是不是可以把業務處理邏輯單拎出來,交給線程池來處理,一方面減小對主線程的影響,另一方面利用cpu多核的優勢。這一點希望大家要理解透徹,方便我們后續理解redis由單線程模型到多線程模型的設計的思路。
3.2 單reactor多線程模型
這種模型相對單reactor單線程模型,只是將業務邏輯的處理邏輯交給了一個線程池來處理。
處理流程
- reactor監聽連接事件、socket事件,當有連接事件過來時交給acceptor處理,當有socket事件過來時交個對應的handler處理。
- handler完成讀事件后,包裝成一個任務對象,交給線程池來處理,把業務處理邏輯交給其他線程來處理。
優點
- 讓主線程專注于通用事件的處理(連接、讀、寫),從設計上進一步解耦;
- 利用cpu多核的優勢。
缺點
- 貌似這種模型已經很完美了,我們再思考下,如果客戶端很多、流量特別大的時候,通用事件的處理(讀、寫)也可能會成為主線程的瓶頸,因為每次讀、寫操作都涉及系統調用。
有沒有什么好的辦法來解決上述問題呢?通過以上的分析,大家有沒有發現一個現象,當某一個點成為系統瓶頸點時,想辦法把他拿出來,交個其他線程來處理,那這種場景是否適用呢?
3.3 多reactor多線程模型
這種模型相對單reactor多線程模型,只是將scoket的讀寫處理從mainreactor中拎出來,交給subreactor線程來處理。
處理流程
- mainreactor主線程負責連接事件的監聽和處理,當acceptor處理完連接過程后,主線程將連接分配給subreactor;
- subreactor負責mainreactor分配過來的socket的監聽和處理,當有socket事件過來時交個對應的handler處理;
handler完成讀事件后,包裝成一個任務對象,交給線程池來處理,把業務處理邏輯交給其他線程來處理。
優點
- 讓主線程專注于連接事件的處理,子線程專注于讀寫事件吹,從設計上進一步解耦;
- 利用cpu多核的優勢。
缺點
- 實現上會比較復雜,在極度追求單機性能的場景中可以考慮使用。
四、redis的線程模型
4.1 概述
以上我們聊了,io網路模型的發展歷史,也聊了io多路復用的reactor模式。那redis采用的是哪種reactor模式呢?在回答這個問題前,我們先梳理幾個概念性的問題。
redis服務器中有兩類事件,文件事件和時間事件。
- 文件事件:在這里可以把文件理解為socket相關的事件,比如連接、讀、寫等;
- 時間時間:可以理解為定時任務事件,比如一些定期的rdb持久化操作。
本文重點聊下socket相關的事件。
4.2 模型圖
首先我們來看下redis服務的線程模型圖;
io多路復用負責各事件的監聽(連接、讀、寫等),當有事件發生時,將對應事件放入隊列中,由事件分發器根據事件類型來進行分發;
如果是連接事件,則分發至連接應答處理器;get、set等redis命令分發至命令請求處理器。
命令處理完后產生命令回復事件,再由事件隊列,到事件分發器,到命令回復處理器,回復客戶端響應。
4.3 一次客戶端和服務端的交互流程
4.3.1 連接流程
連接過程
- redis服務端主線程監聽固定端口,并將連接事件綁定連接應答處理器。
- 客戶端發起連接后,連接事件被觸發,io多路復用程序將連接事件包裝好后丟人事件隊列,然后由事件分發處理器分發給連接應答處理器。
- 連接應答處理器創建client對象以及socket對象,我們這里關注socket對象,并產生ae_readable事件,和命令處理器關聯,標識后續該socket對可讀事件感興趣,也就是開始接收客戶端的命令操作。
- 當前過程都是由一個主線程負責處理。
4.3.2 命令執行流程
set命令執行過程
- 客戶端發起set命令,io多路復用程序監聽到該事件后(讀事件),將數據包裝成事件丟到事件隊列中(事件在上個流程中綁定了命令請求處理器);
- 事件分發處理器根據事件類型,將事件分發給對應的命令請求處理器;
- 命令請求處理器,讀取socket中的數據,執行命令,然后產生ae_writable事件,并綁定命令回復處理器;
- io多路復用程序監聽到寫事件后,將數據包裝成事件丟到事件隊列中,事件分發處理器根據事件類型分發至命令回復處理器;
- 命令回復處理器,將數據寫入socket中返回給客戶端。
4.4 模型優缺點
以上流程分析我們可以看出redis采用的是單線程reactor模型,我們也分析了這種模式的優缺點,那redis為什么還要采用這種模式呢?
redis本身的特性
命令執行基于內存操作,業務處理邏輯比較快,所以命令處理這一塊單線程來做也能維持一個很高的性能。
優點
- reactor單線程模型的優點,參考上文。
缺點
- reactor單線程模型的缺點也同樣在redis中來體現,唯一不同的地方就在于業務邏輯處理(命令執行)這塊不是系統瓶頸點。
- 隨著流量的上漲,io操作的的耗時會越來越明顯(read操作,內核中讀數據到應用程序。write操作,應用程序中的數據到內核),當達到一定閥值時系統的瓶頸就體現出來了。
redis又是如何去解的呢?
哈哈~將耗時的點從主線程拎出來唄?那redis的新版本是這么做的嗎?我們一起來看下。
4.5 redis多線程模式
redis的多線程模型跟”多reactor多線程模型“、“單reactor多線程模型有點區別”,但同時用了兩種reactor模型的思想,具體如下;
- redis的多線程模型是將io操作多線程化,本身邏輯處理過程(命令執行過程)依舊是單線程,借助了單reactor思想,實現上又有所區分。
- 將io操作多線程化,又跟單reactor衍生出多reactor的思想一致,都是將io操作從主線程中拎出來。
命令執行大致流程
- 客戶端發送請求命令,觸發讀就緒事件,服務端主線程將socket(為了簡化理解成本,統一用socket來代表連接)放入一個隊列,主線程不負責讀;
- io 線程通過socket讀取客戶端的請求命令,主線程忙輪詢,等待所有 i/o 線程完成讀取任務,io線程只負責讀不負責執行命令;
- 主線程一次性執行所有命令,執行過程和單線程一樣,然后需要返回的連接放入另外一個隊列中,有io線程來負責寫出(主線程也會寫);
- 主線程忙輪詢,等待所有 i/o 線程完成寫出任務。
五、總結
了解一個組件,更多的是要去了解他的設計思路,要去思考為什么要這么設計,做這種技術選型的背景是啥,對后續做系統架構設計有什么參考意義等等。一通百通,希望對大家有參考意義。
到此這篇關于redis線程模型的原理分析的文章就介紹到這了,更多相關redis線程模型內容請搜索服務器之家以前的文章或繼續瀏覽下面的相關文章希望大家以后多多支持服務器之家!
原文鏈接:https://www.cnblogs.com/vivotech/p/15622917.html