一、背景
要提升服務器的並發處理能力,通常有兩大方向的思路。
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狀態的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.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更高的性能。