2004-03-02 瀏覽次數:1635
NIO 是帶有 JDK 1.4 的 Java 平臺的最有名(如果不是最出色的)的添加部分之一。下面的許多文章闡述了 NIO 的基本知識及如何利用非阻塞通道的好處。但它們所遺漏的一件事正是,沒(méi)有充分地展示 NIO 如何可以提高 J2EE Web 層的可伸縮性。對于企業(yè)開(kāi)發(fā)人員來(lái)說(shuō),這些信息特別密切相關(guān),因為實(shí)現 NIO 不像把少數幾個(gè) import 語(yǔ)句改變成一個(gè)新的 I/O 包那樣簡(jiǎn)單。首先,Servlet API 采用阻塞 I/O 語(yǔ)義,因此默認情況下,它不能利用非阻塞 I/O。其次,不像 JDK 1.0 中那樣,線(xiàn)程不再是“資源獨占”(resource hog),因此使用較少的線(xiàn)程不一定表明服務(wù)器可以處理更多的客戶(hù)機。
在本文中,為了創(chuàng )建基于 Servlet 并實(shí)現了 NIO 的 Web 服務(wù)器,您將學(xué)習如何解決 Servlet API 與非阻塞 I/O 的不配合問(wèn)題。我們將會(huì )看到在多元的 Web 服務(wù)器環(huán)境中,這個(gè)服務(wù)器是如何針對標準 I/O 服務(wù)器(Tomcat 5.0)進(jìn)行伸縮的。為符合企業(yè)中生存期的事實(shí),我們將重點(diǎn)放在當保持 socket 連接的客戶(hù)機數量以指數級增長(cháng)時(shí),NIO 與標準 I/O 相比較的情況如何。
注意,本文針對某些 Java 開(kāi)發(fā)人員,他們已經(jīng)熟悉了 Java 平臺上 I/O 編程的基礎知識。有關(guān)非阻塞 I/O 的介紹,請參閱
參考資料 部分。
大家都知道,線(xiàn)程是比較昂貴的。在 Java 平臺的早期(JDK 1.0),線(xiàn)程的開(kāi)銷(xiāo)是一個(gè)很大負擔,因此強制開(kāi)發(fā)人員自定義生成解決方案。一個(gè)常見(jiàn)的解決方案是使用 VM 啟動(dòng)時(shí)創(chuàng )建的線(xiàn)程池,而不是按需創(chuàng )建每個(gè)新線(xiàn)程。盡管最近在 VM 層上提高了線(xiàn)程的性能,但標準 I/O 仍然要求分配惟一的線(xiàn)程來(lái)處理每個(gè)新打開(kāi)的 socket。就短期而言,這工作得相當不錯,但當線(xiàn)程的數量增加超過(guò)了 1K,標準 I/O 的不足就表現出來(lái)了。由于要在線(xiàn)程間進(jìn)行上下文切換,因此 CPU 簡(jiǎn)直變成了超載。
由于 JDK 1.4 中引入了 NIO,企業(yè)開(kāi)發(fā)人員最終有了“單線(xiàn)程”模型的一個(gè)內置解決方案:多元 I/O 使得固定數量的線(xiàn)程可以服務(wù)不斷增長(cháng)的用戶(hù)數量。
多路復用(Multiplexing) 指的是通過(guò)一個(gè)載波來(lái)同時(shí)發(fā)送多個(gè)信號或流。當使用手機時(shí),日常的多路復用例子就發(fā)生了。無(wú)線(xiàn)頻率是稀有的資源,因此無(wú)線(xiàn)頻率提供商使用多路復用技術(shù)通過(guò)一個(gè)頻率發(fā)送多個(gè)呼叫。在一個(gè)例子中,把呼叫分成一些段,然后給這些段很短的持續時(shí)間,并在接收端重新裝配。這就叫做 時(shí)分多路復用(time-division multiplexing) ,即 TDM。
在 NIO 中,接收端相當于“選擇器”(參閱 java.nio.channels.Selector)。不是處理呼叫,選擇器是處理多個(gè)打開(kāi)的 socket。就像在 TDM 中那樣,選擇器重新裝配從多個(gè)客戶(hù)機寫(xiě)入的數據段。這使得服務(wù)器可以用單個(gè)線(xiàn)程管理多個(gè)客戶(hù)機。
對于 NIO,非阻塞讀寫(xiě)是必要的,但它們并不是完全沒(méi)有麻煩。除了不會(huì )阻塞之外,非阻塞讀不能給呼叫方任何保證??蛻?hù)機或服務(wù)器應用程序可能讀取完整信息、部分消息或者根本讀取不到消息。另外,非阻塞讀可能讀取到太多的消息,從而強制為下一個(gè)呼叫準備一個(gè)額外的緩沖區。最后,不像流那樣,讀取了零字節并不表明已經(jīng)完全接收了消息。
這些因素使得沒(méi)有輪詢(xún)就不可能實(shí)現甚至是簡(jiǎn)單的 readline 方法。所有的 servlet 容器必須在它們的輸入流上提供 readline 方法。因此,許多開(kāi)發(fā)人員放棄了創(chuàng )建基于 Servlet 并實(shí)現了 NIO 的 Web 應用程序服務(wù)器。不過(guò)這里有一個(gè)解決方案,它組合了 Servlet API 和 NIO 的多元 I/O 的能力。
在下面的幾節中,您將學(xué)習如何使用 java.io.PipedInput 和 PipedOutputStream 類(lèi)來(lái)把生產(chǎn)者/消費者模型應用到消費者非阻塞 I/O。當讀取非阻塞通道時(shí),把它寫(xiě)到正由第二個(gè)線(xiàn)程消費的管道。注意,這種分解映射線(xiàn)程不同于大多數基于 Java 的客戶(hù)機/服務(wù)器應用程序。這里,我們讓一個(gè)線(xiàn)程單獨負責處理非阻塞通道(生產(chǎn)者),讓另一個(gè)線(xiàn)程單獨負責把數據作為流消費(消費者)。管道也為應用程序服務(wù)器解決了非阻塞 I/O 問(wèn)題,因為 servlet 在消費 I/O 時(shí)將采用阻塞語(yǔ)義。
示例服務(wù)器展示了 Servlet API 和 NIO 不兼容的生產(chǎn)者/消費者解決方案。該服務(wù)器與 Servlet API 非常相似,可以為成熟的基于 NIO 應用程序服務(wù)器提供 POC (proof of concept),是專(zhuān)門(mén)編寫(xiě)來(lái)衡量 NIO 相對于標準 Java I/O 的性能的。它處理簡(jiǎn)單的 HTTP get 請求,并支持來(lái)自客戶(hù)機的 Keep-Alive 連接。這是重要的,因為多路復用 I/O 只證明在要求服務(wù)器處理大量打開(kāi)的 scoket 連接時(shí)是有意的。
該服務(wù)器被分成兩個(gè)包:org.sse.server 和 org.sse.http 包中有提供主要 服務(wù)器 功能的類(lèi),比如如下的一些功能:接收新客戶(hù)機連接、閱讀消息和生成工作線(xiàn)程以處理請求。http 包支持 HTTP 協(xié)議的一個(gè)子集。詳細闡述 HTTP 超出了本文的范圍。
現在讓我們來(lái)看一下 org.sse.server 包中一些最重要的類(lèi)。
Server 類(lèi)擁有多路復用循環(huán) —— 任何基于 NIO 服務(wù)器的核心。在清單 1 中,在服務(wù)器接收新客戶(hù)機或檢測到正把可用的字節寫(xiě)到打開(kāi)的 socket 前,select() 的調用阻塞了。這與標準 Java I/O 的主要區別是,所有的數據都是在這個(gè)循環(huán)中讀取的。通常會(huì )把從特定 socket 中讀取字節的任務(wù)分配給一個(gè)新線(xiàn)程。使用 NIO 選擇器事件驅動(dòng)方法,實(shí)際上可以用單個(gè)線(xiàn)程處理成千上萬(wàn)的客戶(hù)機,不過(guò),我們還會(huì )在后面看到線(xiàn)程仍有一個(gè)角色要扮演。
每個(gè) select() 調用返回一組事件,指出新客戶(hù)機可用;新數據準備就緒,可以讀??;或者客戶(hù)機準備就緒,可以接收響應。server 的 handleKey() 方法只對新客戶(hù)機(key.isAcceptable())和傳入數據 (key.isReadable()) 感興趣。到這里,工作就結束了,轉入 ServerEventHandler 類(lèi)。
public void listen() { SelectionKey key = null; try { while (true) { selector.select(); Iterator it = selector.selectedKeys().iterator(); while (it.hasNext()) { key = (SelectionKey) it.next(); handleKey(key); it.remove(); } } } catch (IOException e) { key.cancel(); } catch (NullPointerException e) { // NullPointer at sun.nio.ch.WindowsSelectorImpl, Bug: 4729342 e.printStackTrace(); } }
ServerEventHandler 類(lèi)響應服務(wù)器事件。當新客戶(hù)機變?yōu)榭捎脮r(shí),它就實(shí)例化一個(gè)新的 Client 對象,該對象代表了那個(gè)客戶(hù)機的狀態(tài)。數據是以非阻塞方式從通道中讀取的,并被寫(xiě)到 Client 對象中。ServerEventHandler 對象也維護請求隊列。為了處理(消費)隊列中的請求,生成了不定數量的工作線(xiàn)程。在傳統的生產(chǎn)者/消費者方式下,為了在隊列變?yōu)榭諘r(shí)線(xiàn)程會(huì )阻塞,并在新請求可用時(shí)線(xiàn)程會(huì )得到通知,需要寫(xiě) Queue。
為了支持等待的線(xiàn)程,在清單 2 中已經(jīng)重寫(xiě)了 remove() 方法。如果列表為空,就會(huì )增加等待線(xiàn)程的數量,并阻塞當前線(xiàn)程。它實(shí)質(zhì)上提供了非常簡(jiǎn)單的線(xiàn)程池。
public class Queue extends LinkedList { private int waitingThreads = 0; public synchronized void insert(Object obj) { addLast(obj); notify(); } public synchronized Object remove() { if ( isEmpty() ) { try { waitingThreads++; wait();} catch (InterruptedException e) {Thread.interrupted();} waitingThreads--; } return removeFirst(); } public boolean isEmpty() { return (size() - waitingThreads <= 0); } }
工作線(xiàn)程的數量與 Web 客戶(hù)機的數量無(wú)關(guān)。不是為每個(gè)打開(kāi)的 socket 分配一個(gè)線(xiàn)程,相反,我們把所有請求放到一個(gè)由一組 RequestHandlerThread 實(shí)例所服務(wù)的通用隊列中。理想情況下,線(xiàn)程的數量應該根據處理器的數量和請求的長(cháng)度或持續時(shí)間進(jìn)行調整。如果請求通過(guò)資源或處理需求花了很長(cháng)時(shí)間,那么通過(guò)添加更多的線(xiàn)程,可以提高感知到的服務(wù)質(zhì)量。
注意,這不一定提高整體的吞吐量,但確實(shí)改善了用戶(hù)體驗。即使在超載的情況下,也會(huì )給每個(gè)線(xiàn)程一個(gè)處理時(shí)間片。這一原則同樣適用于基于標準 Java I/O 的服務(wù)器;不過(guò)這些服務(wù)器是受到限制的,因為會(huì ) 要求 它們?yōu)槊總€(gè)打開(kāi)的 socket 連接分配一個(gè)線(xiàn)程。NIO 服務(wù)器完全不用擔心這一點(diǎn),因此它們可以擴展到大量用戶(hù)。最后的結果是 NIO 服務(wù)器仍然需要線(xiàn)程,只是不需要那么多。
Client 類(lèi)有兩個(gè)用途。首先,通過(guò)把傳入的非阻塞 I/O 轉換成可由 Servlet API 消費的阻塞 InputStream,它解決了阻塞/非阻塞問(wèn)題。其次,它管理特定客戶(hù)機的請求狀態(tài)。因為當全部讀取消息時(shí),非阻塞通道沒(méi)有給出任何提示,所以強制我們在協(xié)議層處理這一情況。Client 類(lèi)在任意指定的時(shí)刻都指出了它是否正在參與進(jìn)行中的請求。如果它準備處理新請求,write() 方法就會(huì )為請求處理而將該客戶(hù)機排到隊列中。如果它已經(jīng)參與了請求,它就只是使用 PipedInputStream 和 PipedOutputStream 類(lèi)把傳入的字節轉換成一個(gè) InputStream。
圖 1 展示了兩個(gè)線(xiàn)程圍繞管道進(jìn)行交互。主線(xiàn)程把從通道讀取的數據寫(xiě)到管道中。管道把相同的數據作為 InputStream 提供給消費者。管道的另一個(gè)重要特性是:它是進(jìn)行緩沖處理的。如果沒(méi)有進(jìn)行緩沖處理,主線(xiàn)程在嘗試寫(xiě)到管道時(shí)就會(huì )阻塞。因為主線(xiàn)程單獨負責所有客戶(hù)機間的多路復用,因此我們不能讓它阻塞。
在 Client 自己排隊后,工作線(xiàn)程就可以消費它了。RequestHandlerThread 類(lèi)承擔了這個(gè)角色。至此,我們已經(jīng)看到主線(xiàn)程是如何連續地循環(huán)的,它要么接受新客戶(hù)機,要么讀取新的 I/O。工作線(xiàn)程循環(huán)等待新請求。當客戶(hù)機在請求隊列上變?yōu)榭捎脮r(shí),它就馬上被 remove() 方法中阻塞的第一個(gè)等待線(xiàn)程所消費。
public void run() { while (true) { Client client = (Client) myQueue.remove(); try { for (; ; ) { HttpRequest req = new HttpRequest(client.clientInputStream, myServletContext); HttpResponse res = new HttpResponse(client.key); defaultServlet.service(req, res); if (client.notifyRequestDone()) break; } } catch (Exception e) { client.key.cancel(); client.key.selector().wakeup(); } } }
然后該線(xiàn)程創(chuàng )建新的 HttpRequest 和 HttpResponse 實(shí)例,并調用 defaultServlet 的 service 方法。注意,HttpRequest 是用 Client 對象的 clientInputStream 屬性構造的。PipedInputStream 就是負責把非阻塞 I/O 轉換成阻塞流。
從現在開(kāi)始,請求處理就與您在 J2EE Servlet API 中期望的相似。當對 servlet 的調用返回時(shí),工作線(xiàn)程在返回到池中之前,會(huì )檢查是否有來(lái)自相同客戶(hù)機的另一個(gè)請求可用。注意,這里用到了單詞 池 (pool)。事實(shí)上,線(xiàn)程會(huì )對隊列嘗試另一個(gè) remove() 調用,并變成阻塞,直到下一個(gè)請求可用。
示例服務(wù)器實(shí)現了 HTTP 1.1 協(xié)議的一個(gè)子集。它處理普通的 HTTP get 請求。它帶有兩個(gè)命令行參數。第一個(gè)指定端口號,第二個(gè)指定 HTML 文件所駐留的目錄。在解壓文件后,切換 到項目目錄,然后執行下面的命令,注意要把下面的 webroot 目錄替換為您自己的目錄:
java -cp bin org.sse.server.Start 8080 "C:\mywebroot"
還請注意,服務(wù)器并沒(méi)有實(shí)現目錄清單,因此必須指定有效的 URL 來(lái)指向您的 webroot 目錄下的文件。
示例 NIO 服務(wù)器是在重負載下與 Tomcat 5.0 進(jìn)行比較的。選擇 Tomcat 是因為它是基于標準 Java I/O 的純 Java 解決方案。為了提高可伸縮性,一些高級的應用程序服務(wù)器是用 JNI 本機代碼優(yōu)化的,因此它們沒(méi)有提供標準 I/O 和 NIO 之間的很好比較。目標是要確定 NIO 是否給出了大量的性能優(yōu)勢,以及是在什么條件下給出的。
如下是一些說(shuō)明:
Tomcat 是用最大的線(xiàn)程數量 2000 來(lái)配置的,而示例服務(wù)器只允許用 4 個(gè)工作線(xiàn)程運行。
每個(gè)服務(wù)器是針對相同的一組簡(jiǎn)單 HTTP get 測試的,這些 HTTP get 基本上由文本內容組成。
把加載工具(Microsoft Web Application Stress Tool)設置為使用“Keep-Alive”會(huì )話(huà),導致了大約要為每個(gè)用戶(hù)分配一個(gè) socket。然后它導致了在 Tomcat 上為每個(gè)用戶(hù)分配一個(gè)線(xiàn)程,而 NIO 服務(wù)器用固定數量的線(xiàn)程來(lái)處理相同的負載。
圖 2 展示了在不斷增加負載下的“請求/秒”率。在 200 個(gè)用戶(hù)時(shí),性能是相似的。但當用戶(hù)數量超過(guò) 600 時(shí),Tomcat 的性能開(kāi)始急劇下降。這最有可能是由于在這么多的線(xiàn)程間切換上下文的開(kāi)銷(xiāo)而導致的。相反,基于 NIO 的服務(wù)器的性能則以線(xiàn)性方式下降。記住,Tomcat 必須為每個(gè)用戶(hù)分配一個(gè)線(xiàn)程,而 NIO 服務(wù)器只配置有 4 個(gè)工作線(xiàn)程。
圖 3 進(jìn)一步顯示了 NIO 的性能。它展示了操作的 Socket 連接錯誤數/分鐘。同樣,在大約 600 個(gè)用戶(hù)時(shí),Tomcat 的性能急劇下降,而基于 NIO 的服務(wù)器的錯誤率保持相對較低。
在本文中您已經(jīng)學(xué)習了,實(shí)際上可以使用 NIO 編寫(xiě)基于 Servlet 的 Web 服務(wù)器,甚至可以啟用它的非阻塞特性。對于企業(yè)開(kāi)發(fā)人員來(lái)說(shuō),這是好消息,因為在企業(yè)環(huán)境中,NIO 比標準 Java I/O 更能夠進(jìn)行伸縮。不像標準的 Java I/O,NIO 可以用固定數量的線(xiàn)程處理許多客戶(hù)機。當基于 Servlet 的 NIO Web 服務(wù)器用來(lái)處理保持和擁有 socket 連接的客戶(hù)機時(shí),會(huì )獲得更好的性能。