參考:http://ifeve.com/non-blocking-server/
原文地址
目錄
- Java NIO教程
- Java NIO 教程(一) 概述
- Java NIO 教程(二) Channel
- Java NIO 教程(三) Buffer
- Java NIO 教程(四) Scatter/Gather
- Java NIO 教程(五) 通道之間的數據傳輸
- Java NIO 教程(六) Selector
- Java NIO 教程(七) FileChannel
- Java NIO 教程(八) SocketChannel
- Java NIO 教程(九) ServerSocketChannel
- Java NIO 教程(十) 非阻塞式服務器
- Java NIO 教程(十一) Java NIO DatagramChannel
- Java NIO 教程(十二) Pipe
- Java NIO 教程(十三) Java NIO vs. IO
- Java NIO 教程(十四) Java NIO Path
- Java NIO 教程(十五) Java NIO Files
- Java NIO 教程(十六) Java NIO AsynchronousFileChannel
即使你知道Java NIO非阻塞的工作特性(如Selector
,Channel
,Buffer
等組件),但是想要設計一個非阻塞的服務器仍然是一件很困難的事。非阻塞式服務器相較于阻塞式來說要多上許多挑戰。本文將會討論非阻塞式服務器的主要幾個難題,并針對這些難題給出一些可能的解決方案。
查找關于非阻塞式服務器設計方面的資料實在不太容易,所以本文提供的解決方案都是基于本人工作和想法上的。如果各位有其他的替代方案或者更好的想法,我會很樂意聽取這些方案和想法!你可以在文章下方留下你的評論,或者發郵件給我(郵箱為:info@jenkov.com )。
本文的設計思路想法都是基于Java NIO的。但是我相信如果某些語言中也有像Selector
之類的組件的話,文中的想法也能用于該語言。據我所知,類似的組件底層操作系統會提供,所以對你來說也可以根據其中的思想運用在其他語言上。
非阻塞式服務器– GitHub 倉庫
我已經創建了一些簡單的這些思想的概念驗證呈現在這篇教程中,并且為了讓你可以看到,我把源碼放到了github資源庫上了。這里是GitHub資源庫地址:
https://github.com/jjenkov/java-nio-server
非阻塞式IO管道(Pipelines)
一個非阻塞式IO管道是由各個處理非阻塞式IO組件組成的鏈。其中包括讀/寫IO。下圖就是一個簡單的非阻塞式IO管道組成:
一個組件使用Selector
監控Channel
什么時候有可讀數據。然后這個組件讀取輸入并且根據輸入生成相應的輸出。最后輸出將會再次寫入到一個Channel
中。
一個非阻塞式IO管道不需要將讀數據和寫數據都包含,有一些管道可能只會讀數據,另一些可能只會寫數據。
上圖僅顯示了一個單一的組件。一個非阻塞式IO管道可能擁有超過一個以上的組件去處理輸入數據。一個非阻塞式管道的長度是由他的所要完成的任務決定。
一個非阻塞IO管道可能同時讀取多個Channel
里的數據。舉個例子:從多個SocketChannel
管道讀取數據。
其實上圖的控制流程還是太簡單了。這里是組件從Selector
開始從Channel
中讀取數據,而不是Channel
將數據推送給Selector
進入組件中,即便上圖畫的就是這樣。
非阻塞式 vs 阻塞式管道
非阻塞和阻塞IO管道兩者之間最大的區別在于他們如何從底層Channel
(Socket
或者file
)讀取數據。
IO管道通常從流中讀取數據(來自socket
或者file
)并且將這些數據拆分為一系列連貫的消息。這和使用tokenizer
(這里估計是解析器之類的意思)將數據流解析為token
(這里應該是數據包的意思)類似。相反你只是將數據流分解為更大的消息體。我將拆分數據流成消息這一組件稱為“消息讀取器”(Message Reader
)下面是Message Reader
拆分流為消息的示意圖:
一個阻塞IO管道可以使用類似InputStream
的接口每次一個字節地從底層Channel
讀取數據,并且這個接口阻塞直到有數據可以讀取。這就是阻塞式Message Reader
的實現過程。
使用阻塞式IO接口簡化了Message Reader
的實現。阻塞式Message Reader
從不用處理在流沒有數據可讀的情況,或者它只讀取流中的部分數據并且對于消息的恢復也要延遲處理的情況。
同樣,阻塞式Message Writer
(一個將數據寫入流中組件)也從不用處理只有部分數據被寫入和寫入消息要延遲恢復的情況。
阻塞式IO管道的缺陷
雖然阻塞式Message Reader
容易實現,但是也有一個不幸的缺點:每一個要分解成消息的流都需要一個獨立的線程。必須要這樣做的理由是每一個流的IO接口會阻塞,直到它有數據讀取。這就意味著一個單獨的線程是無法嘗試從一個沒有數據的流中讀取數據轉去讀另一個流。一旦一個線程嘗試從一個流中讀取數據,那么這個線程將會阻塞直到有數據可以讀取。
如果IO管道是必須要處理大量并發鏈接服務器的一部分的話,那么服務器就需要為每一個鏈接維護一個線程。對于任何時間都只有幾百條并發鏈接的服務器這確實不是什么問題。但是如果服務器擁有百萬級別的并發鏈接量,這種設計方式就沒有良好收放。每個線程都會占用棧32bit-64bit的內存。所以一百萬個線程占用的內存將會達到1TB!不過在此之前服務器將會把所有的內存用以處理傳過來的消息(例如:分配給消息處理期間使用對象的內存)
為了將線程數量降下來,許多服務器使用了服務器維持線程池(例如:常用線程為100)的設計,從而一次一個地從入站鏈接(inbound connections
)地讀取。入站鏈接保存在一個隊列中,線程按照進入隊列的順序處理入站鏈接。這一設計如下圖所示:(譯者注:Tomcat就是這樣的)
然而,這一設計需要入站鏈接合理地發送數據。如果入站鏈接長時間不活躍,那么大量的不活躍鏈接實際上就造成了線程池中所有線程阻塞。這意味著服務器響應變慢甚至是沒有反應。
一些服務器嘗試通過彈性控制線程池的核心線程數量這一設計減輕這一問題。例如,如果線程池線程不足時,線程池可能開啟更多的線程處理請求。這一方案意味著需要大量的長時鏈接才能使服務器不響應。但是記住,對于并發線程數任然是有一個上限的。因此,這一方案仍然無法很好地解決一百萬個長時鏈接。
基礎非阻塞式IO管道設計
一個非阻塞式IO管道可以使用一個單獨的線程向多個流讀取數據。這需要流可以被切換到非阻塞模式。在非阻塞模式下,當你讀取流信息時可能會返回0個字節或更多字節的信息。如果流中沒有數據可讀就返回0字節,如果流中有數據可讀就返回1+字節。
為了避免檢查沒有可讀數據的流我們可以使用 Java NIO Selector. 一個或多個SelectableChannel
實例可以同時被一個Selector
注冊.。當你調用Selector
的select()
或者selectNow()
方法它只會返回有數據讀取的SelectableChannel
的實例. 下圖是該設計的示意圖:
讀取部分消息
當我們從一個SelectableChannel
讀取一個數據包時,我們不知道這個數據包相比于源文件是否有丟失或者重復數據(原文是:When we read a block of data from a SelectableChannel we do not know if that data block contains less or more than a message)。一個數據包可能的情況有:缺失數據(比原有消息的數據少)、與原有一致、比原來的消息的數據更多(例如:是原來的1.5或者2.5倍)。數據包可能出現的情況如下圖所示:
在處理類似上面這樣部分信息時,有兩個問題:
- 判斷你是否能在數據包中獲取完整的消息。
- 在其余消息到達之前如何處理已到達的部分消息。
判斷消息的完整性需要消息讀取器(Message Reader
)在數據包中尋找是否存在至少一個完整消息體的數據。如果一個數據包包含一個或多個完整消息體,這些消息就能夠被發送到管道進行處理。尋找完整消息體這一處理可能會重復多次,因此這一操作應該盡可能的快。
判斷消息完整性和存儲部分消息都是消息讀取器(Message Reader
)的責任。為了避免混合來自不同Channel
的消息,我們將對每一個Channel
使用一個Message Reader
。設計如下圖所示:
在從Selector
得到可從中讀取數據的Channel
實例之后,與該Channel
相關聯的Message Reader
讀取數據并嘗試將他們分解為消息。這樣讀出的任何完整消息可以被傳到讀取通道(read pipeline
)任何需要處理這些消息的組件中。
一個Message Reader
一定滿足特定的協議。Message Reader
需要知道它嘗試讀取的消息的消息格式。如果我們的服務器可以通過協議來復用,那它需要有能夠插入Message Reader
實現的功能 – 可能通過接收一個Message Reader
工廠作為配置參數。
存儲部分消息
現在我們已經確定Message Reader
有責任存儲部分消息,直到收到完整的消息,我們需要弄清楚這些部分消息的存儲應該如何實現。
有兩個設計因素我們要考慮:
- 我們想盡可能少地復制消息數據。復制越多,性能越低。
- 我們希望將完整的消息存儲在連續的字節序列中,使解析消息更容易。
每個Message Reader的緩沖區
很顯然部分消息需要存儲某些緩沖區中。簡單的實現方式可以是每一個Message Reader
內部簡單地有一個緩沖區。但是這個緩沖區應該多大?它要大到足夠儲存最大允許儲存消息。因此,如果最大允許儲存消息是1MB,那么Message Reader
內部緩沖區將至少需要1MB。
當我們的鏈接達到百萬數量級,每個鏈接都使用1MB并沒有什么作用。1,000,000 * 1MB仍然是1TB的內存!那如果最大的消息是16MB甚至是128MB呢?
大小可調的緩沖區
另一個選擇是在Message Reader
內部實現一個大小可調的緩沖區。大小可調的緩沖區開始的時候很小,如果它獲取的消息過大,那緩沖區會擴大。這樣每一條鏈接就不一定需要如1MB的緩沖區。每條鏈接的緩沖區只要需要足夠儲存下一條消息的內存就行了。
有幾個可實現可調大小緩沖區的方法。它們都各自有自己的優缺點,所以接下來的部分我將逐個討論。
通過復制調整大小
實現可調大小緩沖區的第一種方式是從一個大小(例如:4KB)的緩沖區開始。如果4KB的緩沖區裝不下一個消息,則會分配一個更大的緩沖區(如:8KB),并將大小為4KB的緩沖區數據復制到這個更大的緩沖區中去。
通過復制實現大小可調緩沖區的優點在于消息的所有數據被保存在一個連續的字節數組中,這就使得消息的解析更加容易。它的缺點就是在復制更大消息的時候會導致大量的數據。
為了減少消息的復制,你可以分析流進你系統的消息的大小,并找出盡量減少復制量的緩沖區的大小。例如,你可能看到大多數消息都小于4KB,這是因為它們都僅包含很小的request/responses
。這意味著緩沖區的初始值應該設為4KB。
然后你可能有一個消息大于4KB,這通常是因為它里面包含一個文件。你可能注意到大多數流進系統的文件都是小于128KB的。這樣第二個緩沖區的大小設置為128KB就較為合理。
最后你可能會發現一旦消息超過128KB之后,消息的大小就沒有什么固定的模式,因此緩沖區最終的大小可能就是最大消息的大小。
根據流經系統的消息大小,上面三種緩沖區大小可以減少數據的復制。小于4KB的消息將不會復制。對于一百萬個并發鏈接其結果是:1,000,000 * 4KB = 4GB,對于目前大多數服務器還是有可能的。介于4KB – 128KB的消息將只會復制一次,并且只有4KB的數據復制進128KB的緩沖區中。介于128KB至最大消息大小的消息將會復制兩次。第一次復制4KB,第二次復制128KB,所以最大的消息總共復制了132KB。假設沒有那么多超過128KB大小的消息那還是可以接受的。
一旦消息處理完畢,那么分配的內存將會被清空。這樣在同一鏈接接收到的下一條消息將會再次從最小緩沖區大小開始算。這樣做的必要性是確保了不同連接間內存的有效共享。所有的連接很有可能在同一時間并不需要打的緩沖區。
我有一篇介紹如何實現這樣支持可調整大小的數組的內存緩沖區的完整文章:
文章包含一個GitHub倉庫連接,其中的代碼演示了是如何實現的。
通過追加調整大小
調整緩沖區大小的另一種方法是使緩沖區由多個數組組成。當你需要調整緩沖區大小時,你只需要另一個字節數組并將數據寫進去就行了。
這里有兩種方法擴張一個緩沖區。一個方法是分配單獨的字節數組,并將這些數組保存在一個列表中。另一個方法是分配較大的共享字節數組的片段,然后保留分配給緩沖區的片段的列表。就個人而言,我覺得片段的方式會好些,但是差別不大。
通過追加單獨的數組或片段來擴展緩沖區的優點在于寫入過程中不需要復制數據。所有的數據可以直接從socket
(Channel
)復制到一個數組或片段中。
以這種方式擴展緩沖區的缺點是在于數據不是存儲在單獨且連續的數組中。這將使得消息的解析更困難,因為解析器需要同時查找每個單獨數組的結尾處和所有數組的結尾處。由于你需要在寫入的數據中查找消息的結尾,所以該模型并不容易使用。
TLV編碼消息
一些協議消息格式是使用TLV格式(類型(Type
)、長度(Length
)、值(Value
))編碼。這意味著當消息到達時,消息的總長度被存儲在消息的開頭。這一方式你可以立即知道應該對整個消息分配多大的內存。
TLV編碼使得內存管理變得更加容易。你可以立即知道要分配多大的內存給這個消息。只有部分在結束時使用的緩沖區才會使得內存浪費。
TLV編碼的一個缺點是你要在消息的所有數據到達之前就分配好這個消息需要的所有內存。一些慢連接可能因此分配完你所有可用內存,從而使得你的服務器無法響應。
此問題的解決方法是使用包含多個TLV字段的消息格式。因此,服務器是為每個字段分配內存而不是為整個消息分配內存,并且是字段到達之后再分配內存。然而,一個大消息中的一個大字段在你的內存管理有同樣的影響。
另外一個方案就是對于還未到達的信息設置超時時間,例如10-15秒。當恰好有許多大消息到達服務器時,這個方案能夠使得你的服務器可以恢復,但是仍然會造成服務器一段時間無法響應。另外,惡意的DoS(Denial of Service拒絕服務)攻擊仍然可以分配完你服務器的所有內存。
TLV編碼存在許多不同的形式。實際使用的字節數、自定字段的類型和長度都依賴于每一個TLV編碼。TLV編碼首先放置字段的長度、然后是類型、然后是值(一個LTV編碼)。 雖然字段的順序不同,但它仍然是TLV的一種。
TLV編碼使內存管理更容易這一事實,其實是HTTP 1.1是如此可怕的協議的原因之一。 這是他們試圖在HTTP 2.0中修復數據的問題之一,數據在LTV編碼幀中傳輸。 這也是為什么我們使用TLV編碼的VStack.co project 設計了我們自己的網絡協議。
寫部分數據
在非阻塞IO管道中寫數據仍然是一個挑戰。當你調用一個處于非阻塞式Channel對象的write(ByteBuffer)
方法時,ByteBuffer
寫入多少數據是無法保證的。write(ByteBuffer)
方法會返回寫入的字節數,因此可以跟蹤寫入的字節數。這就是挑戰:跟蹤部分寫入的消息,以便最終可以發送一條消息的所有字節。
為了管理部分消息寫入Channel
,我們將創建一個消息寫入器(Message Writer
)。就像Message Reader
一樣,每一個要寫入消息的Channel
我們都需要一個Message Writer
。在每個Message Writer
中,我們跟蹤正在寫入的消息的字節數。
如果達到的消息量超過Message Writer
可直接寫入Channel
的消息量,消息就需要在Message Writer
排隊。然后Message Writer
盡快地將消息寫入到Channel
中。
下圖是部分消息如何寫入的設計圖:
為了使Message Writer
能夠盡快發送數據,Message Writer
需要能夠不時被調用,這樣就能發送更多的消息。
如果你有大量的連接那你將需要大量的Message Writer
實例。檢查Message Writer
實例(如:一百萬個)看寫任何數據時是否緩慢。 首先,許多Message Writer
實例都沒有任何消息要發送,我們并不想檢查那些Message Writer
實例。其次,并不是所有的Channel
實例都可以準備好寫入數據。 我們不想浪費時間嘗試將數據寫入無法接受任何數據的Channel
。
為了檢查Channel
是否準備好進行寫入,您可以使用Selector
注冊Channel
。然而我們并不想將所有的Channel
實例注冊到Selector
中去。想象一下,如果你有1,000,000個連接且其中大多是空閑的,并且所有的連接已經與Selector
注冊。然后當你調用select()
時,這些Channel
實例的大部分將被寫入就緒(它們大都是空閑的,記得嗎?)然后你必須檢查所有這些連接的Message Writer
,以查看他們是否有任何數據要寫入。
為了避免檢查所有消息的Message Writer實例和所有不可能被寫入任何信息的Channel實例,我們使用這兩步的方法:
- 當一個消息被寫入Message Writer,Message Writer向Selector注冊其相關Channel(如果尚未注冊)。
- 當你的服務器有時間時,它檢查Selector以查看哪些注冊的Channel實例已準備好進行寫入。 對于每個寫就緒Channel,請求其關聯的Message Writer將數據寫入Channel。 如果Message Writer將其所有消息寫入其Channel,則Channel將再次從Selector注冊。
這兩個小步驟確保了有消息寫入的Channel實際上已經被Selector注冊了。
匯總
正如你所見,一個非阻塞式服務器需要時不時檢查輸入的消息來判斷是否有任何的新的完整的消息發送過來。服務器可能會在一個或多個完整消息發來之前就檢查了多次。檢查一次是不夠的。
同樣,一個非阻塞式服務器需要時不時檢查是否有任何數據需要寫入。如果有,服務器需要檢查是否有任何相應的連接準備好將該數據寫入它們。只有在第一次排隊消息時才檢查是不夠的,因為消息可能被部分寫入。
所有這些非阻塞服務器最終都需要定期執行的三個“管道”(pipelines
)::
- 讀取管道(The read pipeline),用于檢查是否有新數據從開放連接進來的。
- 處理管道(The process pipeline),用于所有任何完整消息。
- 寫入管道(The write pipeline),用于檢查是否可以將任何傳出的消息寫入任何打開的連接。
這三條管道在循環中重復執行。你可能可以稍微優化執行。例如,如果沒有排隊的消息可以跳過寫入管道。 或者,如果我們沒有收到新的,完整的消息,也許您可以跳過處理管道。
以下是說明完整服務器循環的圖:
如果仍然發現這有點復雜,請記住查看GitHub資料庫:
https://github.com/jjenkov/java-nio-server
也許看到正在執行的代碼可能會幫助你了解如何實現這一點。
服務器線程模型
GitHub資源庫里面的非阻塞式服務器實現使用了兩個線程的線程模式。第一個線程用來接收來自ServerSocketChannel
的傳入連接。第二個線程處理接受的連接,意思是讀取消息,處理消息并將響應寫回連接。這兩個線程模型的圖解如下:
上一節中說到的服務器循環處理是由處理線程(Processor Thread
)執行。