Java NIO: Non-blocking Server 非阻塞網絡服務器

本文翻譯自 Jakob Jenkov 的 Java NIO: Non-blocking Server ,原文地址:http://tutorials.jenkov.com/java-nio/non-blocking-server.html
文中全部想法均來自原做者,學習之餘,以爲很不錯,對之後深刻學習服務器有幫助,故翻譯之,有錯誤還望指教html

Non-blocking Server

即便了解 NIO 非阻塞功能如何工做(Selector,Channel,Buffer等),設計非阻塞服務器仍然很難。 與阻塞 IO 相比,非阻塞 IO 包含若干挑戰。 本文將討論非阻塞服務器的主要挑戰,併爲描述一些可能的解決方案。java

找到有關設計非阻塞服務器的好資料很難。 所以,本文中提供的解決方案基於 Jakob Jenkov 的工做和想法。git

本文中描述的想法是圍繞 Java NIO 設計的。 可是,我相信這些想法能夠在其餘語言中重複使用,只要它們具備某種相似 Selector 的結構。 據我所知,這些構造是由底層操做系統提供的。github

非阻塞 IO 管道

非阻塞 IO 管道是指處理非阻塞 IO 的一系列組件,包括以非阻塞方式讀寫 IO ,如下是簡化的非阻塞IO管道的說明:數組

組件使用選擇器來監聽通道什麼時候有可讀數據。 而後組件讀取輸入數據並根據輸入生成一些輸出。 輸出再次寫入通道。服務器

非阻塞 IO 管道不須要同時讀寫數據。 某些管道可能只讀取數據,而某些管道可能只能寫入數據。多線程

上圖僅顯示單個組件。 非阻塞 IO 管道可能有多個組件處理傳入數據。 非阻塞IO管道的長度取決於管道須要作什麼。併發

非阻塞 IO 管道也能夠同時從多個通道讀取。 例如,從多個 SocketChannel 讀取數據。性能

上圖中的控制流程是已簡化的。 它是經過 Selector 啓動從 Channel 讀取數據的組件。 不是 Channel 將數據推入 Selector 並從那裏推入組件,即便這是上圖所示。學習

非阻塞與阻塞 IO 管道

非阻塞和阻塞 IO 管道之間的最大區別在於如何從底層通道(套接字或文件)讀取數據。

IO 管道一般從某些流(來自套接字或文件)讀取數據,並將該數據拆分爲相干消息。 這相似於將數據流分解爲令牌以使用令牌解析器進行解析。 將流分解爲消息的組件叫作消息讀取器(Message Reader)。 如下是將消息流分解爲消息的消息讀取器(Message Reader)的示意圖:

阻塞 IO 管道,是使用相似於 InputStream 的接口,每次從底層 Channel 讀取一個字節,而且阻塞,直到有數據可讀取。 這就是阻塞 Message Reader 的實現。

使用阻塞 IO 接口流能夠簡化 Message Reader 的實現。 阻塞 Message Reader 沒必要處理從流中讀取數據,可是沒有數據可讀的狀況,或者只讀取了部分消息,以及稍後回覆讀取消息的狀況。

相似地,阻塞 Message Writer(將消息寫入流的組件)也沒必要處理只寫入部分消息的狀況,以及稍後必須恢復消息寫入的狀況。

阻止 IO 管道的缺陷

雖然阻塞的 Message Reader 更容易實現,但它有一個很大的缺點,就是須要爲每一個須要拆分紅消息的流提供一個單獨的線程,由於每一個流的 IO 接口都會阻塞,直到有一些數據要從中讀取。 這意味着單個線程沒法勝任從一個流讀取,若是沒有數據,則從另外一個流讀取這種任務。 一旦線程嘗試從流中讀取數據,線程就會阻塞,直到實際上有一些數據要讀取。

若是 IO 管道是必須處理大量併發鏈接的服務器的一部分,則服務器將須要每一個活動進入鏈接一個線程,可是,若是服務器具備數百萬個併發鏈接,則這種類型的設計不能很好地擴展。 每一個線程將爲其堆棧提供 320K(32位JVM)和 1024K(64位JVM)內存。 所以,100*10000 線程將佔用 1 TB 內存!

