Java NIO學習筆記七 Non-blocking Server

來源:互聯網
上載者:User

標籤:put   郵件   get   style   ref   strong   網路通訊協定   學習筆記   超過   

Java NIO:Non-blocking Server

  即使你瞭解了Java NIO非阻塞功能的工作(怎麼樣SelectorChannel, Buffer等等),設計一個無阻塞伺服器仍然很難。非阻塞IO包含了相比阻塞IO的要有難度。本章非阻塞伺服器教程將討論非阻塞伺服器的主要挑戰,並為他們描述一些潛在的解決方案。

  本教程中描述的想法是圍繞Java NIO設計的。但是,我認為,只要有這樣的構造,這些想法可以用其他語言重複使用Selector。據我所知,這樣的結構是由底層作業系統提供的,所以很有可能您可以使用其他語言訪問此操作。

非阻塞伺服器 - GitHub存放庫

  我建立了一個簡單的概念驗證概念,在本教程中提出的想法,並將其放在GitRebu存放庫中,可供查看。這是GitHub資訊庫:

https://github.com/jjenkov/java-nio-server

非阻塞IO管道

  非阻塞IO管道是處理非阻塞IO組件的鏈。這包括以非阻塞方式讀寫IO。以下是簡化的非阻塞IO管道的流程圖:

  組件使用選取器來檢測通道何時讀取資料。然後組件讀取輸入資料,並根據輸入產生一些輸出。輸出Channel再次寫入。

  非阻塞IO管道不需要同時讀寫資料。一些管道只能讀取資料,而一些管道只能寫入資料。

  僅顯示單個組件。非阻塞IO管道可能具有多個組件進程傳入資料。非阻塞IO管道的長度取決於管道需要做什麼。

  非阻塞IO管道也可能同時從多個Channels 讀取。例如,從多個SocketChannels 讀取資料。

  中的控制流程程也簡化了。它是發起資料的讀出從該組件Channel經過Selector。事實不是圖中所展示的Channel將資料推Selector進入到組件中。

非阻塞與阻塞IO管道

  非阻塞和阻塞IO管道之間的最大區別是如何從底層Channel(通訊端或檔案)讀取資料 。

  IO管道通常從一些流(從通訊端或檔案)讀取資料,並將該資料分解成一致的訊息。這類似於將資料流打破使用標記器進行解析。相反,您將資料流分解成更大的訊息。我們把將流打破成訊息的組件稱為訊息讀取器

訊息閱讀器將流分成訊息的簡易流程圖:

  阻塞IO管道可以使用InputStream介面,其中一次可以從底層Channel讀取一個位元組,並且其中的InputStream介面處於阻塞狀態,直到有資料準備好讀取。這導致阻塞Message Reader實現。

  使用阻塞IO介面流大大簡化了Message Reader的實現。阻塞訊息讀取器不必處理資料流中沒有讀取資料的情況,或者僅部分訊息是從流中讀取,並且訊息需要稍後恢複解析。

  類似地,阻塞訊息寫入器(將訊息寫入流的組件)不必處理僅寫入部分訊息的,以及稍後需要恢複訊息寫入的情況。

阻止IO管道缺點

  雖然阻塞訊息閱讀器容易實現,但是對於需要分割成訊息的每個流需要單獨的線程是不幸的缺點。因此每個流的IO介面阻塞,直到有一些資料從中讀取是必要的。這意味著單個線程不能嘗試從一個流中讀取,如果沒有資料,則從另一個流中讀取。一旦線程嘗試從流中讀取資料,線程將阻塞,直到實際上有一些資料要讀取。

  如果IO管道是處理大量並發已連線的服務器的一部分,則伺服器每個活動進入串連的將需要一個線程。如果伺服器在任何時間只有幾個並發串連,這可能不是一個問題。但是,如果伺服器具有數百萬個並發串連,則這種類型的設計不會很好地擴充。每個線程的堆棧將佔用320K(32位JVM)和1024K(64位JVM)記憶體。所以,1000000線程將佔用1 TB記憶體!而在這之前,伺服器已經使用記憶體來處理傳入的訊息(例如,為訊息處理期間使用的對象分配的記憶體)。

  為了保持線程數量的減少,許多伺服器使用一種設計,其中伺服器保留一個線程池(例如100),從而一次從入站串連中讀取訊息。入站串連保留在隊列中,並且線程按入站串連放入隊列的順序處理來自每個入站串連的訊息。此設計如所示:

  但是,此設計要求入站串連能夠合理地發送資料。如果入站串連可能在較長時間內處於非使用中,則大量非活動串連實際上可能會阻塞線程池中的所有線程。這意味著伺服器響應遲緩,甚至無響應。

  一些伺服器設計嘗試通過線上程池中的線程彈性數量數量來緩解這個問題。例如,如果線程池用盡線程,則線程池可能會啟動更多的線程來處理負載。這方案意味著需要更多數量的慢速連線才能使伺服器無響應。注意,您可以運行多少線程仍然有上限。所以,這將不會適應於1.000.000慢速連線。

