Java進階知識點5:服務端高併發的基石 - NIO與Reactor模式以及AIO與Proactor模式

1、背景

要提高服務器的併發處理能力,一般有兩大方向的思路。編程

一、系統架構層面。好比負載均衡、多級緩存、單元化部署等等。後端

二、單節點優化層面。好比修復代碼級別的性能Bug、JVM參數調優、IO優化等等。緩存

通常來講,系統架構的合理程度,決定了系統在總體性能上的伸縮性(高伸縮性,簡而言之就是能夠很任性,性能不行就加機器,加到性能足夠爲止);而單節點在性能上的優化程度,決定了單個請求的時延,以及要達到指望的性能,所需集羣規模的大小。二者左右開弓,才能快速構建出性能良好的系統。性能優化

今天,咱們就聊聊在單節點優化層面最重要的IO優化。之因此IO優化最重要,是由於IO速度遠低於CPU和內存,而不夠良好的軟件設計,經常致使CPU和內存被IO所拖累,如何擺脫IO的束縛,充分發揮CPU和內存的潛力,是性能優化的核心內容。服務器

而CPU和內存又是如何被IO所拖累的呢?這就從Java中幾種典型的IO操做模式提及。網絡

2、Java中的典型IO操做模式

2.1 同步阻塞模式

Java中的BIO風格的API,都是該模式,例如:架構

Socket socket = getSocket();
socket.getInputStream().read(); //讀不到數據誓不返回

該模式下,最直觀的感覺就是若是IO設備暫時沒有數據可供讀取,調用API就卡住了,若是數據一直不來就一直卡住。併發

2.2 同步非阻塞模式

Java中的NIO風格的API,都是該模式,例如:負載均衡

SocketChannel socketChannel = getSocketChannel(); //獲取non-blocking狀態的Channel
socketChannel.read(ByteBuffer.allocate(4)); //讀不到數據就算了,當即返回0告訴你沒有讀到

該模式下,一般須要不斷調用API,直至讀取到數據,不過好在函數調用不會卡住,我想繼續嘗試讀取或者先去作點其餘事情再來讀取均可以。框架

2.3 異步非阻塞模式

Java中的AIO風格的API,都是該模式,例如:

AsynchronousSocketChannel asynchronousSocketChannel = getAsynchronousSocketChannel();
asynchronousSocketChannel.read(ByteBuffer.allocate(4), null, new CompletionHandler<Integer, Object>() {
    @Override
    public void completed(Integer result, Object attachment) {
        //讀不到數據不會觸發該回調來煩你,只有確實讀取到數據,且把數據已經存在ByteBuffer中了,API纔會經過此回調接口主動通知您
    }
    @Override
    public void failed(Throwable exc, Object attachment) {
    }
});

該模式服務最到位,除了會讓編程變的相對複雜之外,幾乎無可挑剔。

 2.4 小結

對於IO操做而言,同步和異步的本質區別在於API是否會將IO就緒(好比有數據可讀)的狀態主動通知你。同步意味着想要知道IO是否就緒,必須發起一次詢問,典型的一問一答,若是回答是沒有就緒,那你還得本身不斷詢問,直到答案是就緒爲止。異步意味着,IO就緒後,API將主動通知你,無需你不斷髮起詢問,這一般要求調用API時傳入通知的回調接口。

阻塞和非阻塞的本質區別在於IO操做因IO未就緒不能當即完成時,API是否會將當前線程掛起。阻塞意味着API會一直等待IO就緒後,完成本次IO操做才返回,在此以前調用該API的用戶線程將一直掛起,沒法進行其餘計算處理。非阻塞意味着API會當即返回,而不是等待IO就緒,用戶能夠當即再次得到線程的控制權,可使用該線程進行其餘計算處理。

那有沒有異步阻塞模式呢?若是API支持異步,至關於API說:「你玩去吧,我準備好了通知你」,可是你仍是傻乎乎地不去玩,原地等待API作完後的通知。這一般是由於本次IO操做很重要,拿不到結果業務流程根本沒法繼續,因此爲了編程上的簡單起見,仍是乖乖等吧。可見異步阻塞模式更多的是出於業務流程控制和簡化編碼難度的考慮,由業務代碼自主造成的,Java語言不會特別爲你準備異步阻塞IO的API。

3、分離快與慢

3.1 BIO的侷限

CPU和內存是高速設備,磁盤、網絡等IO設備是低速設備,在Java編程語言中,對CPU和內存的使用被抽象爲對線程、棧、堆的使用,對IO設備的使用被抽象爲IO相關的API調用。

顯然,若是使用BIO風格的IO API,因爲其同步阻塞特性,會致使IO設備未就緒時,線程掛起,該線程沒法繼續使用CPU和內存,直至IO就緒。因爲IO設備的速度遠低於CPU和內存,因此使用BIO風格的API時,有極大的機率會讓當前線程長時間掛起,這就造成了CPU和內存資源被IO所拖累的狀況。

做爲服務端應用,會面臨大量客戶端向服務端發起鏈接請求的場景,每一個鏈接對服務端而言,都意味着須要進行後續的網絡IO讀取,IO讀取完成後,才能得到完整的請求內容,進而才能再進行一些列相關計算處理得到請求結果,最後還要將結果經過網絡IO回寫給客戶端。使用BIO的編碼風格,一般是同一個線程全程負責一個鏈接的IO讀取、數據處理和IO回寫,該線程絕大部分時間均可能在等待IO就緒,只有極少時間在真正利用CPU資源。

而此時服務器要想同時處理大量客戶端鏈接,後端就同時開啓與併發鏈接數量相應的線程。線程是操做系統的寶貴資源,並且每開啓一個操做系統線程,Java還會消耗-Xss指定的線程堆棧大小的堆外內存,若是同時存在大量線程,操做系統調度線程的開銷也會顯著增長,致使服務器性能快速降低。因此此時服務器想要支持上萬乃至幾十萬的高併發鏈接,可謂難上加難。

3.2 NIO的突破

3.2.1 突破思路

因爲NIO的非阻塞特性,決定了IO未就緒時,線程能夠沒必要掛起,繼續處理其餘事情。這就爲分離快與慢提供了可能,高速的CPU和內存能夠沒必要苦等IO交互,一個線程也沒必要侷限於只爲一個IO鏈接服務。這樣,就讓用少許的線程處理海量IO鏈接成爲了可能。

3.2.2 思路落地

雖然咱們看到了曙光,可是要將這個思路落地還需解決掉一些實際的問題。

a)當IO未就緒時,線程就釋放出來,轉而爲其餘鏈接服務,那誰去監控這個被拋棄IO的就緒事件呢?

b)IO就緒了,誰又去負責將這個IO分配給合適的線程繼續處理呢?

爲了解決第一個問題,操做系統提供了IO多路複用器(好比Linux下的select、poll和epoll),Java對這些多路複用器進行了封裝(通常選用性能最好的epoll),也提供了相應的IO多路複用API。NIO的多路複用API典型編程模式以下:

// 開啓一個ServerSocketChannel,在8080端口上監聽
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress("0.0.0.0", 8080));
// 建立一個多路複用器
Selector selector = Selector.open();
// 將ServerSocketChannel註冊到多路複用器上,並聲明關注其ACCEPT就緒事件
server.register(selector, SelectionKey.OP_ACCEPT);
while (selector.select() != 0) {
    // 遍歷全部就緒的Channel關聯的SelectionKey
    Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
    while (iterator.hasNext()) {
        SelectionKey key = iterator.next();
        // 若是這個Channel是READ就緒
        if (key.isReadable()) {
            // 讀取該Channel
            ((SocketChannel) key.channel()).read(ByteBuffer.allocate(10));
        }
        if (key.isWritable()) {
            //... ...
        }
        // 若是這個Channel是ACCEPT就緒
        if (key.isAcceptable()) {
            // 接收新的客戶端鏈接
            SocketChannel accept = ((ServerSocketChannel) key.channel()).accept();
            // 將新的Channel註冊到多路複用器上,並聲明關注其READ/WRITE就緒事件
            accept.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);
        }
        // 刪除已經處理過的SelectionKey
        iterator.remove();
    }
}

IO多路複用API能夠實現用一個線程,去監控全部IO鏈接的IO就緒事件。

第二個問題在上面的代碼中其實也獲得了「解決」,可是上面的代碼是使用監控IO就緒事件的線程來完成IO的具體操做,若是IO操做耗時較大(好比讀操做就緒後,有大量數據須要讀取),那麼會致使監控線程長時間爲某個具體的IO服務,從而致使整個系統長時間沒法感知其餘IO的就緒事件並分派IO處理任務。因此生產環境中,通常使用一個Boss線程專門用於監控IO就緒事件,一個Work線程池負責具體的IO讀寫處理。Boss線程檢測到新的IO就緒事件後,根據事件類型,完成IO操做任務的分配,並將具體的操做交由Work線程處理。這其實就是Reactor模式的核心思想。

3.2.3 Reactor模式

如上所述,Reactor模式的核心理念在於:

a)依賴於非阻塞IO。

b)使用多路複用器監管海量IO的就緒事件。

c)使用Boss線程和Work線程池分離IO事件的監測與IO事件的處理。

Reactor模式中有以下三類角色:

a)Acceptor。用戶處理客戶端鏈接請求。Acceptor角色映射到Java代碼中,即爲SocketServerChannel。

b)Reactor。用於分派IO就緒事件的處理任務。Reactor角色映射到Java代碼中,即爲使用多路複用器的Boss線程。