爲了減小線程數量,許多服務器使用一種設計,讓服務器保留一個線程池(例如 100),該線程池一次一個地從入站鏈接(inbound connections)讀取消息。 入站鏈接保留在隊列中,而且線程按入站鏈接放入隊列的順序處理來自每一個入站鏈接的消息。 這個設計以下圖示:

可是,此設計要求入站鏈接合理地發送數據。 若是已鏈接的入站鏈接在較長時間內處於非活動狀態,則大量非活動鏈接可能會阻塞(佔用)線程池中的全部線程。 這意味着服務器響應緩慢甚至無響應。

某些服務器設計試圖經過在線程池中的線程數量具備必定彈性來緩解此問題。 例如,若是線程池用完線程,則線程池可能會啓動更多線程來處理負載。 此解決方案意味着須要更多數量的長時間鏈接才能使服務器無響應。 但請記住,運行的線程數仍然存在上限。 所以,這不會解決上述有 100*10000 線程的問題。

基礎非阻塞 IO 管道設計

非阻塞 IO 管道可使用單個線程來讀取來自多個流的消息。 這要求流能夠切換到非阻塞模式。 在非阻塞模式下,當從中讀取數據時,若是流沒有要讀取的數據,則返回 0 字節。 當流實際上有一些要讀取的數據時,返回至少 1 個字節。

爲了不檢查有 0 字節的流來讀取,咱們使用 Selector 註冊一個或多個 SelectableChannel 實例。 當在 Selector 上調用 select() 或 selectNow() 時,它只提供實際上有數據要讀取的 SelectableChannel 實例。 這個設計的示意圖:

讀取部分消息

當咱們從 SelectableChannel 讀取數據塊時,咱們不知道該數據塊是否包含了一條完整的消息,可能的狀況有:比一條消息少、一條完整消息、比一條消息多,以下圖:

處理上述狀況有兩個挑戰:

  1. 檢測數據塊中消息完整性;
  2. 在消息的其他部分到達以前,已收到的部分消息如何處理;

檢測完整消息要求消息讀取器查看數據塊中的數據是否包含至少一個完整消息。 若是數據塊包含一個或多個完整消息,則能夠沿管道發送這些消息以進行處理。 這個步驟將重複不少次,所以這個過程必須儘量快。

每當數據塊中存在部分消息時,不管是單獨消息仍是在一個或多個完整消息以後,都須要存儲該部分消息,直到該消息的其他部分到達。

檢測完整消息和存儲部分消息都是 Message Reader 的職責。 爲區分來自不一樣 Channel 的消息數據,須要爲每一個 Channel 使用一個 Message Reader 。 設計看起來像這樣:

檢索具備要從選擇器讀取的數據的通道實例後,與該通道關聯的消息讀取器讀取數據並嘗試將其分解爲消息。若是有任何完整的消息被讀取,則能夠將這些消息沿讀取管道傳遞給須要處理它們的任何組件。

一個消息閱讀器固然是針對特定協議的。 消息讀取器須要知道它嘗試讀取的消息的消息格式。 若是咱們的服務器實現能夠跨協議重用,則須要可以插入Message Reader 實現 ---- 可能經過以某種方式接受 Message Reader 工廠做爲配置參數。

存儲部分消息

既然咱們已經肯定消息閱讀器負責存儲部分消息,直到收到完整的消息,咱們須要弄清楚應該如何實現部分消息的存儲。

應該考慮兩個設計考慮因素:

  1. 儘量少地複製消息數據。 複製越多,性能越低。
  2. 將完整的消息存儲在連續的字節序列中,使解析消息更容易。
每一個消息讀取器的緩衝區

顯然,部分消息須要存儲在某寫緩衝區中。 簡單的實現是在每一個 Message Reader 中內部只有一個緩衝區。 可是,緩衝區應該有多大? 它須要足夠大才能存儲最大容許消息。 所以,若是容許的最大消息是 1MB ,那麼每一個 Message Reader 中的內部緩衝區至少須要 1MB 。