基本無阻塞IO管道設計

  非阻塞IO管道可以使用單個線程來讀取多個流的訊息。這要求流可以切換到非阻塞模式。當處於非阻塞模式時,如果你嘗試從其讀取資料時,流可能返回0個或更多位元組。如果流沒有要讀取的資料,則返回0個位元組。當流實際上有一些要讀取的資料時,返回1+位元組。

  為了避免檢查有0個位元組的流,我們使用Java NIO選取器。一個或多個SelectableChannel執行個體可以用Selector註冊。當調用select()selectNow()在其上Selector只給出SelectableChannel執行個體,實際上有資料讀取的。此設計如所示:

閱讀部分訊息

  當我們從一個SelectableChannel資料區塊中讀取資料時,我們不知道該資料區塊是否包含少於或者多於一個訊息。資料區塊可能潛在地包含一個部分訊息(小於一條訊息),一條完整訊息,或者多於一條訊息,例如1.5或2.5條訊息。各種部分訊息的可能性如下所示:

處理部分訊息有兩個痛點:

  A.檢測資料區塊中是否有完整訊息。

  B.在部分訊息到達訊息的其餘部分之前應該如何處理。

  檢測完整訊息要求訊息讀取器查看資料區塊中的資料,查看資料是否至少包含一條完整訊息。如果資料區塊包含一個或多個完整訊息,則可以將這些訊息發送到管道中進行處理。尋找完整資訊的過程將會重複,所以這個過程必須儘可能快。

  無論何時在資料區塊中存在部分訊息,無論是本身還是在一個或多個完整訊息之後,需要儲存該部分訊息,直到該訊息的其餘部分從該訊息到達Channel

  檢測完整訊息和儲存部分訊息都是訊息讀取器的責任。為了避免混淆來自不同Channel執行個體的訊息資料,我們將使用一個Message Reader Channel。設計如下所示:

  在檢索到Channel具有要從中讀取的資料的執行個體之後,與之相關聯Selector的訊息讀取器Channel讀取資料並嘗試將其分解成訊息。如果這樣導致任何完整的訊息被讀取,這些訊息可以被傳遞到讀取流水線到需要處理它們的任何組件。

  訊息閱讀器當然是協議特定的。訊息讀取器需要知道其嘗試讀取的訊息的訊息格式。如果我們的伺服器實現可以通過協議重複使用,則需要能夠將訊息讀取器實現插入 - 可能通過以某種方式接受訊息讀取器工廠作為配置參數。

儲存部分訊息

  現在我們已經確定訊息讀取器有責任儲存部分訊息,直到收到完整的訊息,我們需要弄清楚這個部分訊息儲存應該如何?。

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

  1.我們要儘可能少地複製資訊資料。拷貝越多,效能越差。

  2.我們希望將完整的訊息儲存在連續的位元組序列中,使解析訊息更容易。

每個訊息讀取器的緩衝區

  顯然,部分訊息需要儲存在緩衝器中。簡單的實現將是在每個訊息讀取器中內部簡單地具有一個緩衝區。但是,緩衝區應該有多大?它將需要足夠大,以便能夠儲存甚至最大的允許的訊息。所以,如果最大的允許訊息是1MB,那麼每個訊息讀取器中的內部緩衝區將需要至少為1MB。

  當我們達到數以百萬計的串連時,每個串連使用1MB並不會奏效。1.000.000 x 1MB還是1TB記憶體!如果最大郵件大小是16MB呢?或者128MB?

可調整緩衝區

  另一個選擇是實現一個可調整大小的緩衝區用於每個訊息讀取器內。一個可調整大小的緩衝區將開始小,如果一個訊息對於緩衝區太大,緩衝區將被擴充。這樣一來,每個串連不一定需要一個例如1MB的緩衝區。每個串連只需要佔用大量記憶體,因為它們需要儲存下一條訊息。  

  實現緩衝區的可調我們將在後面的幾章討論.

通過複製調整大小

  實現可調整大小的緩衝區的第一種方法是從一個例如4KB的小緩衝區開始。如果訊息不能適應4KB緩衝區,則可以分配更大的緩衝區,例如8KB,並將來自4KB緩衝區的資料複製到較大的緩衝區中。

