Redis執行緒模型的前世今生
一、概述
眾所周知,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 單執行緒阻塞
服務端單執行緒來處理,當客戶端請求來臨時,服務端用主執行緒來處理連線、讀取、寫入等操作。
以下用程式碼模擬了單執行緒的阻塞模式;
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多執行緒
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的狀態,如果準備好了就把它拿出來,是不是就減少了服務端的執行緒數呢?
一起看下程式碼,單純非阻塞模式,我們基本上不用,為了演示邏輯,我們模擬了相關程式碼如下;
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多路複用
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 執行緒完成寫出任務。
五、總結
瞭解一個元件,更多的是要去了解他的設計思路,要去思考為什麼要這麼設計,做這種技術選型的背景是啥,對後續做系統架構設計有什麼參考意義等等。一通百通,希望對大家有參考意義。
作者:vivo網際網路伺服器團隊-Wang Shaodong
- vivo資料庫與儲存平臺的建設和探索
- 事件驅動架構在 vivo 內容平臺的實踐
- vivo資料庫與儲存平臺的建設和探索
- 事件驅動架構在 vivo 內容平臺的實踐
- vivo 推送平臺架構演進
- Jetpack—LiveData元件的缺陷以及應對策略
- Jetpack—LiveData元件的缺陷以及應對策略
- vivo 推送平臺架構演進
- 前端質量提升利器-馬可程式碼覆蓋率平臺
- 前端質量提升利器-馬可程式碼覆蓋率平臺
- 版本不相容Jar包衝突該如何是好?
- DevTools 實現原理與效能分析實戰
- JDK ThreadPoolExecutor核心原理與實踐
- 版本不相容Jar包衝突該如何是好?
- DevTools 實現原理與效能分析實戰
- Kubernetes 叢集無損升級實踐
- 2021 VDC :vivo 網際網路服務億級使用者的技術架構演進之路 | 附PPT 下載
- Kubernetes 叢集無損升級實踐
- JavaMoney規範(JSR 354)與對應實現解讀
- vivo瀏覽器的快速開發平臺實踐-總覽篇