當咱們達到數百萬個鏈接時,每一個鏈接使用 1MB 並不真正起做用。 100*10000 x 1MB 仍然是 1TB 內存! 若是最大消息大小爲 16MB 怎麼辦? 那128MB?

可調整大小的緩衝區

另外一個選擇是實現一個可調整大小的緩衝區, 緩衝區將從較小的大小開始,若是消息對於緩衝區而言太大了,則會擴展緩衝區。 這樣,每一個鏈接不必定須要例如 1MB 緩衝區。 每一個鏈接只佔用保存下一條消息所需的內存。

有幾種方法能夠實現可調整大小的緩衝區。 全部這些都有優勢和缺點,稍後會討論它們。

1.經過複製消息調整大小

實現可調整大小的緩衝區的第一種方法是從一個小的緩衝區開始,例如, 4KB。 若是消息不能大於 4KB,則可使用更大的緩衝區。 例如分配 8KB,並未來自 4KB 緩衝區的數據複製到更大的緩衝區中。

逐個複製緩衝區實現的優勢是消息的全部數據都保存在一個連續的字節數組中。 這使得解析消息變得更加容易。逐個複製緩衝區實現的缺點是它會致使大量數據複製。

爲了減小數據複製,能夠分析流經系統的消息大小,以找到一些能夠減小複製量的緩衝區大小。

例如,大多數消息是少於 4KB ,由於它們只包含很是小的請求/響應。 這意味着第一個緩衝區大小應爲 4KB。而後若是消息大於 4KB,一般是由於它包含一個文件,流經系統的大多數文件都少於128KB,咱們可使第二個緩衝區大小爲 128KB。最後,一旦消息高於 128KB,消息的大小就沒有規律了,最終的緩衝區大小就是最大的消息大小。

根據流經系統的消息大小設置這3個緩衝區大小就能夠減小數據複製。 永遠不會複製低於 4KB 的消息。 對於一百萬併發鏈接,致使 100*10000 x 4KB = 4GB,今天的大多數服務器中是可以知足這個內存值的。 4KB 到 128KB 之間的消息將被複制一次,而且只須要將 4KB 數據複製到 128KB 緩衝區中。 128KB 和最大消息大小之間的消息將被複制兩次。 第一次 4KB 將被複制,第二次 128KB 將被複制,所以共有 132KB 複製爲最大的消息。 若是沒有那麼多 128KB 以上的消息,這還能夠接受。

消息徹底處理完畢後,應再次釋放已分配的內存。 這樣,從同一鏈接接收的下一條消息再次以最小的緩衝區大小開始,這能夠確保在鏈接之間更有效地共享內存。 並非全部的鏈接都會在同一時間須要大的緩衝區。

2. 經過追加消息調整大小

另外一種調整緩衝區大小的方法是使緩衝區由多個數組組成,當須要調整緩衝區大小時,只需繼續分配另外一個字節數組並將數據寫入其中。

有兩種方法來增長這樣的緩衝區。 一種方法是分配單獨的字節數組,並將這些字節數組的保存到一個列表中。 另外一種方法是分配較大的共享字節數組的片斷,而後將分配給緩衝區的每個片斷保存到一個列表。 就我的而言,我以爲第二種片斷方法略好一些,但差異不大。

經過向其添加單獨的數組或切片來增長緩衝區的優勢是在寫入期間不須要複製數據。 全部數據均可以直接從套接字(Channel)複製到數組或切片中。

以這種方式增加緩衝區的缺點是數據不存儲在單個連續的數組中。 這使得消息解析更加困難,由於解析器須要同時查找每一個單獨數組的末尾和全部數組的末尾。 因爲須要在寫入的數據中查找消息的結尾,所以該模型不易使用。

TLV 編碼消息

一些協議消息格式使用 TLV 格式(type,length,value)進行編碼。 這意味着,當消息到達時,消息的總長度存儲在消息的開頭,這樣就能夠當即知道爲整個消息分配多少內存。

TLV 編碼使得內存管理更容易,由於能夠知道要爲消息分配多少內存,不會存在只有部分被使用的緩衝區,因此沒有內存被浪費。