通過複製調整大小的優缺點:

  優點:是訊息的所有資料都儲存在單個連續的位元組數組中。這使得解析訊息更容易。

  缺點:是它會導致大量的資料複製用於更大的訊息。

為了減少資料複製,您可以分析流過系統的訊息的大小,以找到一些可以減少複製量的緩衝區大小。例如,您可能會看到大多數訊息都小於4KB,因為它們只包含非常小的請求/響應。這意味著第一個緩衝區大小應該是4KB。

  那麼你可能會看到,如果一條訊息大於4KB,那通常是因為它包含一個檔案。您可能會注意到,大部分流經系統的檔案都不到128KB。那麼使第二個緩衝區大小為128KB是有意義的。

  最後你可能會看到,一旦一個訊息高於128KB,訊息的大小就沒有真正的模式,所以也許最後的緩衝區大小應該是最大的訊息大小。

  根據流經系統的訊息大小,這3種緩衝區大小可以減少資料複製。4KB以下的訊息永遠不會被複製。對於1.000.000並發串連,導致1.000.000 x 4KB = 4GB,這在今天(2015年)的大多數伺服器中是可能的。4KB和128KB之間的訊息將被複製一次,只有4KB資料將被複製到128KB緩衝區。128KB和最大郵件大小之間的郵件將被複製兩次。第一次4KB將被複製,第二次128KB將被複製,所以總共132KB複製最大的訊息。假設沒有超過128KB的這麼多資訊可能是可以接受的。

  一旦訊息完全處理完畢,分配的記憶體應該再次被釋放。這樣,從同一個串連接收到的下一條訊息將以最小的緩衝區大小再次開始。這是必要的,以確保在串連之間可以更有效地共用記憶體。很可能並不是所有的串連都將同時需要大的緩衝區。

調整大小追加

  調整緩衝區大小的另一種方法是使緩衝區由多個數組組成。當您需要調整緩衝區大小時,您只需分配另一個位元組數組並將資料寫入該數組。

  有兩種方式來增長這樣一個緩衝區。一種方法是分配單獨的位元組數組並保留這些位元組數組的列表。另一種方法是分配較大的共用位元組數組的片段,然後保留分配給緩衝區的片段的列表。就個人而言,我覺得切片方法稍好些,但差別很小。

  通過在其中附加單獨的數組或片來增加緩衝區的優點是在寫入過程中不需要複製資料。所有資料可以直接從通訊端(Channel)直接複製到數組或片中。

  以這種方式生長緩衝區的缺點是資料不儲存在單個連續陣列中。這使得訊息解析更加困難,因為解析器需要同時尋找每個單獨數組的末尾和所有數組的結束。由於您需要在寫入的資料中尋找訊息的結尾,所以該模型並不容易使用。

TLV編碼訊息

  一些協議訊息格式使用TLV格式(類型、長度、值)進行編碼。這意味著當訊息到達時,訊息的總長度被儲存在訊息的開頭。這樣你就可以立即知道為整個訊息分配多少記憶體。

  TLV編碼使記憶體管理更加容易。您立即知道要為訊息分配多少記憶體。只有部分使用的緩衝區結束時才會浪費記憶體。

  TLV編碼的一個缺點是在訊息的所有資料到達之前分配訊息的所有記憶體。因此,發送大郵件的慢速連線可以分配所有可用的記憶體,從而使您的伺服器無響應。

  此問題的解決方案是使用包含多個TLV欄位的訊息格式。因此,為每個欄位分配儲存空間,而不是為整個訊息分配儲存空間,並且僅當欄位到達時才分配儲存空間。然而,一個大的欄位可以對你的記憶體管理具有相同的效果,作為一個大的訊息。

  另一個解決方案是逾時在10-15秒內未收到的訊息。這可以使您的伺服器從巧合,同時到達許多大訊息恢複,但仍會使伺服器反應遲一段時間。另外,有意的DoS(拒絕服務)攻擊仍然可以為您的伺服器完全分配記憶體。

  TLV編碼存在不同的變體。正是使用了多少位元組,因此指定欄位的類型和長度取決於每個單獨的TLV編碼。還有TLV編碼首先放置欄位的長度,然後是類型,然後是值(一個LTV編碼)。雖然欄位的順序不同,但它仍然是TLV變體。

  TLV編碼使記憶體管理更容易的事實是HTTP 1.1是如此可怕的協議的原因之一。這是他們試圖在HTTP 2.0中修複資料的問題之一,資料在LTV編碼幀中傳輸。這也是為什麼我們為 使用TLV編碼的VStack.co項目設計了我們自己的網路通訊協定。