c)Handler。用於處理具體的IO就緒事件。(好比讀取並處理數據等)。Handler角色映射到Java代碼中,即爲Worker線程池中的每一個線程。

Acceptor的鏈接就緒事件,也是交由Reactor監管的,有些地方爲了分離鏈接的創建和對鏈接的處理,爲將Reactor分離爲一個主Reactor,專門用戶監管鏈接相關事件(即SelectionKey.OP_ACCEPT),一個從Reactor,專門用戶監管鏈接上的數據相關事件(即SelectionKey.OP_READ 和SelectionKey.OP_WRITE)。

關於Reactor的模型圖,網上一搜一大把,我就不獻醜了。相信理解了它的核心思想,圖天然在心中。關於Reactor模式的應用,能夠參見著名NIO編程框架Netty,其實有了Netty以後,通常都直接使用Netty框架進行服務端NIO編程。

3.3 AIO的更進一步

3.3.1 AIO得天獨厚的優點

你很容易發現,若是使用AIO,NIO突破時所面臨的落地問題彷佛自然就不存在了。由於每個IO操做均可以註冊回調函數,自然就不須要專門有一個多路複用器去監聽IO就緒事件,也不須要一個Boss線程去分配事件,全部IO操做只要一完成,就自然會經過回調進入本身的下一步處理。

並且,更讓人驚喜的是,經過AIO,連NIO中Work線程去讀寫數據的操做均可以省略了,由於AIO是保證數據真正讀取/寫入完成後,才觸發回調函數,用戶都沒必要關注IO操做自己,只需關注拿到IO中的數據後,應該進行的業務邏輯。

簡而言之,NIO的多路複用器,是通知你IO就緒事件,AIO的回調是通知你IO完成事件。AIO作的更加完全一些。這樣在某些平臺上也會帶來性能上的提高,由於AIO的IO讀寫操做能夠交由操做系統內核完成,充分發揮內核潛能,減小了IO系統調用時用戶態與內核態間的上下文轉換,效率更高。

(不過遺憾的是,Linux內核的AIO實現有不少問題(不在本文討論範疇),性能在某些場景下還不如NIO,連Linux上的Java都是用epoll來模擬AIO,因此Linux上使用Java的AIO API,只是能體驗到異步IO的編程風格,但並不會比NIO高效。綜上,Linux平臺上的Java服務端編程,目前主流依然採用NIO模型。)

使用AIO API典型編程模式以下:

//建立一個Group,相似於一個線程池,用於處理IO完成事件
AsynchronousChannelGroup group = AsynchronousChannelGroup.withCachedThreadPool(Executors.newCachedThreadPool(), 32);
//開啓一個AsynchronousServerSocketChannel,在8080端口上監聽
AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open(group);
server.bind(new InetSocketAddress("0.0.0.0", 8080));
//接收到新鏈接
server.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
    //新鏈接就緒事件的處理函數
    @Override
    public void completed(AsynchronousSocketChannel result, Object attachment) {
        result.read(ByteBuffer.allocate(4), attachment, new CompletionHandler<Integer, Object>() {
            //讀取完成事件的處理函數
            @Override
            public void completed(Integer result, Object attachment) {
            }

            @Override
            public void failed(Throwable exc, Object attachment) {
            }
        });
    }
    @Override
    public void failed(Throwable exc, Object attachment) {
    }
});

3.3.2 Proactor模式

Java的AIO API其實就是Proactor模式的應用。

也Reactor模式相似,Proactor模式也能夠抽象出三類角色:

a)Acceptor。用戶處理客戶端鏈接請求。Acceptor角色映射到Java代碼中,即爲AsynchronousServerSocketChannel。

b)Proactor。用於分派IO完成事件的處理任務。Proactor角色映射到Java代碼中,即爲API方法中添加回調參數。

c)Handler。用於處理具體的IO完成事件。(好比處理讀取到的數據等)。Handler角色映射到Java代碼中,即爲AsynchronousChannelGroup 中的每一個線程。

可見,Proactor與Reactor最大的區別在於:

a)無需使用多路複用器。

b)Handler無需執行具體的IO操做(好比讀取數據或寫入數據),而是隻執行IO數據的業務處理。

4、總結

一、Java中的IO有同步阻塞、同步非阻塞、異步非阻塞三種操做模式,分別對應BIO、NIO、AIO三類API風格。

二、BIO須要保證一個鏈接一個線程,因爲線程是操做系統寶貴資源,不可開過多,因此BIO嚴重限制了服務端可承載的併發鏈接數量。

三、使用NIO特性,輔以Reactor編程模式,是Java在Linux下實現服務器端高併發能力的主流方式。

四、使用AIO特性,輔以Proactor編程模式,在其餘平臺上(好比Windows)可以得到比NIO更高的性能。

相關文章
相關標籤/搜索