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

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

Non-blocking Server

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

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

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

非阻塞 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線程模型如下所示:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章