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 讀取資料塊時,我們不知道該資料塊是否包含了一條完整的訊息,可能的情況有:比一條訊息少、一條完整訊息、比一條訊息多,如下圖:
處理上述情況有兩個挑戰:
- 檢測資料塊中訊息完整性;
- 在訊息的其餘部分到達之前,已收到的部分訊息如何處理;
檢測完整訊息要求訊息讀取器檢視資料塊中的資料是否包含至少一個完整訊息。 如果資料塊包含一個或多個完整訊息,則可以沿管道傳送這些訊息以進行處理。 這個步驟將重複很多次,因此這個過程必須儘可能快。
每當資料塊中存在部分訊息時,無論是單獨訊息還是在一個或多個完整訊息之後,都需要儲存該部分訊息,直到該訊息的其餘部分到達。
檢測完整訊息和儲存部分訊息都是 Message Reader 的職責。 為區分來自不同 Channel 的訊息資料,需要為每個 Channel 使用一個 Message Reader 。 設計看起來像這樣:
檢索具有要從選擇器讀取的資料的通道例項後,與該通道關聯的訊息讀取器讀取資料並嘗試將其分解為訊息。如果有任何完整的訊息被讀取,則可以將這些訊息沿讀取管道傳遞給需要處理它們的任何元件。
一個訊息閱讀器當然是針對特定協議的。 訊息讀取器需要知道它嘗試讀取的訊息的訊息格式。 如果我們的伺服器實現可以跨協議重用,則需要能夠插入Message Reader 實現 ---- 可能通過以某種方式接受 Message Reader 工廠作為配置引數。
儲存部分訊息
既然我們已經確定訊息閱讀器負責儲存部分訊息,直到收到完整的訊息,我們需要弄清楚應該如何實現部分訊息的儲存。
應該考慮兩個設計考慮因素:
- 儘可能少地複製訊息資料。 複製越多,效能越低。
- 將完整的訊息儲存在連續的位元組序列中,使解析訊息更容易。
每個訊息讀取器的緩衝區
顯然,部分訊息需要儲存在某寫緩衝區中。 簡單的實現是在每個 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 例項,我們使用這兩步方法:
- 當訊息寫入訊息編寫器時,訊息編寫器將其關聯的 Channel 註冊到選擇器(如果尚未註冊)。
- 當伺服器有時間時,它會檢查選擇器以檢視哪些已註冊的 Channel 例項已準備好進行寫入,對於每個寫就緒通道,請求其關聯的訊息編寫器將資料寫入通道。 如果 Message Writer 已經將其所有訊息寫入了其 Channel ,則 Channel 將從 Selector 中登出。
這樣,只有具有要寫入訊息的 Channel 例項才能實際註冊到 Selector 。
總結
非阻塞伺服器需要不時檢查傳入資料,以檢視是否收到任何新的完整訊息。 伺服器可能需要多次檢查,直到收到一條或多條完整訊息,僅僅檢查一次是不夠的。
同樣,非阻塞伺服器需要不時檢查是否有任何要寫入的資料。 如果是,則伺服器需要檢查相應的連線是否已準備好寫入。 僅在第一次排隊訊息時檢查是不夠的,因為開始的時候訊息可能只是資料的一部分。
總而言之,非阻塞伺服器最終需要定期執行三個“管道”:
- 讀取管道,用於檢查來自開啟連線的新傳入資料。
- 處理管道,處理收到的任何完整訊息的程序管道。
- 寫入管道,檢查是否可以將傳出訊息寫入開啟的連線。
這三個管道在迴圈中重複執行,還可能稍微優化它們的執行。 例如,如果沒有排隊的訊息,可以跳過迴圈執行寫入管道。 或者,如果我們沒有收到新的完整訊息,也許可以跳過處理管道。
這是一個完整伺服器迴圈示意圖:
如果仍然覺得這有點複雜,可以檢視 GitHub 倉庫:https://github.com/jjenkov/java-nio-server
也許看看程式碼有助於幫助理解。
伺服器執行緒模型
GitHub 儲存庫中的非阻塞伺服器實現使用具有 2 個執行緒的執行緒模型。 第一個執行緒接受來自 ServerSocketChannel 的傳入連線。 第二個執行緒處理接受的連線,即讀取訊息,處理訊息和將響應寫回連線。 這個2執行緒模型如下所示: