Netty(DotNetty)原理解析

好名字可以讓你的朋友更容易記住你發表於2019-07-23

一、背景介紹

DotNetty是微軟的Azure團隊,使用C#實現的Netty的版本釋出。不但使用了C#和.Net平臺的技術特點,並且保留了Netty原來絕大部分的程式設計介面。讓我們在使用時,完全可以依照Netty官方的教程來學習和使用DotNetty應用程式。

Netty 是一個非同步事件驅動的網路應用程式框架,用於快速開發可維護的高效能協議伺服器和客戶端。

二、NIO

他並不是 Java 獨有的概念,NIO代表的一個詞彙叫著IO多路複用。它是由作業系統提供的系統呼叫,早期這個作業系統呼叫的名字是select,但是效能低下,後來漸漸演化成了 Linux 下的epoll和Mac裡的kqueue。我們一般就說是epoll,因為沒有人拿蘋果電腦作為伺服器使用對外提供服務。而Netty就是基於Java NIO技術封裝的一套框架。為什麼要封裝,因為原生的Java NIO使用起來沒那麼方便,而且還有臭名昭著的bug,Netty把它封裝之後,提供了一個易於操作的使用模式和介面,使用者使用起來也就便捷多了。

 

說NIO之前先說一下BIO(Blocking IO),如何理解這個Blocking呢?

1.客戶端監聽(Listen)時,Accept是阻塞的,只有新連線來了,Accept才會返回,主執行緒才能繼

2.讀寫socket時,Read是阻塞的,只有請求訊息來了,Read才能返回,子執行緒才能繼續處理

3.讀寫socket時,Write是阻塞的,只有客戶端把訊息收了,Write才能返回,子執行緒才能繼續讀取下一個請求

4.傳統的BIO模式下,從頭到尾的所有執行緒都是阻塞的,這些執行緒就乾等著,佔用系統的資源,什麼事也不幹。

Netty 的非阻塞 I/O 的實現關鍵是基於 I/O 複用模型,這裡用 Selector 物件表示:

 

Netty 的 IO 執行緒 NioEventLoop 由於聚合了多路複用器 Selector,可以同時併發處理成百上千個客戶端連線。

當執行緒從某客戶端 Socket 通道進行讀寫資料時,若沒有資料可用時,該執行緒可以進行其他任務。

執行緒通常將非阻塞 IO 的空閒時間用於在其他通道上執行 IO 操作,所以單獨的執行緒可以管理多個輸入和輸出通道。

由於讀寫操作都是非阻塞的,這就可以充分提升 IO 執行緒的執行效率,避免由於頻繁 I/O 阻塞導致的執行緒掛起。

一個 I/O 執行緒可以併發處理 N 個客戶端連線和讀寫操作,這從根本上解決了傳統同步阻塞 I/O 一連線一執行緒模型,架構的效能、彈性伸縮能力和可靠性都得到了極大的提升。

基於Buffer

傳統的 I/O 是面向位元組流或字元流的,以流式的方式順序地從一個 Stream 中讀取一個或多個位元組, 因此也就不能隨意改變讀取指標的位置。

在 NIO 中,拋棄了傳統的 I/O 流,而是引入了 Channel 和 Buffer 的概念。在 NIO 中,只能從 Channel 中讀取資料到 Buffer 中或將資料從 Buffer 中寫入到 Channel。

基於 Buffer 操作不像傳統 IO 的順序操作,NIO 中可以隨意地讀取任意位置的資料。

事件驅動模型

通常,我們設計一個事件處理模型的程式有兩種思路:

  • 1.輪詢方式,執行緒不斷輪詢訪問相關事件發生源有沒有發生事件,有發生事件就呼叫事件處理邏輯。
  • 2.事件驅動方式,發生事件,主執行緒把事件放入事件佇列,在另外執行緒不斷迴圈消費事件列表中的事件,呼叫事件對應的處理邏輯處理事件。事件驅動方式也被稱為訊息通知方式,其實是設計模式中觀察者模式的思路。

事件機制,它可以用一個執行緒把Accept,讀寫操作,請求處理的邏輯全乾了。如果什麼事都沒得做,它也不會死迴圈,它會將執行緒休眠起來,直到下一個事件來了再繼續幹活,這樣的一個執行緒稱之為NIO執行緒。用虛擬碼表示:

while true {
    events = takeEvents(fds)  // 獲取事件,如果沒有事件,執行緒就休眠
    for event in events {
        if event.isAcceptable {
            doAccept() // 新連結來了
        } elif event.isReadable {
            request = doRead() // 讀訊息
            if request.isComplete() {
                doProcess()
            }
        } elif event.isWriteable {
            doWrite()  // 寫訊息
        }
    }
}複製程式碼

  