TLV 編碼的一個缺點是在消息的全部數據到達以前爲消息分配全部內存。 所以,發送大消息的一些慢鏈接能夠分配可用的全部內存,從而使服務器無響應。

此問題的解決方法是使用包含多個 TLV 字段的消息格式。 所以,爲每一個字段分配內存,而不是爲整個消息分配內存,而且僅在字段到達時分配內存。 可是,一個大字段可能會對內存管理產生與大消息相同的影響。

另外一種解決方法是對未收到的消息設置超時時間,例如 10-15 秒,這可使服務器從許多大的同時到達的消息中恢復過來,但它仍然會使服務器一段時間無響應。 此外,故意的 DoS(拒絕服務)攻擊仍然能夠致使服務器的內存被耗盡。

TLV 編碼存在不一樣的形式。實際使用字節數,指定字段類型和長度取決於每一個單獨的 TLV 編碼。 還有 TLV 編碼先放置字段的長度,而後是類型,而後是值(LTV編碼)。 雖然字段的順序不一樣,但它仍然是 TLV 變體。

實際上,TLV 編碼使內存管理更容易,是使得 HTTP 1.1 協議如此糟糕的緣由之一。 這也是爲何在HTTP2.0 中在數據傳輸時使用 TLV 來編碼幀的緣由。

寫入部分消息

在非阻塞 IO 管道中,寫入數據也是一個挑戰,在通道上調用 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 。

要檢查通道是否準備好寫入,可使用選擇器註冊通道。 可是,咱們不但願使用 Selector 註冊全部 Channel 實例。 想象一下,若是全部 100*10000 個通道都在 Selector 中註冊,而後調用 select() 時,大多數這些 Channel 實例都是可寫入的(它們大可能是空閒的,還記得嗎?),而後還必須檢查全部這些鏈接的 Message Writer 以查看它們是否有要寫入的數據。

爲了不檢查沒有數據須要寫入的通道的 Message Writer 實例,咱們使用這兩步方法:

  1. 當消息寫入消息編寫器時,消息編寫器將其關聯的 Channel 註冊到選擇器(若是還沒有註冊)。
  2. 當服務器有時間時,它會檢查選擇器以查看哪些已註冊的 Channel 實例已準備好進行寫入,對於每一個寫就緒通道,請求其關聯的消息編寫器將數據寫入通道。 若是 Message Writer 已經將其全部消息寫入了其 Channel ,則 Channel 將從 Selector 中註銷。

這樣,只有具備要寫入消息的 Channel 實例才能實際註冊到 Selector 。

總結

非阻塞服務器須要不時檢查傳入數據,以查看是否收到任何新的完整消息。 服務器可能須要屢次檢查,直到收到一條或多條完整消息,僅僅檢查一次是不夠的。

一樣,非阻塞服務器須要不時檢查是否有任何要寫入的數據。 若是是,則服務器須要檢查相應的鏈接是否已準備好寫入。 僅在第一次排隊消息時檢查是不夠的,由於開始的時候消息可能只是數據的一部分。

總而言之,非阻塞服務器最終須要按期執行三個「管道」:

  1. 讀取管道,用於檢查來自打開鏈接的新傳入數據。
  2. 處理管道,處理收到的任何完整消息的進程管道。
  3. 寫入管道,檢查是否能夠將傳出消息寫入打開的鏈接。

這三個管道在循環中重複執行,還可能稍微優化它們的執行。 例如,若是沒有排隊的消息,能夠跳過循環執行寫入管道。 或者,若是咱們沒有收到新的完整消息,也許可​​以跳過處理管道。

這是一個完整服務器循環示意圖:

若是仍然以爲這有點複雜,能夠查看 GitHub 倉庫:https://github.com/jjenkov/java-nio-server
也許看看代碼有助於幫助理解。

服務器線程模型

GitHub 存儲庫中的非阻塞服務器實現使用具備 2 個線程的線程模型。 第一個線程接受來自 ServerSocketChannel 的傳入鏈接。 第二個線程處理接受的鏈接,即讀取消息,處理消息和將響應寫回鏈接。 這個2線程模型以下所示:

相關文章
相關標籤/搜索