Java進階知識點:服務端高並發的基石 - NIO與Reactor AIO與Proactor

來源:互聯網
上載者:User

標籤:記憶體   部署   網路io   叢集   上下文   selector   好的   rri   建立   

一、背景

要提升伺服器的並發處理能力,通常有兩大方向的思路。

1、系統架構層面。比如負載平衡、多級緩衝、單元化部署等等。

2、單節點最佳化層面。比如修複代碼層級的效能Bug、JVM參數調優、IO最佳化等等。

一般來說,系統架構的合理程度,決定了系統在整體效能上的伸縮性(高伸縮性,簡而言之就是可以很任性,效能不行就加機器,加到效能足夠為止);而單節點在效能上的最佳化程度,決定了單個請求的時延,以及要達到期望的效能,所需叢集規模的大小。兩者雙管齊下,才能快速構建出效能良好的系統。

今天,我們就聊聊在單節點最佳化層面最重要的IO最佳化。之所以IO最佳化最重要,是因為IO速度遠低於CPU和記憶體,而不夠良好的軟體設計,常常導致CPU和記憶體被IO所拖累,如何擺脫IO的束縛,充分發揮CPU和記憶體的潛力,是效能最佳化的核心內容。

而CPU和記憶體又是如何被IO所拖累的呢?這就從Java中幾種典型的IO操作模式說起。

二、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狀態的ChannelsocketChannel.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.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資料的業務處理。

四、總結

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

2、BIO需要保證一個串連一個線程,由於線程是作業系統寶貴資源,不可開過多,所以BIO嚴重限制了服務端可承載的並發串連數量。

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

4、使用AIO特性,輔以Proactor編程模式,在其他平台上(比如Windows)能夠獲得比NIO更高的效能。

Java進階知識點:服務端高並發的基石 - NIO與Reactor AIO與Proactor

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.