Reactor執行緒模型

Reactor單執行緒模型

一個NIO執行緒+一個accept執行緒:

 

由於Reactor模式使用的是非同步非阻塞IO,所有的IO操作都不會導致阻塞,理論上一個執行緒可以獨立處理所有IO相關的操作。從架構層面看,一個NIO執行緒確實可以完成其承擔的職責。例如,通過Acceptor類接收客戶端的TCP連線請求訊息,鏈路建立成功之後,通過Dispatch將對應的ByteBuffer派發到指定的Handler上進行訊息解碼。使用者執行緒可以通過訊息編碼通過NIO執行緒將訊息傳送給客戶端。

對於一些小容量應用場景,可以使用單執行緒模型。但是對於高負載、大併發的應用場景卻不合適,主要原因如下:

1)一個NIO執行緒同時處理成百上千的鏈路,效能上無法支撐,即便NIO執行緒的CPU負荷達到100%,也無法滿足海量訊息的編碼、解碼、讀取和傳送;

2)當NIO執行緒負載過重之後,處理速度將變慢,這會導致大量客戶端連線超時,超時之後往往會進行重發,這更加重了NIO執行緒的負載,最終會導致大量訊息積壓和處理超時,成為系統的效能瓶頸;

3)可靠性問題:一旦NIO執行緒意外跑飛,或者進入死迴圈,會導致整個系統通訊模組不可用,不能接收和處理外部訊息,造成節點故障。

 

Reactor多執行緒模型

 

Reactor多執行緒模型的特點:

1)有專門一個NIO執行緒-Acceptor執行緒用於監聽服務端,接收客戶端的TCP連線請求;

2)網路IO操作-讀、寫等由一個NIO執行緒池負責,執行緒池可以採用標準的JDK執行緒池實現,它包含一個任務佇列和N個可用的執行緒,由這些NIO執行緒負責訊息的讀取、解碼、編碼和傳送;

3)1個NIO執行緒可以同時處理N條鏈路,但是1個鏈路只對應1個NIO執行緒,防止發生併發操作問題。

在絕大多數場景下,Reactor多執行緒模型都可以滿足效能需求;但是,在極個別特殊場景中,一個NIO執行緒負責監聽和處理所有的客戶端連線可能會存在效能問題。例如併發百萬客戶端連線,或者服務端需要對客戶端握手進行安全認證,但是認證本身非常損耗效能。在這類場景下,單獨一個Acceptor執行緒可能會存在效能不足問題,為了解決效能問題,產生了第三種Reactor執行緒模型-主從Reactor多執行緒模型。

 

Reactor主從模型

主從Reactor執行緒模型的特點是:服務端用於接收客戶端連線的不再是個1個單獨的NIO執行緒,而是一個獨立的NIO執行緒池。Acceptor接收到客戶端TCP連線請求處理完成後(可能包含接入認證等),將新建立的SocketChannel註冊到IO執行緒池(sub reactor執行緒池)的某個IO執行緒上,由它負責SocketChannel的讀寫和編解碼工作。Acceptor執行緒池僅僅只用於客戶端的登陸、握手和安全認證,一旦鏈路建立成功,就將鏈路註冊到後端subReactor執行緒池的IO執行緒上,由IO執行緒負責後續的IO操作。

利用主從NIO執行緒模型,可以解決1個服務端監聽執行緒無法有效處理所有客戶端連線的效能不足問題。

它的工作流程總結如下:

  1. 從主執行緒池中隨機選擇一個Reactor執行緒作為Acceptor執行緒,用於繫結監聽埠,接收客戶端連線;
  2. Acceptor執行緒接收客戶端連線請求之後建立新的SocketChannel,將其註冊到主執行緒池的其它Reactor執行緒上,由其負責接入認證、IP黑白名單過濾、握手等操作;
  3. 步驟2完成之後,業務層的鏈路正式建立,將SocketChannel從主執行緒池的Reactor執行緒的多路複用器上摘除,重新註冊到Sub執行緒池的執行緒上,用於處理I/O的讀寫操作.

 

Netty可以基於如上三種模型進行靈活的配置。

總結

Netty是建立在NIO基礎之上,Netty在NIO之上又提供了更高層次的抽象。

在Netty裡面,Accept連線可以使用單獨的執行緒池去處理,讀寫操作又是另外的執行緒池來處理。

Accept連線和讀寫操作也可以使用同一個執行緒池來進行處理。而請求處理邏輯既可以使用單獨的執行緒池進行處理,也可以跟放在讀寫執行緒一塊處理。執行緒池中的每一個執行緒都是NIO執行緒。使用者可以根據實際情況進行組裝,構造出滿足系統需求的高效能併發模型。

相關文章