Java NIO4:Socket通道

五月的倉頡發表於2016-02-04

Socket通道

上文講述了通道、檔案通道,這篇文章來講述一下Socket通道,Socket通道與檔案通道有著不一樣的特徵,分三點說:

1、NIO的Socket通道類可以執行於非阻塞模式並且是可選擇的,這兩個效能可以啟用大程式(如網路伺服器和中介軟體元件)巨大的可伸縮性和靈活性,因此,再也沒有為每個Socket連線使用一個執行緒的必要了。這一特性避免了管理大量執行緒所需的上下文交換總開銷,藉助NIO類,一個或幾個執行緒就可以管理成百上千的活動Socket連線了並且只有很少甚至沒有效能損失

2、全部Socket通道類(DatagramChannel、SocketChannel和ServerSocketChannel)在被例項化時都會建立一個對應的Socket物件,就是我們所熟悉的來自java.net的類(Socket、ServerSocket和DatagramSocket),這些Socket可以通過呼叫socket()方法從通道類獲取,此外,這三個java.net類現在都有getChannel()方法

3、每個Socket通道(在java.nio.channels包中)都有一個關聯的java.net.socket物件,反之卻不是如此,如果使用傳統方式(直接例項化)建立了一個Socket物件,它就不會有關聯的SocketChannel並且它的getChannel()方法將總是返回null

概括地講,這就是Socket通道所要掌握的知識點知識點,不難,記住並通過自己寫程式碼/檢視JDK原始碼來加深理解。

 

非阻塞模式

前面第一點說了,NIO的Socket通道可以執行於非阻塞模式,這個陳述雖然簡單卻有著深遠的含義。傳統Java Socket的阻塞性質曾經是Java程式可伸縮性的最重要制約之一,非阻塞I/O是許多複雜的、高效能的程式構建的基礎。

要把一個Socket通道置於非阻塞模式,要依賴的是Socket通道類的弗雷SelectableChannel,下面看一下這個類的簡單定義:

public abstract class SelectableChannel extends AbstractInterruptibleChannel implements Channel
{
    ...
    public abstract void configureBlocking(boolean block) throws IOException;
    public abstract boolean isBlocking();
    public abstract Object blockngLock();
    ...
}

因為這篇文章是講述Socket通道的,因此省略了和選擇器相關的方法,這些省略的內容將在下一篇文章中說明。

從SelectableChannel的API中可以看出,設定或重新設定一個通道的阻塞模式是很簡單的,只要呼叫configureBlocking()方法即可,傳遞引數值為true則設為阻塞模式,引數值為false則設為非阻塞模式,就這麼簡單。同時,我們可以通過呼叫isBlocking()方法來判斷某個Socket通道當前處於哪種模式中。

偶爾,我們也會需要放置Socket通道的阻塞模式被更改,所以API中有一個blockingLock()方法,該方法會返回一個非透明物件引用,返回的物件是通道實現修改阻塞模式時內部使用的,只有擁有此物件的鎖的執行緒才能更改通道的阻塞模式,對於確保在執行程式碼的關鍵部分時Socket通道的阻塞模式不會改變以及在不影響其他執行緒的前提下暫時改變阻塞模式來說,這個方法是非常方便的。

 

Socket通道服務端程式

OK,接下來先看下Socket通道服務端程式應該如何編寫:

 1 public class NonBlockingSocketServer
 2 {
 3     public static void main(String[] args) throws Exception
 4     {
 5         int port = 1234;
 6         if (args != null && args.length > 0)
 7         {
 8             port = Integer.parseInt(args[0]);
 9         }
10         ServerSocketChannel ssc = ServerSocketChannel.open();
11         ssc.configureBlocking(false);
12         ServerSocket ss = ssc.socket();
13         ss.bind(new InetSocketAddress(port));
14         System.out.println("開始等待客戶端的資料!時間為" + System.currentTimeMillis());
15         while (true)
16         {
17             SocketChannel sc = ssc.accept();
18             if (sc == null)
19             {
20                 // 如果當前沒有資料,等待1秒鐘再次輪詢是否有資料,在學習了Selector之後此處可以使用Selector
21                 Thread.sleep(1000);
22             }
23             else
24             {
25                 System.out.println("客戶端已有資料到來,客戶端ip為:" + sc.socket().getRemoteSocketAddress() 
26                         + ", 時間為" + System.currentTimeMillis()) ;
27                 ByteBuffer bb = ByteBuffer.allocate(100);
28                 sc.read(bb);
29                 bb.flip();
30                 while (bb.hasRemaining())
31                 {
32                     System.out.print((char)bb.get());
33                 }
34                 sc.close();
35                 System.exit(0);
36             }
37         }
38     }
39 }

整個程式碼流程大致上就是這樣,沒什麼特別值得講的,注意一下第18行~第22行,由於這裡還沒有講到Selector,因此當客戶端Socket沒有到來的時候選擇的處理辦法是每隔1秒鐘輪詢一次。

 