寫部分訊息

  在非阻塞IO管道中寫入資料也是一個挑戰。當您 以非阻塞模式調用write(ByteBuffer)Channel,不能保證ByteBuffer正在寫入的位元組數。該write(ByteBuffer)方法返回寫入多少個位元組,因此可以跟蹤寫入的位元組數。這就是挑戰:跟蹤部分寫入的訊息,以便最終發送一條訊息的所有位元組。

  為了管理部分訊息的寫入,Channel我們將建立一個Message Writer。就像訊息閱讀器一樣,我們將需要一個Message Writer,每個Channel我們寫資訊。在每個Message Writer中,我們跟蹤正在寫入的訊息的位元組數。

  如果訊息寫入器的更多訊息到達可以直接寫入到訊息寫入器Channel的訊息,訊息需要在訊息寫入器內部排隊。訊息寫入器然後將訊息儘可能快地寫入Channel

  這是一個圖表,顯示了部分訊息寫作到目前為止的設計:

  要使Message Writer能夠發送僅部分早期發送的訊息,則需要不時調用Message Writer,因此可以發送更多資料。

  如果你有很多的串連,你將會有很多的Message Writer執行個體。檢查例如一百萬個Message Writer執行個體,看看他們是否可以寫任何資料都很慢。首先,許多Message Writer執行個體很多都沒有任何訊息要發送。我們不想檢查那些Message Writer執行個體。其次,並不是所有的Channel 執行個體都可以準備好寫入資料。我們不想浪費時間嘗試將資料寫入Channel 不能接受任何資料的資料。

  要檢查是否Channel準備好寫作,您可以使用a註冊頻道Selector。但是,我們不想用所有Channel執行個體註冊Selector。想象一下,如果您有1.000.000個串連,大多是閒置,並且所有的1.000.000個串連都登入Selector。那麼當你打電話時,select()大部分這些Channel 執行個體都會被寫好(他們大都閑著,記得嗎?)。然後,您必須檢查所有這些串連的Message Writer,以查看他們是否有任何資料要寫入。

為了避免檢查訊息的所有Message Writer執行個體,以及所有Channel執行個體,無論如何都不會發送任何訊息,我們使用這兩步的方法:

  1. 當訊息寫入訊息寫入器時,訊息寫入器註冊它Channel 與Selector(如果尚未註冊)相關聯。 

  2. 當您的伺服器有時間時,它會檢查Selector哪些註冊的Channel 執行個體已準備好進行寫入。對於每個寫入就緒,Channel它的相關訊息寫入器被請求寫入資料Channel。如果一個訊息寫入器將其所有訊息寫入其中ChannelChannel則從另一個訊息 中登出Selector

這個小的兩步方法確保只有Channel具有寫入訊息的執行個體實際上已被註冊Selector

總結: 

  非阻塞伺服器需要不時檢查傳入的資料,以查看是否收到新的完整訊息。伺服器可能需要多次檢查,直到收到一個或多個完整的訊息。一次檢查是不夠的。

  同樣,非阻塞伺服器需要不時檢查是否有任何資料要寫入。如果是,伺服器需要檢查是否有任何相應的串連準備好將該資料寫入它們。只有在第一次排隊訊息時才檢查是不夠的,因為訊息可能被部分寫入。

  所有這些非阻塞伺服器最終都需要定期執行的三個“管道”:

  • 讀取管道,用於從開啟的串連檢查新的傳入資料。
  • 處理任何收到的完整訊息的進程流程。
  • 寫入流水線檢查是否可以將任何傳出的訊息寫入任何開啟的串連。

  這三條管道在迴圈中重複執行。您可能可以稍微最佳化執行。例如,如果沒有排隊的訊息可以跳過寫入管道。或者,如果我們沒有收到新的,完整的訊息,也許您可??以跳過流程管道。

  以下是說明完整伺服器迴圈的圖:

如果仍然發現這有點複雜,請記住查看GitHub資料庫:

https://github.com/jjenkov/java-nio-server

伺服器執行緒模式

GitHub存放庫中的非阻塞伺服器實現使用具有2個線程的執行緒模式。第一個線程接受來自a的傳入串連ServerSocketChannel。第二個線程處理接受的串連,意思是讀取訊息,處理訊息並將響應寫回串連。這個2執行緒模式如下所示:

 

Java NIO學習筆記七 Non-blocking Server

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.