2002 年 3 月 12 日
Java 技術平台早就應該提供非阻塞 I/O 機制了。幸運的是,Merlin(JDK 1.4)有一根幾乎在各個場合都適用的魔杖,而解除阻塞了的 I/O 的阻塞狀態正是這位魔術師的專長。軟體工程師 Aruna Kalagnanam 和 Balu G 介紹了 Merlin 的新 I/O 包 ― java.nio(NIO)― 的這種非阻塞功能,並且用一個通訊端編程樣本向您展示 NIO 能做些什麼。請單擊本文頂部或底部的
討論,在
討論論壇與作者及其他讀者分享您關於本文的心得。
伺服器在合理的時間之內處理大量客戶機請求的能力取決於伺服器使用 I/O 流的效率。同時為成百上千個客戶機提供服務的伺服器必須能夠並發地使用 I/O 服務。Java 平台直到 JDK 1.4(也就是 Merlin)才支援非阻塞 I/O 調用。用 Java 語言寫的伺服器,由於其線程與客戶機之比幾乎是一比一,因而易於受到大量線程開銷的影響,其結果是既導致了效能問題又缺乏延展性。
為瞭解決這個問題,Java 平台的最新發行版引入了一組新的類。Merlin 的 java.nio 包充滿瞭解決線程開銷問題的技巧,包中最重要的是新的
SelectableChannel 類和 Selector 類。
通道(channel)是客戶機和伺服器之間的一種通訊方式。 選取器(selector)與 Windows 訊息迴圈類似,它從不同客戶機捕獲各種事件並將它們指派到相應的事件處理常式。在本文,我們將向您展示這兩個類如何協同工作,從而為 Java 平台建立非阻塞 I/O 機制。
Merlin 之前的 I/O 編程
我們將從考察基礎的、Merlin 之前的伺服器-通訊端(server-socket)程式開始。在 ServerSocket 類的生存期中,其重要功能如下:
我們來考察一下以上每一個步驟,我們用程式碼片段來說明。 首先,我們建立一個新的 ServerSocket :
ServerSocket s = new ServerSocket(); |
接著,我們要接受傳入調用。這裡,調用 accept() 應該可以完成任務,但其中有個小陷阱您得當心:
Socket conn = s.accept( ); |
對 accept() 的調用將一直阻塞,直到伺服器通訊端接受了一個請求串連的客戶機請求。一旦建立了串連,伺服器就使用
LineNumberReader 讀取客戶機請求。因為
LineNumberReader 要到緩衝區滿時才成批地讀取資料,所以這個調用在讀時阻塞。 下面的片段顯示了工作中的
LineNumberReader (阻塞等等)。
InputStream in = conn.getInputStream();InputStreamReader rdr = new InputStreamReader(in);LineNumberReader lnr = new LineNumberReader(rdr);Request req = new Request();while (!req.isComplete() ){ String s = lnr.readLine(); req.addLine(s);} |
InputStream.read() 是另一種讀取資料的方式。不幸的是,
read 方法也要一直阻塞到資料可用為止,
write 方法也一樣,。
圖 1 描繪了伺服器的典型工作過程。黑體線表示處於阻塞的操作。
圖 1. 典型的工作中的伺服器
在 JDK 1.4 之前,自由地使用線程是處理阻塞問題最典型的辦法。但這個解決辦法會產生它自己的問題 ― 即線程開銷,線程開銷同時影響效能和延展性。不過,隨著 Merlin 和 java.nio 包的到來,一切都變了。
在下面的幾個部分中,我們將考察 java.nio 的基本思想,然後把我們所學到的一些知識應用於修改前面描述的伺服器-通訊端樣本。
反應器模式(Reactor pattern)
NIO 設計背後的基石是反應器設計模式。 分布式系統中的伺服器應用程式必須處理多個向它們發送服務要求的客戶機。然而,在調用特定的服務之前,伺服器應用程式必須將每個傳入請求多路分用並指派到各自相應的服務提供者。反應器模式正好適用於這一功能。它允許事件驅動應用程式將服務要求多路分用並進行指派,然後,這些服務要求被並發地從一個或多個客戶機傳送到應用程式。
|
反應器模式的核心功能
- 將事件多路分用
- 將事件指派到各自相應的事件處理常式
|
|
反應器模式與觀察者模式(Observer pattern)在這個方面極為相似:當一個主體發生改變時,所有依屬體都得到通知。不過,觀察者模式與單個事件來源關聯,而反應器模式則與多個事件來源關聯。
請參閱
參考資料瞭解關於反應器模式的更多資訊。
通道和選取器
NIO 的非阻塞 I/O 機制是圍繞 選取器和 通道構建的。 Channel 類表示伺服器和客戶機之間的一種通訊機制。與反應器模式一致,
Selector 類是 Channel 的多工器。
Selector 類將傳入客戶機請求多路分用並將它們指派到各自的請求處理常式。
我們將仔細考察 Channel 類和
Selector 類的各個功能,以及這兩個類如何協同工作,建立非阻塞 I/O 實現。
通道做什麼
通道表示連到一個實體(例如:硬體裝置、檔案、網路通訊端或者能執行一個或多個不同 I/O 操作(例如:讀或寫)的程式組件)的開放串連。可以非同步地關閉和中斷 NIO 通道。所以,如果一個線程在某條通道的 I/O 操作上阻塞時,那麼另一個線程可以將這條通道關閉。類似地,如果一個線程在某條通道的 I/O 操作上阻塞時,那麼另一個線程可以中斷這個阻塞線程。
圖 2. java.nio.channels 的類階層
2 所示,在 java.nio.channels 包中有不少通道介面。我們主要關心 java.nio.channels.SocketChannel 介面和
java.nio.channels.ServerSocketChannel 介面。 這兩個介面可用來分別代替
java.net.Socket 和 java.net.ServerSocket 。儘管我們當然將把注意力放在以非阻塞方式使用通道上,但通道可以以阻塞方式或非阻塞方式使用。
建立一條非阻塞通道
為了實現基礎的非阻塞通訊端讀和寫操作,我們要處理兩個新類。它們是來自 java.net 包的 InetSocketAddress 類,它指定串連到哪裡,以及來自 java.nio.channels 包的
SocketChannel 類,它執行實際的讀和寫操作。
這部分中的程式碼片段顯示了一種經過修改的、非阻塞的辦法來建立基礎的伺服器-通訊端程式。請注意這些代碼樣本與第一個樣本中所用的代碼之間的變化,從添加兩個新類開始:
String host = ......; InetSocketAddress socketAddress = new InetSocketAddress(host, 80); SocketChannel channel = SocketChannel.open(); channel.connect(socketAddress); |
|
| 緩衝區的角色 Buffer 是包含特定基礎資料型別 (Elementary Data Type)資料的抽象類別。從本質上說,它是一個封裝器,它將帶有 getter/setter 方法的固定大小的數組封裝起來,這些 getter/setter 方法使得緩衝區的內容可以被訪問。
Buffer 類有許多子類,如下:
ByteBuffer
CharBuffer
DoubleBuffer
FloatBuffer
IntBuffer
LongBuffer
ShortBuffer
ByteBuffer 是唯一支援對其它類型進行讀寫的類,因為其它類都是特定於類型的。一旦串連上,就可以使用
ByteBuffer 對象從通道讀資料或將資料寫到通道。請參閱 參考資料瞭解關於
ByteBuffer 的更多資訊。
|
|
為了使通道成為非阻塞的,我們在通道上調用 configureBlockingMethod(false) ,如下所示:
channel.configureBlockingMethod(false); |
在阻塞模式中,線程將在讀或寫時阻塞,一直到讀或寫操作徹底完成。如果在讀的時候,資料尚未完全到達通訊端,則線程將在讀操作上阻塞,一直到資料可用。
在非阻塞模式中,線程將讀取已經可用的資料(不論多少),然後返回執行其它任務。如果將真(true)傳遞給 configureBlockingMethod() ,則通道的行為將與在
Socket 上進行阻塞讀或寫時的行為完全相同。唯一的主要差別,如上所述,是這些阻塞讀和寫可以被其它線程中斷。
單靠 Channel 建立非阻塞 I/O 實現是不夠的。要實現非阻塞 I/O,
Channel 類必須與 Selector 類配合進行工作。
選取器做什麼
在反應器模式情形中, Selector 類充當
Reactor 角色。 Selector 對多個
SelectableChannels 的事件進行多工。每個
Channel 向 Selector 註冊事件。當事件從客戶機處到來時,
Selector 將它們多路分用並將這些事件指派到相應的
Channel 。
建立 Selector 最簡單的辦法是使用
open() 方法,如下所示:
Selector selector = Selector.open(); |
通道遇上選取器
每個要為客戶機請求提供服務的 Channel 都必須首先建立一個串連。下面的代碼建立稱為
Server 的 ServerSocketChannel 並將它綁定到本地連接埠:
ServerSocketChannel serverChannel = ServerSocketChannel.open();serverChannel.configureBlocking(false);InetAddress ia = InetAddress.getLocalHost();InetSocketAddress isa = new InetSocketAddress(ia, port );serverChannel.socket().bind(isa); |
每個要為客戶機請求提供服務的 Channel 都必須接著將自己向
Selector 註冊。 Channel 應根據它將處理的事件進行註冊。例如,接受傳入串連的
Channel 應這樣註冊,如下:
SelectionKey acceptKey = channel.register( selector,SelectionKey.OP_ACCEPT); |
Channel 向 Selector 的註冊用
SelectionKey 對象表示。滿足以下三個條件之一,
Key 就失效:
Channel 被關閉。
Selector 被關閉。
- 通過調用
Key 的 cancel() 方法將
Key 本身取消。
Selector 在 select() 調用時阻塞。接著,它開始等待,直到建立了一個新的串連,或者另一個線程將它喚醒,或者另一個線程將原來的阻塞線程中斷。
註冊伺服器
Server 是那個將自己向 Selector 註冊以接受所有傳入串連的
ServerSocketChannel ,如下所示:
SelectionKey acceptKey = serverChannel.register(sel, SelectionKey.OP_ACCEPT); while (acceptKey.selector().select() > 0 ){ ...... |
Server 被註冊後,我們根據每個關鍵字(key)的類型以迭代方式對一組關鍵字進行處理。一個關鍵字被處理完成後,就都被從就緒關鍵字(ready keys)列表中除去,如下所示:
Set readyKeys = sel.selectedKeys(); Iterator it = readyKeys.iterator();while (it.hasNext()) {SelectionKey key = (SelectionKey)it.next(); it.remove(); .... .... .... } |
如果關鍵字是可接受(acceptable)的,則接受串連,註冊通道,以接受更多的事件(例如:讀或寫操作)。 如果關鍵字是可讀的(readable)或可寫的(writable),則伺服器會指示它已經就緒於讀寫本端資料:
SocketChannel socket;if (key.isAcceptable()) { System.out.println("Acceptable Key"); ServerSocketChannel ssc = (ServerSocketChannel) key.channel(); socket = (SocketChannel) ssc.accept(); socket.configureBlocking(false); SelectionKey another = socket.register(sel,SelectionKey.OP_READ|SelectionKey.OP_WRITE);}if (key.isReadable()) { System.out.println("Readable Key"); String ret = readMessage(key); if (ret.length() > 0) { writeMessage(socket,ret); } }if (key.isWritable()) { System.out.println("Writable Key"); String ret = readMessage(key); socket = (SocketChannel)key.channel(); if (result.length() > 0 ) { writeMessage(socket,ret); } } |
唵嘛呢叭咪吽 — 非阻塞伺服器通訊端快顯靈!
對 JDK 1.4 中的非阻塞 I/O 的介紹的最後一部分留給您:運行這個樣本。
在這個簡單的非阻塞伺服器-通訊端樣本中,伺服器讀取發送自客戶機的檔案名稱,顯示該檔案的內容,然後將內容寫回到客戶機。
這裡是您運行這個樣本需要做的事情:
- 安裝 JDK 1.4(請參閱
參考資料)。
- 將兩個
原始碼檔案複製到您的目錄。
- 編譯和運行伺服器,
java NonBlockingServer 。
- 編譯和運行客戶機,
java Client 。
- 輸入類檔案所在目錄的一個文字檔或 java 檔案的名稱。
- 伺服器將讀取該檔案並將其內容發送到客戶機。
- 客戶機將把從伺服器接收到的資料列印出來。(由於所用的
ByteBuffer 的限制,所以將唯讀取 1024 位元組。)
- 輸入 quit 或 shutdown 命令關閉客戶機。
結束語
Merlin 的新 I/O 包覆蓋範圍很廣。Merlin 的新的非阻塞 I/O 實現的主要優點有兩方面:線程不再在讀或寫時阻塞,以及 Selector 能夠處理多個串連,從而大幅降低了伺服器應用程式開銷。
我們已經著重論述了新的 java.nio 包的這兩大優點。我們希望,您將把在這裡所學到的知識應用到自己的實際應用程式開發工作中。