Socket通道客戶端程式

伺服器端經常會使用非阻塞Socket通達,因為它們使同時管理很多Socket通道變得更容易,客戶端卻並不強求,因為客戶端發起的Socket操作往往比較少,且都是一個接著一個發起的。但是,在客戶端使用一個或幾個非阻塞模式的Socket通道也是有益處的,例如藉助非阻塞Socket通道,GUI程式可以專注於使用者請求並且同時維護與一個或多個伺服器的會話。在很多程式上,非阻塞模式都是有用的,所以,我們看一下客戶端應該如何使用Socket通道:

 1 public class NonBlockingSocketClient
 2 {
 3     private static final String STR = "Hello World!";
 4     private static final String REMOTE_IP= "127.0.0.1";
 5     
 6     public static void main(String[] args) throws Exception
 7     {
 8         int port = 1234;
 9         if (args != null && args.length > 0)
10         {
11             port = Integer.parseInt(args[0]);
12         }
13         SocketChannel sc = SocketChannel.open();
14         sc.configureBlocking(false);
15         sc.connect(new InetSocketAddress(REMOTE_IP, port));
16         while (!sc.finishConnect())
17         {
18             System.out.println("同" + REMOTE_IP+ "的連線正在建立,請稍等!");
19             Thread.sleep(10);
20         }
21         System.out.println("連線已建立,待寫入內容至指定ip+埠!時間為" + System.currentTimeMillis());
22         ByteBuffer bb = ByteBuffer.allocate(STR.length());
23         bb.put(STR.getBytes());
24         bb.flip(); // 寫緩衝區的資料之前一定要先反轉(flip)
25         sc.write(bb);
26         bb.clear();
27         sc.close();
28     }
29 }

總得來說和普通的Socket操作差不多,通過通道讀寫資料,非常方便。不過再次提醒,通道只能操作位元組緩衝區也就是ByteBuffer的資料

 

執行結果展示

上面的程式碼,為了展示結果的需要,在關鍵點上都加上了時間列印,這樣會更清楚地看到執行結果。

首先執行服務端程式(注意不可以先執行客戶端程式,如果先執行客戶端程式,客戶端程式會因為服務端未開啟監聽而丟擲ConnectionException),看一下:

看到紅色方塊,此時程式是執行的,接著執行客戶端程式:

看到客戶端已經將"Hello World!"寫入了Socket並通過通道傳到了伺服器端,方框變灰,說明程式執行結束了。此時看一下伺服器端有什麼變化:

看到伺服器端列印出了字串"Hello World!",並且方框變灰,程式執行結束,這和程式碼是一致的。

注意一點,客戶端看到的時間是XXX10307,伺服器端看到的時間是XXX10544,這是很正常的,因為前面說過了,伺服器端程式是每隔一秒鐘輪詢一次是否有Socket到來的。

當然,由於服務端程式的作用是監聽1234埠,因此完全可以寫客戶端的程式碼,可以直接訪問http://127.0.0.1:1234/a/b/c/d/?e=5&f=6&g=7就可以了,看一下效果:

有了這個基礎,我們就可以自己解析HTTP請求,甚至可以自己寫一個Web伺服器。

 

客戶端Socket通道複用性的研究

這個是我今天上班的時候想到的一個問題,補充到最後。

伺服器端程式不變,客戶端現在是單個執行緒傳送了一次資料到服務端的,假如現在我的客戶端有多條執行緒同時通過Socket通道傳送資料到服務端又會是怎麼樣的現象?首先將服務端端的程式碼稍作改變,讓服務端SocketChannel在拿到客戶端的資料之後程式不會停止執行而是會持續監聽來自客戶端的Socket,由於伺服器端的程式碼比較多,這裡只列一下改動的地方,:

...
bb.flip();
while (bb.hasRemaining())
{
    System.out.print((char)bb.get());
}
System.out.println();
//sc.close();
//System.exit(0);
...

