最近工作中,接觸到了Java網絡編程方面的東西:Socket、NIO、MongoDB等,也看了tomcat的源碼,也加強了線程方面的知識,也使用了MINA這樣的框架。感覺獲益良多,原本技術上的薄弱環節也在慢慢提高,很多想寫的東西,也在慢慢規划整理。無奈最近在籌備婚禮的事情,顯得有些耽擱。
想了很久,決定先寫寫IO中經常被提到的概念——“同步與異步、阻塞與非阻塞”以及在Java網絡編程中的簡單運用。
想達到的目的有兩個:
1。深入的理解同步與異步、阻塞與非阻塞,這看似爛大街的詞匯很多人已經習慣不停的說,但卻說不出其中的所以然,包括我。
2。理解各種IO模型在Java網絡IO中的運用,能夠根據不同的應用場景選擇合適的交互方式。了解不同的交互方式對IO性能的影響。
前提
首先先強調上下文:下面提到了同步與異步、阻塞與非阻塞的概念都是在IO的場合下。它們在其它場合下有着不同的含義,比如操作系統中,通信技術上。
然后借鑒下《Unix網絡編程卷》中的理論:
IO操作中涉及的2個主要對象為程序進程、系統內核。以讀操作為例,當一個IO讀操作發生時,通常經歷兩個步驟:
1,等待數據准備
2,將數據從系統內核拷貝到操作進程中
例如,在socket上的讀操作,步驟1會等到網絡數據包到達,到達后會拷貝到系統內核的緩沖區;步驟2會將數據包從內核緩沖區拷貝到程序進程的緩沖區中。
阻塞(blocking)與非阻塞(non-blocking)IO
IO的阻塞、非阻塞主要表現在一個IO操作過程中,如果有些操作很慢,比如讀操作時需要准備數據,那么當前IO進程是否等待操作完成,還是得知暫時不能操作后先去做別的事情?一直等待下去,什么事也不做直到完成,這就是阻塞。抽空做些別的事情,這是非阻塞。
非阻塞IO會在發出IO請求后立即得到回應,即使數據包沒有准備好,也會返回一個錯誤標識,使得操作進程不會阻塞在那里。操作進程會通過多次請求的方式直到數據准備好,返回成功的標識。
想象一下下面兩種場景:
A 小明和小剛兩個人都很耿直內向,一天小明來找小剛借書:“小剛啊,你那本XXX借我看看”。 於是小剛就去找書,小明就等着,找了半天找到了,把書給了小明。
B 小明和小剛兩個人都很活潑外向,一天小明來找小剛借書:“嘿小剛,你那本XXX借我看看”。 小剛說:“我得找一會”,小明就去打球去了。過會又來,這次書找到了,把書給了小明。
結論:A是阻塞的,B是非阻塞的。
從CPU角度可以看出非阻塞明顯提高了CPU的利用率,進程不會一直在那等待。但是同樣也帶來了線程切換的增加。增加的 CPU 使用時間能不能補償系統的切換成本需要好好評估。
同步(synchronous)與異步(asynchronous)IO
先來看看正式點的定義,POSIX標准將IO模型分為了兩種:同步IO和異步IO,Richard Stevens在《Unix網絡編程卷》中也總結道:
A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
An asynchronous I/O operation does not cause the requesting process to be blocked;
可以看出,判斷同步和異步的標准在於:一個IO操作直到完成,是否導致程序進程的阻塞。如果阻塞就是同步的,沒有阻塞就是異步的。這里的IO操作指的是真實的IO操作,也就是數據從內核拷貝到系統進程(讀)的過程。
繼續前面借書的例子,異步借書是這樣的:
C 小明很懶,一天小明來找小剛借書:“嘿小剛,你那本XXX借我看看”。 小剛說:“我得找一會”,小明就出去打球了並且讓小剛如果找到了就把書拿給他。小剛是個負責任的人,找到了書送到了小明手上。
A和B的借書方式都是同步的,有人要問了B不是非阻塞嘛,怎么還是同步?
前面說了IO操作的2個步驟:准備數據和把數據從內核中拷貝到程序進程。映射到這個例子,書即是准備的數據,小剛是內核,小明是程序進程,小剛把書給小明這是拷貝數據。在B方式中,小剛找書這段時間小明的確是沒閑着,該干嘛干嘛,但是小剛找到書把書給小明的這個過程也就是拷貝數據這個步驟,小明還是得乖乖的回來候着小剛把書遞手上。所以這里就阻塞了,根據上面的定義,所以是同步。
在涉及到 IO 處理時通常都會遇到一個是同步還是異步的處理方式的選擇問題。同步能夠保證程序的可靠性,而異步可以提升程序的性能。小明自己去取書不管等着不等着遲早拿到書,指望小剛找到了送來,萬一小剛忘了或者有急事忙別的了,那書就沒了。
討論
說實話,網上關於同步與異步、阻塞與非阻塞的文章多之又多,大部分是拷貝的,也有些寫的非常好的。參考了許多,也借鑒了許多,也經過自己的思考。
同步與異步、阻塞與非阻塞之間確實有很多相似的地方,很容易混淆。wiki更是把異步與非阻塞畫上了等號,更多的人還是認為他們是不同的。原因可能有很多,每個人的知識背景不同,設定的上下文也不同。
我的看法是:在IO中,根據上面同步異步的概念,也可以看出來同步與異步往往是通過阻塞非阻塞的形式來表達的,並且是通過一種中間處理機制來達到異步的效果。同步與異步往往是IO操作請求者和回應者之間在IO實際操作階段的協作方式,而阻塞非阻塞更確切的說是一種自身狀態,當前進程或者線程的狀態。
在發出IO讀請求后,阻塞IO會一直等待有數據可讀,當有數據可讀時,會等待數據從內核拷貝至系統進程;而非阻塞IO都會立即返回。至於數據怎么處理是程序進程自己的事情,無關同步和異步。
兩種方式的組合
組合的方式當然有四種,分別是:同步阻塞、同步非阻塞、異步阻塞、異步非阻塞。
Java網絡IO實現和IO模型
不同的操作系統上有不同的IO模型,《Unix網絡編程卷》將unix上的IO模型分為5類:blocking I/O、nonblocking I/O、I/O multiplexing (select and poll)、signal driven I/O (SIGIO)以及asynchronous I/O (the POSIX aio_functions)。具體可參考《Unix網絡編程卷1》6.2章節。
在windows上IO模型也是有5種:select 、WSAAsyncSelect、WSAEventSelect、Overlapped I/O 事件通知以及IOCP。具體可參考windows五種IO模型。
Java是平台無關的語言,在不同的平台上會調用底層操作系統的不同的IO實現,下面就來說一下Java提供的網絡IO的工具和實現,為了擴大阻塞非阻塞的直觀感受,我都使用了長連接。
阻塞IO
同步阻塞最常用的一種用法,使用也是最簡單的,但是 I/O 性能一般很差,CPU 大部分在空閑狀態。下面是一個簡單的基於TCP的同步阻塞的Socket服務端例子:
1 @Test 2 public void testJIoSocket() throws Exception 3 { 4 ServerSocket serverSocket = new ServerSocket(10002); 5 Socket socket = null; 6 try 7 { 8 while (true) 9 { 10 socket = serverSocket.accept(); 11 System.out.println("socket連接:" + socket.getRemoteSocketAddress().toString()); 12 BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); 13 while(true) 14 { 15 String readLine = in.readLine(); 16 System.out.println("收到消息" + readLine); 17 if("end".equals(readLine)) 18 { 19 break; 20 } 21 //客戶端斷開連接 22 socket.sendUrgentData(0xFF); 23 } 24 } 25 } 26 catch (SocketException se) 27 { 28 System.out.println("客戶端斷開連接"); 29 } 30 catch (IOException e) 31 { 32 e.printStackTrace(); 33 } 34 finally 35 { 36 System.out.println("socket關閉:" + socket.getRemoteSocketAddress().toString()); 37 socket.close(); 38 } 39 }
使用SocketTest作為客戶端工具進行測試,同時開啟2個客戶端連接Server端並發送消息,如下圖:
再看下后台的打印
socket連接:/127.0.0.1:54080 收到消息hello! 收到消息my name is client1
由於服務器端是單線程的,在第一個連接的客戶端阻塞了線程后,第二個客戶端必須等待第一個斷開后才能連接。當輸入“end”字符串斷開客戶端1,這時候看到后台繼續打印:
socket連接:/127.0.0.1:54080 收到消息hello! 收到消息my name is client1 收到消息end socket關閉:/127.0.0.1:54080 socket連接:/127.0.0.1:54091 收到消息hello! 收到消息my name is client2
所有的客戶端連接在請求服務端時都會阻塞住,等待前面的完成。即使是使用短連接,數據在寫入 OutputStream 或者從 InputStream 讀取時都有可能會阻塞。這在大規模的訪問量或者系統對性能有要求的時候是不能接受的。
阻塞IO + 每個請求創建線程/線程池
通常解決這個問題的方法是使用多線程技術,一個客戶端一個處理線程,出現阻塞時只是一個線程阻塞而不會影響其它線程工作;為了減少系統線程的開銷,采用線程池的辦法來減少線程創建和回收的成本,模式如下圖:
簡單的實現例子如下,使用一個線程(Accptor)接收客戶端請求,為每個客戶端新建線程進行處理(Processor),線程池的我就不弄了:
public class MultithreadJIoSocketTest { @Test public void testMultithreadJIoSocket() throws Exception { ServerSocket serverSocket = new ServerSocket(10002); Thread thread = new Thread(new Accptor(serverSocket)); thread.start(); Scanner scanner = new Scanner(System.in); scanner.next(); } public class Accptor implements Runnable { private ServerSocket serverSocket; public Accptor(ServerSocket serverSocket) { this.serverSocket = serverSocket; } public void run() { while (true) { Socket socket = null; try { socket = serverSocket.accept(); if(socket != null) { System.out.println("收到了socket:" + socket.getRemoteSocketAddress().toString()); Thread thread = new Thread(new Processor(socket)); thread.start(); } } catch (IOException e) { e.printStackTrace(); } } } } public class Processor implements Runnable { private Socket socket; public Processor(Socket socket) { this.socket = socket; } @Override public void run() { try { BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); String readLine; while(true) { readLine = in.readLine(); System.out.println("收到消息" + readLine); if("end".equals(readLine)) { break; } //客戶端斷開連接 socket.sendUrgentData(0xFF); Thread.sleep(5000); } } catch (InterruptedException e) { e.printStackTrace(); } catch (SocketException se) { System.out.println("客戶端斷開連接"); } catch (IOException e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException e) { e.printStackTrace(); } } } } }
使用2個客戶端連接,這次沒有阻塞,成功的收到了2個客戶端的消息。
收到了socket:/127.0.0.1:55707 收到了socket:/127.0.0.1:55708 收到消息hello! 收到消息hello!
在單個線程處理中,我人為的使單個線程read后阻塞5秒,就像前面說的,出現阻塞也只是在單個線程中,沒有影響到另一個客戶端的處理。
這種阻塞IO的解決方案在大部分情況下是適用的,在出現NIO之前是最通常的解決方案,Tomcat里阻塞IO的實現就是這種方式。但是如果是大量的長連接請求呢?不可能創建幾百萬個線程保持連接。再退一步,就算線程數不是問題,如果這些線程都需要訪問服務端的某些競爭資源,勢必需要進行同步操作,這本身就是得不償失的。
非阻塞IO + IO multiplexing
Java從1.4開始提供了NIO工具包,這是一種不同於傳統流IO的新的IO方式,使得Java開始對非阻塞IO支持;NIO並不等同於非阻塞IO,只要設置Blocking屬性就可以控制阻塞非阻塞。至於NIO的工作方式特點原理這里一概不說,以后會寫。模式如下圖:
下面是簡單的實現:
public class NioNonBlockingSelectorTest { Selector selector; private ByteBuffer receivebuffer = ByteBuffer.allocate(1024); @Test public void testNioNonBlockingSelector() throws Exception { selector = Selector.open(); SocketAddress address = new InetSocketAddress(10002); ServerSocketChannel channel = ServerSocketChannel.open(); channel.socket().bind(address); channel.configureBlocking(false); channel.register(selector, SelectionKey.OP_ACCEPT); while(true) { selector.select(); Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey selectionKey = iterator.next(); iterator.remove(); handleKey(selectionKey); } } } private void handleKey(SelectionKey selectionKey) throws IOException { ServerSocketChannel server = null; SocketChannel client = null; if(selectionKey.isAcceptable()) { server = (ServerSocketChannel)selectionKey.channel(); client = server.accept(); System.out.println("客戶端: " + client.socket().getRemoteSocketAddress().toString()); client.configureBlocking(false); client.register(selector, SelectionKey.OP_READ); } if(selectionKey.isReadable()) { client = (SocketChannel)selectionKey.channel(); receivebuffer.clear(); int count = client.read(receivebuffer); if (count > 0) { String receiveText = new String( receivebuffer.array(),0,count); System.out.println("服務器端接受客戶端數據--:" + receiveText); client.register(selector, SelectionKey.OP_READ); } } } }
Java NIO提供的非阻塞IO並不是單純的非阻塞IO模式,而是建立在Reactor模式上的IO復用模型;在IO multiplexing Model中,對於每一個socket,一般都設置成為non-blocking,但是整個用戶進程其實是一直被阻塞的。只不過進程是被select這個函數阻塞,而不是被socket IO給阻塞,所以還是屬於非阻塞的IO。
這篇文章中把這種模式歸為了異步阻塞,我其實是認為這是同步非阻塞的,可能看的角度不一樣。
異步IO
Java1.7中提供了異步IO的支持,暫時還沒有看過,所以以后再討論。
網絡IO優化
對於網絡IO有一些基本的處理規則如下:
1。減少交互的次數。比如增加緩存,合並請求。
2。減少傳輸數據大小。比如壓縮后傳輸、約定合理的數據協議。
3。減少編碼。比如提前將字符轉化為字節再傳輸。
4。根據應用場景選擇合適的交互方式,同步阻塞,同步非阻塞,異步阻塞,異步非阻塞。
就說到這里吧,感覺有點亂,有些地方還是找不到更貼切的語言來描述。