Redis是單進程單線程?爲什麼這麼快:
進入redis 安裝目錄下執行以下命令,查看set lpush命令的處理效率:./redis-benchmark -t set,lpush -n 100000 -q
根據官方的數據,Redis 的 QPS 可以達到 10 萬左右(每秒請求數)。
就我這個虛擬機的性能,可以看到每秒鐘處理 13 萬多次 set 請求,每秒鐘處理 13 萬次左右 set 請求。可見redis性能之高.原因有如下三點:
- 純內存結構。KV 結構的內存數據庫,時間複雜度 O(1)。
- 單線程。單線程有什麼好處呢?
- 沒有創建線程、銷燬線程帶來的消耗。
- 避免了上線文切換導致的 CPU 消耗。
- 避免了線程之間帶來的競爭問題,例如加鎖釋放鎖死鎖等等。
- 多路複用。異步非阻塞 I/O,多路複用處理併發連接。
Redis採用了一種非常簡單的做法,單線程來處理來自所有客戶端的併發請求,Redis把任務封閉在一個線程中從而避免了線程安全問題;redis爲什麼是單線程?官方的解釋是,CPU並不是Redis的瓶頸所在,Redis的瓶頸主要在機器的內存和網絡的帶寬。那麼Redis能不能處理高併發請求呢?當然是可以的,至於怎麼實現的,我們來具體瞭解一下。 【注意併發不等於並行,併發性I/O流,意味着能夠讓一個計算單元來處理來自多個客戶端的流請求。並行性,意味着服務器能夠同時執行幾個事情,具有多個計算單元】
單線程爲什麼這麼快?
因爲 Redis 是基於內存的操作,我們先從內存開始說起。
虛擬存儲器 ( 虛擬 內存 l Vitual Memory ):
名詞解釋:主存:內存;輔存:磁盤(硬盤)
計算機主存(內存)可看作一個由 M 個連續的字節大小的單元組成的數組,每個字節有一個唯一的地址,這個地址叫做物理地址(PA)。早期的計算機中,如果 CPU 需要內存,使用物理尋址,直接訪問主存儲器。
這種方式有幾個弊端:
- 在多用戶多任務操作系統中,所有的進程共享主存,如果每個進程都獨佔一塊物理地址空間,主存很快就會被用完。我們希望在不同的時刻,不同的進程可以共用同一塊物理地址空間。
- 如果所有進程都是直接訪問物理內存,那麼一個進程就可以修改其他進程的內存數據,導致物理地址空間被破壞,程序運行就會出現異常。
爲了解決這些問題,我們就想了一個辦法,在 CPU 和主存之間增加一箇中間層。CPU不再使用物理地址訪問,而是訪問一個虛擬地址,由這個中間層把地址轉換成物理地址,最終獲得數據。這個中間層就叫做虛擬存儲器(Virtual Memory)。具體的操作如下所示:
在每一個進程開始創建的時候,都會分配一段虛擬地址,然後通過虛擬地址和物理地址的映射來獲取真實數據,這樣進程就不會直接接觸到物理地址,甚至不知道自己調用的哪塊物理地址的數據。
目前,大多數操作系統都使用了虛擬內存,如 Windows 系統的虛擬內存、Linux 系統的交換空間等等。Windows 的虛擬內存(pagefile.sys)是磁盤空間的一部分。在 32 位的系統上,虛擬地址空間大小是 2^32bit=4G。在 64 位系統上,最大虛擬地址空間大小是多少?是不是 2^64bit=1024*1014TB=1024PB=16EB?實際上沒有用到 64 位,因爲用不到這麼大的空間,而且會造成很大的系統開銷。Linux 一般用低48 位來表示虛擬地址空間,也就是 2^48bit=256T。可以通過命令cat /proc/cpuinfo查看
address sizes : 40 bits physical, 48 bits virtual
實際的物理內存可能遠遠小於虛擬內存的大小。
總結:引入虛擬內存,可以提供更大的地址空間,並且地址空間是連續的,使得程序編寫、鏈接更加簡單。並且可以對物理內存進行隔離,不同的進程操作互不影響。還可以通過把同一塊物理內存映射到不同的虛擬地址空間實現內存共享。
用戶空間和內核空間:
爲了避免用戶進程直接操作內核,保證內核安全,操作系統將虛擬內存劃分爲兩部分,一部分是內核空間(Kernel-space)/ˈkɜːnl /,一部分是用戶空間(User-space)。
內核是操作系統的核心,獨立於普通的應用程序,可以訪問受保護的內存空間,也有訪問底層硬件設備的權限。
內核空間中存放的是內核代碼和數據,而進程的用戶空間中存放的是用戶程序的代碼和數據。不管是內核空間還是用戶空間,它們都處於虛擬空間中,都是對物理地址的映射。
在 Linux 系統中, 內核進程和用戶進程所佔的虛擬內存比例是 1:3。
當進程運行在內核空間時就處於內核態,而進程運行在用戶空間時則處於用戶態。進程在內核空間以執行任意命令,調用系統的一切資源;在用戶空間只能執行簡單的運算,不能直接調用系統資源,必須通過系統接口(又稱 system call),才能向內核發出指令。top 命令:
us 代表 CPU 消耗在 User space 的時間百分比;
sy 代表 CPU 消耗在 Kernel space 的時間百分比。
進程切換(上下文 切換 ):
多任務操作系統是怎麼實現運行遠大於 CPU 數量的任務個數的?當然,這些任務實際上並不是真的在同時運行,而是因爲系統通過時間片分片算法,在很短的時間內,將CPU 輪流分配給它們,造成多任務同時運行的錯覺。
爲了控制進程的執行,內核必須有能力掛起正在 CPU 上運行的進程,並恢復以前掛起的某個進程的執行。這種行爲被稱爲進程切換。
什麼叫上下文?
在每個任務運行前,CPU 都需要知道任務從哪裏加載、又從哪裏開始運行,也就是說,需要系統事先幫它設置好 CPU 寄存器和程序計數器(Program Counter),這個叫做CPU 的上下文。
而這些保存下來的上下文,會存儲在系統內核中,並在任務重新調度執行時再次加載進來。這樣就能保證任務原來的狀態不受影響,讓任務看起來還是連續運行。在切換上下文的時候,需要完成一系列的工作,這是一個很消耗資源的操作。
進程的阻塞:
正在運行的進程由於提出系統服務請求(如 I/O 操作),但因爲某種原因未得到操作系統的立即響應,該進程只能把自己變成阻塞狀態,等待相應的事件出現後才被喚醒。進程在阻塞狀態不佔用 CPU 資源。
文件描述符 FD:
Linux 系統將所有設備都當作文件來處理,而 Linux 用文件描述符來標識每個文件對象。文件描述符(File Descriptor)是內核爲了高效管理已被打開的文件所創建的索引,用於指向被打開的文件,所有執行 I/O 操作的系統調用都通過文件描述符;文件描述符是一個簡單的非負整數,用以表明每個被進程打開的文件。Linux 系統裏面有三個標準文件描述符。0:標準輸入(鍵盤);1:標準輸出(顯示器);2:標準錯誤輸出(顯示器)。
Redis的高性能主要依賴於幾個方面。 C語言實現,C語言在一定程度上還是比Java語言性能要高一些,因爲C語言不需要經過JVM進行翻 譯。 純內存I/O,內存I/O比磁盤I/O性能更快 I/O多路複用,基於epoll的I/O多路複用技術,實現高吞吐網絡I/O 單線程模型,單線程無法利用到多核CPU,但是在Redis中,性能瓶頸並不是在計算上,而是在I/O 能力,所以單線程能夠滿足高併發的要求。 從另一個層面來說,單線程可以避免多線程的頻繁上 下文切換以及同步鎖機制帶來的性能開銷。 下面我們分別從上述幾個方面進行展開說明。
從請求處理開始分析:
- 接收,通過TCP接收到命令,可能會歷經多次TCP包、ack、IO操作
- 解析,將命令取出來
- 執行,到對應的地方將value讀出來
- 返回,將value通過TCP返回給客戶端,如果value較大,則IO負荷會更重
其中解析和執行是純cpu/內存操作,而接收和返回主要是IO操作,首先我們先來看通信的過程。
網絡IO的通信原理 :
同樣,我也畫了一幅圖來描述網絡數據的傳輸流程
首先,對於TCP通信來說,每個TCP Socket的內核中都有一個發送緩衝區和一個接收緩衝區 接收緩衝區把數據緩存到內核,若應用進程一直沒有調用Socket的read方法進行讀取,那麼該數據會一 直被緩存在接收緩衝區內。不管進程是否讀取Socket,對端發來的數據都會經過內核接收並緩存到 Socket的內核接收緩衝區。
read所要做的工作,就是把內核接收緩衝區中的數據複製到應用層用戶的Buffer裏。 進程調用Socket的send發送數據的時候,一般情況下是將數據從應用層用戶的Buffer裏複製到Socket的 內核發送緩衝區,然後send就會在上層返回。換句話說,send返回時,數據不一定會被髮送到對端。
網卡中的緩衝區既不屬於內核空間,也不屬於用戶空間。它屬於硬件緩衝,允許網卡與操作系統之間有 個緩衝; 內核緩衝區在內核空間,在內存中,用於內核程序,做爲讀自或寫往硬件的數據緩衝區; 用 戶緩衝區在用戶空間,在內存中,用於用戶程序,做爲讀自或寫往硬件的數據緩衝區 網卡芯片收到網絡數據會以中斷的方式通知CPU,我有數據了,存在我的硬件緩衝裏了,來讀我啊。 CPU收到這個中斷信號後,會調用相應的驅動接口函數從網卡的硬件緩衝裏把數據讀到內核緩衝區,正 常情況下會向上傳遞給TCP/IP模塊一層一層的處理。
BIO 傳統阻塞IO :
Redis的通信採用的是多路複用機制,什麼是多路複用機制呢? 由於Redis是C語言實現,爲了簡化大家的理解,我們採用Java語言來描述這個過程。 在理解多路複用之前,我們先來了解一下BIO。
BIO模型 在Java中,如果要實現網絡通信,我們會採用Socket套接字來完成。
Socket這不是一個協議,而是一個通信模型。其實它最初是BSD發明的,主要用來一臺電腦的兩個進程 間通信,然後把它用到了兩臺電腦的進程間通信。所以,可以把它簡單理解爲進程間通信,不是什麼高 級的東西。主要做的事情不就是:
- A發包:發請求包給某個已經綁定的端口(所以我們經常會訪問這樣的地址182.13.15.16:1235, 1235就是端口);收到B的允許;然後正式發送;發送完了,告訴B要斷開鏈接;收到斷開允許, 馬上斷開,然後發送已經斷開信息給B。
- B收包:綁定端口和IP;然後在這個端口監聽;接收到A的請求,發允許給A,並做好接收準備,主 要就是清理緩存等待接收新數據;然後正式接收;接受到斷開請求,允許斷開;確認斷開後,繼續 監聽其它請求。
可見,Socket其實就是I/O操作,Socket並不僅限於網絡通信,在網絡通信中,它涵蓋了網絡層、傳輸 層、會話層、表示層、應用層——其實這都不需要記,因爲Socket通信時候用到了IP和端口,僅這兩個 就表明了它用到了網絡層和傳輸層;而且它無視多臺電腦通信的系統差別,所以它涉及了表示層;一般 Socket都是基於一個應用程序的,所以會涉及到會話層和應用層。
構建基礎的BIO通信模型 ,BIO有什麼弊端呢? 當服務端收到客戶端的請求後,不直接返回,而是等待20s。
public class BIOServerSocket {
//先定義一個端口號,這個端口的值是可以自己調整的。
static final int DEFAULT_PORT = 8080;
public static void main(String[] args) throws IOException,
InterruptedException {
ServerSocket serverSocket = null;
serverSocket = new ServerSocket(DEFAULT_PORT);
System.out.println("啓動服務,監聽端口:" + DEFAULT_PORT);
while (true) { //case1: 增加循環,允許循環接收請求
Socket socket = serverSocket.accept();
System.out.println("客戶端:" + socket.getPort() + "已連接");
BufferedReader bufferedReader = new BufferedReader(new
InputStreamReader(socket.getInputStream()));
String clientStr = bufferedReader.readLine(); //讀取一行信息
System.out.println("客戶端發了一段消息:" + clientStr);
Thread.sleep(20000); //case2: 修改:增加等待時間
BufferedWriter bufferedWriter = new BufferedWriter(new
OutputStreamWriter(socket.getOutputStream()));
bufferedWriter.write("我已經收到你的消息了\n");
bufferedWriter.flush(); //清空緩衝區觸發消息發送
}
}
}
傳統 I/O 數據拷貝:
以讀操作爲例:當應用程序執行 read 系統調用讀取文件描述符(FD)的時候,如果這塊數據已經存在於用戶進程的頁內存中,就直接從內存中讀取數據。如果數據不存在,則先將數據從磁盤加載數據到內核緩衝區中,再從內核緩衝區拷貝到用戶進程的頁內存中。(兩次拷貝,兩次 user 和 kernel 的上下文切換)。
I/O 的阻塞到底阻塞在哪裏?Blocking I/O:
當使用 read 或 write 對某個文件描述符進行過讀寫時,如果當前 FD 不可讀,系統就不會對其他的操作做出響應。從設備複製數據到內核緩衝區是阻塞的,從內核緩衝區拷貝到用戶空間,也是阻塞的,直到 copy complete,內核返回結果,用戶進程才解除block 的狀態。
爲了解決阻塞的問題,我們有幾個思路。
- 在服務端創建多個線程或者使用線程池,但是在高併發的情況下需要的線程會很多,系統無法承受,而且創建和釋放線程都需要消耗資源。
- 由請求方定期輪詢,在數據準備完畢後再從內核緩存緩衝區複製數據到用戶空間(非阻塞式 I/O),這種方式會存在一定的延遲。
能不能用一個線程處理多個客戶端請求?
NIO非阻塞IO:
使用多線程的方式來解決這個問題,仍然有一個缺點,線程的數量取決於硬件配置,所以線程數量是有 限的,如果請求量比較大的時候,線程本身會收到限制從而併發量也不會太高。那怎麼辦呢,我們可以 採用非阻塞IO。 NIO 從JDK1.4 提出的,本意是New IO,它的出現爲了彌補原本IO的不足,提供了更高效的方式,提出 一個通道(channel)的概念,在IO中它始終以流的形式對數據的傳輸和接受,下面我們演示一下NIO 的使用。 所謂的NIO(非阻塞IO),其實就是取消了IO阻塞和連接阻塞,當服務端不存在阻塞的時候,就可以不 斷輪詢處理客戶端的請求,如圖所示,表示NIO下的運行流程。
public class NIOServerSocket {
public static void main(String[] args) {
try {
ServerSocketChannel serverSocketChannel=ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false); //設置連接非阻塞
serverSocketChannel.socket().bind(new InetSocketAddress(8080));
while(true){
//是非阻塞的
SocketChannel socketChannel=serverSocketChannel.accept(); //獲得一個客戶端連接
// socketChannel.configureBlocking(false);//IO非阻塞
if(socketChannel!=null){
ByteBuffer byteBuffer=ByteBuffer.allocate(1024);
int i=socketChannel.read(byteBuffer);
Thread.sleep(10000);
byteBuffer.flip(); //反轉
socketChannel.write(byteBuffer);
}else{
Thread.sleep(1000);
System.out.println("連接位就緒");
}
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
大家站在全局的角度再思考一下整個過程,有哪些地方可以優化呢?
NIO多路複用機制:
- I/O 指的是網絡 I/O。
- 多路指的是多個 TCP 連接(Socket 或 Channel)。
- 複用指的是複用一個或多個線程。
它的基本原理就是不再由應用程序自己監視連接,而是由內核替應用程序監視文件描述符。客戶端在操作的時候,會產生具有不同事件類型的 socket。在服務端,I/O 多路複用程序(I/O Multiplexing Module)會把消息放入隊列中,然後通過文件事件分派器(Fileevent Dispatcher),轉發到不同的事件處理器中。
多路複用有很多的實現,以 select 爲例,當用戶進程調用了多路複用器,進程會被阻塞。內核會監視多路複用器負責的所有 socket,當任何一個 socket 的數據準備好了,多路複用器就會返回。這時候用戶進程再調用 read 操作,把數據從內核緩衝區拷貝到用戶空間。
所以,I/O 多路複用的特點是通過一種機制一個進程能同時等待多個文件描述符,而這些文件描述符(套接字描述符)其中的任意一個進入讀就緒(readable)狀態,select()函數就可以返回。Redis 的多路複用, 提供了 select, epoll, evport, kqueue 幾種選擇,在編譯的時候來選擇一種。
- evport 是 Solaris 系統內核提供支持的;
- epoll 是 LINUX 系統內核提供支持的;
- kqueue 是 Mac 系統提供支持的;
- select 是 POSIX 提供的,一般的操作系統都有支撐(保底方案);
我們看到 NIOClientSocket中下面這段代碼,當客戶端通過 read 方法去讀取服務端返回的數據時,如果 此時服務端數據未準備好,對於客戶端來說就是一次無效的輪詢。
while(true) {
int i = socketChannel.read(byteBuffer);
if (i > 0) {
System.out.println("收到服務端的數據:" + new String(byteBuffer.array()));
} else {
System.out.println("服務端數據未準備好");
Thread.sleep(1000);
}
}
我們能不能夠設計成,當客戶端調用 read 方法之後,不僅僅不阻塞,同時也不需要輪詢。而是等到服 務端的數據就緒之後, 告訴客戶端。然後客戶端再去讀取服務端返回的數據呢?就像點外賣一樣,我們在網上下單之後,繼續做其他事情,等到外賣到了公司,外賣小哥主動打 電話告訴你,你直接去前臺取餐即可。
所以爲了優化這個問題,引入了多路複用機制。
I/O多路複用的本質是通過一種機制(系統內核緩衝I/O數據),讓單個進程可以監視多個文件描述符, 一旦某個描述符就緒(一般是讀就緒或寫就緒),能夠通知程序進行相應的讀寫操作
什麼是fd:在linux中,內核把所有的外部設備都當成是一個文件來操作,對一個文件的讀寫會調 用內核提供的系統命令,返回一個fd(文件描述符)。而對於一個socket的讀寫也會有相應的文件描 述符,成爲socketfd。
常見的IO多路複用方式有【select、poll、epoll】,都是Linux API提供的IO複用方式,那麼接下來重 點講一下select、和epoll這兩個模型
-
select:進程可以通過把一個或者多個fd傳遞給select系統調用,進程會阻塞在select操作上,這 樣select可以幫我們檢測多個fd是否處於就緒狀態,這個模式有兩個缺點 由於他能夠同時監聽多個文件描述符,假如說有1000個,這個時候如果其中一個fd 處於就緒 狀態了,那麼當前進程需要線性輪詢所有的fd,也就是監聽的fd越多,性能開銷越大。 同時,select在單個進程中能打開的fd是有限制的,默認是1024,對於那些需要支持單機上 萬的TCP連接來說確實有點少
-
epoll:linux還提供了epoll的系統調用,epoll是基於事件驅動方式來代替順序掃描,因此性能相 對來說更高,主要原理是,當被監聽的fd中,有fd就緒時,會告知當前進程具體哪一個fd就緒,那 麼當前進程只需要去從指定的fd上讀取數據即可,另外,epoll所能支持的fd上線是操作系統的最 大文件句柄,這個數字要遠遠大於1024
由於epoll能夠通過事件告知應用進程哪個fd是可讀的,所以我們也稱這種IO爲異步非阻塞IO, 當然它是僞異步的,因爲它還需要去把數據從內核同步複製到用戶空間中,真正的異步非阻塞, 應該是數據已經完全準備好了,我只需要從用戶空間讀就行.
I/O多路複用的好處是可以通過把多個I/O的阻塞複用到同一個select的阻塞上,從而使得系統在單線程 的情況下可以同時處理多個客戶端請求。它的最大優勢是系統開銷小,並且不需要創建新的進程或者線 程,降低了系統的資源開銷,它的整體實現思想如下圖所示。
客戶端請求到服務端後,此時客戶端在傳輸數據過程中,爲了避免Server端在read客戶端數據過程中阻 塞,服務端會把該請求註冊到Selector復路器上,服務端此時不需要等待,只需要啓動一個線程,通過 selector.select()阻塞輪詢復路器上就緒的channel即可。
也就是說,如果某個客戶端連接數據傳輸完 成,那麼select()方法會返回就緒的channel,然後執行相關的處理即可。
public class NIOSelectorServerSocket implements Runnable{
Selector selector;
ServerSocketChannel serverSocketChannel;
public NIOSelectorServerSocket(int port) throws IOException {
selector=Selector.open();
serverSocketChannel=ServerSocketChannel.open();
//如果採用selector模型,必須要設置非阻塞
serverSocketChannel.configureBlocking(false);
serverSocketChannel.socket().bind(new InetSocketAddress(port));
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
}
@Override
public void run() {
while(!Thread.interrupted()){
try {
selector.select(); //阻塞等待事件就緒
Set selected=selector.selectedKeys(); //事件列表
Iterator it=selected.iterator();
while(it.hasNext()){
//說明有連接進來
dispatch((SelectionKey) it.next());
it.remove();//移除當前就緒的事件
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
private void dispatch(SelectionKey key) throws IOException {
if(key.isAcceptable()){ //是連接事件?
register(key);
}else if(key.isReadable()){ //讀事件
read(key);
}else if(key.isWritable()){ //寫事件
//TODO
}
}
private void register(SelectionKey key) throws IOException {
ServerSocketChannel channel= (ServerSocketChannel) key.channel(); //客戶端連接
SocketChannel socketChannel=channel.accept(); //獲得客戶端連接
socketChannel.configureBlocking(false);
socketChannel.register(selector,SelectionKey.OP_READ);
}
private void read(SelectionKey key) throws IOException {
//得到的是socketChannel
SocketChannel channel= (SocketChannel) key.channel();
ByteBuffer byteBuffer=ByteBuffer.allocate(1024);
channel.read(byteBuffer);
System.out.println("Server Receive Msg:"+new String(byteBuffer.array()));
}
public static void main(String[] args) throws IOException {
NIOSelectorServerSocket selectorServerSocket=new NIOSelectorServerSocket(8080);
new Thread(selectorServerSocket).start();
}
}
事實上NIO已經解決了上述BIO暴露的下面兩個問題:
-
同步阻塞IO,讀寫阻塞,線程等待時間過長。
-
在制定線程策略的時候,只能根據CPU的數目來限定可用線程資源,不能根據連接併發數目來制 定,也就是連接有限制。否則很難保證對客戶端請求的高效和公平。
到這裏爲止,通過NIO的多路複用機制,解決了IO阻塞導致客戶端連接處理受限的問題,服務端只需要 一個線程就可以維護多個客戶端,並且客戶端的某個連接如果準備就緒時,會通過事件機制告訴應用程 序某個channel可用,應用程序通過select方法選出就緒的channel進行處理。
單線程Reactor 模型(高性能I/O設計模式):
瞭解了NIO多路複用後,就有必要再和大家說一下Reactor多路複用高性能I/O設計模式,Reactor本質 上就是基於NIO多路複用機制提出的一個高性能IO設計模式。
它的核心思想是把響應IO事件和業務處理 進行分離,通過一個或者多個線程來處理IO事件,然後將就緒得到事件分發到業務處理handlers線程去步非阻塞處理,如下圖所示。 Reactor模型有三個重要的組件:
- Reactor :將I/O事件發派給對應的Handler
- Acceptor :處理客戶端連接請求
- Handlers :執行非阻塞讀/寫
代碼實現如下:
Reactor:
public class Reactor implements Runnable {
private final Selector selector;
private final ServerSocketChannel serverSocketChannel;
public Reactor(int port) throws IOException {
selector = Selector.open();
serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(port));
serverSocketChannel.configureBlocking(false);
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT, new Acceptor(selector, serverSocketChannel));
}
@Override
public void run() {
while (!Thread.interrupted()) {
try {
selector.select();
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
dispatch(iterator.next());
iterator.remove();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
private void dispatch(SelectionKey key) {
//可能拿到的對象有兩個
// Acceptor
// Handler
Runnable runnable = (Runnable) key.attachment();
if (runnable != null) {
runnable.run(); //
}
}
}
Acceptor:
public class Acceptor implements Runnable {
private final Selector selector;
private final ServerSocketChannel serverSocketChannel;
public Acceptor(Selector selector, ServerSocketChannel serverSocketChannel) {
this.selector = selector;
this.serverSocketChannel = serverSocketChannel;
}
@Override
public void run() {
SocketChannel channel;
try {
channel = serverSocketChannel.accept();//得到一個客戶端連接
System.out.println(channel.getRemoteAddress() + ":收到一個客戶端連接");
channel.configureBlocking(false);
channel.register(selector, SelectionKey.OP_READ, new Handler(channel));
} catch (IOException e) {
e.printStackTrace();
}
}
}
Handler :
public class Handler implements Runnable {
SocketChannel channel;
public Handler(SocketChannel channe) {
this.channel = channe;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "------");
ByteBuffer buffer = ByteBuffer.allocate(1024);
int len = 0, total = 0;
String msg = "";
try {
do {
len = channel.read(buffer);
if (len > 0) {
total += len;
msg += new String(buffer.array());
}
} while (len > buffer.capacity());
System.out.println("total:" + total);
//msg=表示通信傳輸報文
//耗時2s
//登錄: username:password
//ServetRequets: 請求信息
//數據庫的判斷
//返回數據,通過channel寫回到客戶端
System.out.println(channel.getRemoteAddress() + ": Server receive Msg:" + msg);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (channel != null) {
try {
channel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
以上代碼碼是最基本的單Reactor單線程模型(整體的I/O操作是由同一個線程完成的)。
其中Reactor線程,負責多路分離套接字,有新連接到來觸發connect 事件之後,交由Acceptor進行處 理,有IO讀寫事件之後交給hanlder 處理。
Acceptor主要任務就是構建handler ,在獲取到和client相關的SocketChannel之後 ,綁定到相應的 hanlder上,對應的SocketChannel有讀寫事件之後,基於racotor 分發,hanlder就可以處理了(所有的 IO事件都綁定到selector上,有Reactor分發)。
Reactor 模式本質上指的是使用 I/O 多路複用(I/O multiplexing) + 非阻塞 I/O(nonblocking I/O) 的模式。
多線程單Reactor模型:
單線程Reactor這種實現方式有存在着缺點,從實例代碼中可以看出,handler的執行是串行的,如果其 中一個handler處理線程阻塞將導致其他的業務處理阻塞。由於handler和reactor在同一個線程中的執 行,這也將導致新的無法接收新的請求,我們做一個小實驗:
- 在上述Reactor代碼的DispatchHandler的run方法中,增加一個Thread.sleep()。
- 打開多個客戶端窗口連接(可以通過telnet連接)到Reactor Server端,其中一個窗口發送一個信息後被阻塞,另外一個窗 口再發信息時由於前面的請求阻塞導致後續請求無法被處理。
Redis6.0中多線程帶來的性能提升。Redis中的特殊的多線程單Reactor模型。下圖是美團技術團隊使用阿里雲服務器壓測GET/SET命令在4個線程IO時性能上的對比結果,可以明顯 的看到,Redis 在使用多線程模式之後性能大幅提升,達到了一倍。
Redis Server 阿里雲 Ubuntu 18.04 , 8CPU 2.5GHZ,8G內存,主機型號: ecs.ic5.2xlarge Redis Benchmark client: 阿里雲 Unbuntu 18.04 , 8CPU 2.5GHZ,8G內存,主機型號: ecs.ic5.2xlarge
爲了解決這種問題,有人提出使用多線程的方式來處理業務,也就是在業務處理的地方加入線程池異步 處理,將reactor和handler在不同的線程來執行,如下圖所示。
改造代碼代碼如下:
public class MutilDispatchHandler implements Runnable {
SocketChannel channel;
private Executor executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
public MutilDispatchHandler(SocketChannel channel) {
this.channel = channel;
}
@Override
public void run() {
processor();
}
private void processor() {
executor.execute(new ReaderHandler(channel));
}
static class ReaderHandler implements Runnable {
private SocketChannel channel;
public ReaderHandler(SocketChannel channel) {
this.channel = channel;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ":-----");
ByteBuffer buffer = ByteBuffer.allocate(1024);
try {
Thread.sleep(100000);
} catch (InterruptedException e) {
e.printStackTrace();
}
int len = 0, total = 0;
String msg = "";
try {
do {
len = channel.read(buffer);
if (len > 0) {
total += len;
msg += new String(buffer.array());
}
} while (len > buffer.capacity());
System.out.println("total:" + total);
//msg=表示通信傳輸報文
//耗時2s
//登錄: username:password
//ServetRequets: 請求信息
//數據庫的判斷
//返回數據,通過channel寫回到客戶端
System.out.println(channel.getRemoteAddress() + ": Server receive Msg:" + msg);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (channel != null) {
try {
channel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
}
在多線程Reactor模型中,添加了一個工作者線程池,並將非I/O操作從Reactor線程中移出轉交給工作 者線程池來執行。這樣能夠提高Reactor線程的I/O響應,不至於因爲一些耗時的業務邏輯而延遲對後面 I/O請求的處理。
主從多線程多Reactor模式:
mainReactor負責監聽連接,accept連接給subReactor處理,爲什麼要單獨分一個Reactor來處理監聽呢?因爲像TCP這樣需要經過3次握手才能建立連接,這個建立連接的過程也是要耗時間和資源的,單獨分一個Reactor來處理,可以提高性能。
代碼實現如下:
Acceptor
public class Acceptor implements Runnable{
final Selector sel;
final ServerSocketChannel serverSocketChannel;
//獲取當前核心數
private final int POOL_SIZE=Runtime.getRuntime().availableProcessors();
//線程池
private Executor subReactorExecutor= Executors.newFixedThreadPool(POOL_SIZE);
//subReactors 個數
private Reactor[] subReactors=new Reactor[POOL_SIZE];
//任務分發輪詢分發計數
int handerNext=0;
public Acceptor(Selector sel,int port) throws IOException {
this.sel=sel;//打開鏈接
this.serverSocketChannel=ServerSocketChannel.open();
this.serverSocketChannel.socket().bind(new InetSocketAddress(port));
this.serverSocketChannel.configureBlocking(false);
this.serverSocketChannel.register(this.sel, SelectionKey.OP_ACCEPT,this);
init();
System.out.println("Main Reactor Acceptor: Listening on port:"+port);
}
private void init() throws IOException {
//初始化subReactors
for (int i = 0; i < subReactors.length; i++) {
subReactors[i]=new Reactor();
subReactorExecutor.execute(subReactors[i]);
}
}
@Override
public void run() {
//負責處理連接事件和IO事件
try {
SocketChannel socketChannel=serverSocketChannel.accept(); //獲取連接
if(socketChannel!=null){
socketChannel.write(ByteBuffer.wrap("Multiply Reactor Patterm\r\nreactor> ".getBytes()));
System.out.println(Thread.currentThread().getName()+": Main-Reactor-Acceptor:"+socketChannel.getLocalAddress()+"連接");
//輪詢分發
Reactor subReactor=subReactors[handerNext];
//異步處理
subReactor.register(new AsyncHandler(socketChannel));
if(++handerNext==subReactors.length){
handerNext=0;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
Reactor:
public class Reactor implements Runnable{
private final Selector selector;
//任務隊列
private ConcurrentLinkedQueue<AsyncHandler> events=new ConcurrentLinkedQueue<>();
public Reactor() throws IOException {
//打開selector
this.selector = Selector.open();
}
public Selector getSelector() {
return selector;
}
@Override
public void run() {
while(!Thread.interrupted()){
AsyncHandler handler;
try {
//阻塞。獲取任務隊列,爲空的時候會阻塞
while((handler=events.poll())!=null){
handler.getChannel().configureBlocking(false);
SelectionKey selectionKey=handler.getChannel().register(selector,SelectionKey.OP_READ);
selectionKey.attach(handler);
handler.setSk(selectionKey);
}
//獲取到了任務隊列,此刻進入輪詢監聽IO事件
selector.select();
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while(iterator.hasNext()){
SelectionKey key=iterator.next();
Runnable runnable=(Runnable) key.attachment(); //得到AsyncHandler實例
if(runnable!=null){
//調用AsyncHandler 處理任務
runnable.run();
}
iterator.remove();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
public void register(AsyncHandler handler){
events.offer(handler); //有一個事件註冊,添加任務隊列
selector.wakeup();//喚醒阻塞selector
}
}
AsyncHandler:
public class AsyncHandler implements Runnable{
private SocketChannel channel;
private SelectionKey sk;
StringBuilder stringBuilder=new StringBuilder();
ByteBuffer inputBuffer=ByteBuffer.allocate(1024);
ByteBuffer outputBuffer=ByteBuffer.allocate(1024);
public AsyncHandler(SocketChannel channel) {
this.channel = channel;
}
public SocketChannel getChannel() {
return channel;
}
public SelectionKey getSk() {
return sk;
}
public void setSk(SelectionKey sk) {
this.sk = sk;
}
@Override
public void run() {
try {
if (sk.isReadable()) {
read();
} else if (sk.isWritable()) {
write();
}
}catch (Exception e){
}
}
private void read() throws IOException {
inputBuffer.clear();
int n=channel.read(inputBuffer);
if(inputBufferComplete(n)){
System.out.println(Thread.currentThread().getName()+": Server端收到客戶端的請求消息:"+stringBuilder.toString());
outputBuffer.put(stringBuilder.toString().getBytes(StandardCharsets.UTF_8));
this.sk.interestOps(SelectionKey.OP_WRITE);
}
}
private boolean inputBufferComplete(int bytes) throws EOFException {
if(bytes>0){
inputBuffer.flip();
while(inputBuffer.hasRemaining()){
byte ch=inputBuffer.get(); //得到輸入的字符
if(ch==3) { //表示Ctrl+c
throw new EOFException();
}else if(ch=='\r'||ch=='\n'){
return true;
}else {
stringBuilder.append((char)ch);
}
}
}else if(bytes==1){
throw new EOFException();
}
return false;
}
private void write() throws IOException {
int write=-1;
outputBuffer.flip();
if(outputBuffer.hasRemaining()){
write=channel.write(outputBuffer); //把收到的數據寫回到客戶端
}
outputBuffer.clear();
stringBuilder.delete(0,stringBuilder.length());
if(write<=0){
this.sk.channel().close();
}else{
channel.write(ByteBuffer.wrap("\r\nreactor> ".getBytes()));
this.sk.interestOps(SelectionKey.OP_READ);//又轉化爲讀事件
}
}
}
MultiplyReactor:
public class MultiplyReactor {
private int port;
private Reactor mainReactor; //main Reactor
Executor mainReactorExecutor= Executors.newFixedThreadPool(10);
public MultiplyReactor(int port) throws IOException {
this.port = port;
mainReactor=new Reactor();
}
public void start() throws IOException {
new Acceptor(mainReactor.getSelector(),port);
mainReactorExecutor.execute(mainReactor);
}
public static void main(String[] args) throws IOException {
new MultiplyReactor(8080).start();
}
}
父線程與子線程的數據交互簡單職責明確,父線程只需要接收新連接,子線程完成後續的業務處理。父線程與子線程的數據交互簡單,Reactor 主線程只需要把新連接傳給子線程,子線程無需返回數據。
這種模型在許多項目中廣泛使用,包括 Nginx 主從 Reactor 多進程模型,Memcached 主從多線程,Netty 主從多線程模型的支持。