接著看一下對客戶端程式碼的啟動,把寫資料的操作放到執行緒的run方法中去:

 1 public class NonBlockingSocketClient
 2 {
 3     private static final String STR = "Hello World!";
 4     private static final String REMOTE_IP = "127.0.0.1";
 5     private static final int THREAD_COUNT = 5;
 6     
 7     private static class NonBlockingSocketThread extends Thread
 8     {
 9         private SocketChannel sc;
10         
11         public NonBlockingSocketThread(SocketChannel sc)
12         {
13             this.sc = sc;
14         }
15         
16         public void run()
17         {
18             try
19             {
20                 System.out.println("連線已建立,待寫入內容至指定ip+埠!時間為" + System.currentTimeMillis());
21                 String writeStr = STR + this.getName();
22                 ByteBuffer bb = ByteBuffer.allocate(writeStr.length());
23                 bb.put(writeStr.getBytes());
24                 bb.flip(); // 寫緩衝區的資料之前一定要先反轉(flip)
25                 sc.write(bb);
26                 bb.clear();
27             } 
28             catch (IOException e)
29             {
30                 e.printStackTrace();
31             }
32         }
33     }
34     
35     public static void main(String[] args) throws Exception
36     {
37         int port = 1234;
38         if (args != null && args.length > 0)
39         {
40             port = Integer.parseInt(args[0]);
41         }
42         SocketChannel sc = SocketChannel.open();
43         sc.configureBlocking(false);
44         sc.connect(new InetSocketAddress(REMOTE_IP, port));
45         while (!sc.finishConnect())
46         {
47             System.out.println("同" + REMOTE_IP + "的連線正在建立,請稍等!");
48             Thread.sleep(10);
49         }
50         
51         NonBlockingSocketThread[] nbsts = new NonBlockingSocketThread[THREAD_COUNT];
52         for (int i = 0; i < THREAD_COUNT; i++)
53             nbsts[i] = new NonBlockingSocketThread(sc);
54         for (int i = 0; i < THREAD_COUNT; i++)
55             nbsts[i].start();
56         // 一定要join保證執行緒程式碼先於sc.close()執行,否則會有AsynchronousCloseException
57         for (int i = 0; i < THREAD_COUNT; i++)
58             nbsts[i].join();
59         
60         sc.close();
61     }
62 }

啟動了5個執行緒,我們可能期待服務端能有5次的資料到來,實際上是:

原因就是客戶端的五個執行緒共用了同一個SocketChannel,這樣相當於五個執行緒把資料輪番寫到緩衝區,寫完之後再把資料通過通道傳輸到伺服器端。ByteBuffer的write方法放心,是加鎖的,反編譯一下sun.nio.ch.SocketChannelImpl就知道了,因此不會出現"Hello World!Thread-X"這些字元交叉的情況。

所以有了這個經驗,我們讓每個執行緒都new一個自己的SocketChannel,於是客戶端程式變成了:

 1 public class NonBlockingSocketClient
 2 {
 3     private static final String STR = "Hello World!";
 4     private static final String REMOTE_IP = "127.0.0.1";
 5     private static final int THREAD_COUNT = 5;
 6     
 7     private static class NonBlockingSocketThread extends Thread
 8     {
 9         public void run()
10         {
11             try
12             {
13                 int port = 1234;
14                 SocketChannel sc = SocketChannel.open();
15                 sc.configureBlocking(false);
16                 sc.connect(new InetSocketAddress(REMOTE_IP, port));
17                 while (!sc.finishConnect())
18                 {
19                     System.out.println("同" + REMOTE_IP + "的連線正在建立,請稍等!");
20                     Thread.sleep(10);
21                 }
22                 System.out.println("連線已建立,待寫入內容至指定ip+埠!時間為" + System.currentTimeMillis());
23                 String writeStr = STR + this.getName();
24                 ByteBuffer bb = ByteBuffer.allocate(writeStr.length());
25                 bb.put(writeStr.getBytes());
26                 bb.flip(); // 寫緩衝區的資料之前一定要先反轉(flip)
27                 sc.write(bb);
28                 bb.clear();
29                 sc.close();
30             } 
31             catch (IOException e)
32             {
33                 e.printStackTrace();
34             } 
35             catch (InterruptedException e)
36             {
37                 e.printStackTrace();
38             }
39         }
40     }
41     
42     public static void main(String[] args) throws Exception
43     {
44         NonBlockingSocketThread[] nbsts = new NonBlockingSocketThread[THREAD_COUNT];
45         for (int i = 0; i < THREAD_COUNT; i++)
46             nbsts[i] = new NonBlockingSocketThread();
47         for (int i = 0; i < THREAD_COUNT; i++)
48             nbsts[i].start();
49         // 一定要join保證執行緒程式碼先於sc.close()執行,否則會有AsynchronousCloseException
50         for (int i = 0; i < THREAD_COUNT; i++)
51             nbsts[i].join();
52     }
53 }

此時再執行,觀察結果:

看到沒有問題,伺服器端分五次接收來自客戶端的請求了。

當然,這也是有一定問題的:

1、如果伺服器端開放多執行緒使用ServerSocket通道去處理來自客戶端的資料的話,面對成千上萬的高併發很容易地就會耗盡伺服器端寶貴的執行緒資源

2、如果伺服器端只有一條ServerSocket通道執行緒處理來自客戶端的資料的話,一個客戶端的資料處理得慢將直接影響後面執行緒的資料處理

這麼一說似乎又回到了非阻塞I/O的老問題了。不過,Socket通道講解到此,大體的概念我們已經清楚了,接著就輪到NIO的最後也是最難、最核心的部分----選擇器,將在下一篇文章進行詳細的講解。

相關文章