從 I/O 模型到 Netty(三)

Alchemist發表於2017-04-16

從 I/O 模型到 Netty(三)
Netty

零、寫在前面

本文雖然是講Netty,但實際更關注的是Netty中的NIO的實現,所以對於Netty中的OIO(Old I/O)並沒有做過多的描述,或者說根本隻字未提,所以本文中所述的所有實現細節都是基於NIO版本的。

Netty作為一個已經發展了十多年的框架,已然非常成熟了,其中有大量的細節是普通使用者不知道或者不關心的,所以本文難免有遺漏或者紕漏的地方,如果你發現了請告知。

本文不涉及Netty5的部分。

雖然這一節叫「寫在前面」,但實際上上最後寫的。

一、零拷貝

Netty4和Netty3中的buffer包裡的類有很大的區別,但提供的特性大致相同,其中很重要的一個是提供了「零拷貝」的特性。

在處理請求或生成回覆時,往往要使用已有資料,並對之進行擷取、拼接等操作。假設現在要進行一個拼接字串(bytes1bytes2)的操作,如果使用Java NIO中的java.nio.ByteBuffer類的話,我們把它假設成一個byte陣列(其底層真正的儲存也是如此),往往要生成一個更大的byte陣列newBytes,然後將bytes1bytes2分別複製到newBytes的地址中去——其實newBytes中的資料已經都存在記憶體中了,只是分屬不同的陣列,儲存中不連續的記憶體上而已——這麼做需要在記憶體中做額外的拷貝。

而使用Netty中的Buffer類(如io.netty.buffer.ByteBuf)的話,則不會生成新的newBytes陣列,而是生成一個新的物件指向原來的兩個陣列bytes1bytes2

從 I/O 模型到 Netty(三)
新的Buffer中使用了指向原來陣列記憶體的指標

並不是說Java NIO中的這種拷貝的策略不好,拋開場景去談效能是沒有意義的。

Netty3中零拷貝的API和Netty4不盡相同,但實現原理是一樣的,這裡拿Netty4來舉例,程式碼1中對使用Java NIO的java.nio.ByteBuffer和Netty4的io.netty.buffer.ByteBuf拼接資料進行對比。

//程式碼1
public static void main(String[] args) {
    byte[] byte1 = "he     ".getBytes();
    byte[] byte2 = "llo     ".getBytes();

    ByteBuffer b1 = ByteBuffer.allocate(10);
    b1.put(byte1);
    ByteBuffer b2 = ByteBuffer.allocate(10);
    b2.put(byte2);
    ByteBuffer b3 = ByteBuffer.allocate(20);
    ByteBuffer[] b4 = {b1, b2};     #1
    b3.put(b1.array());
    b3.put(b2.array());             #2
    //讀取內容
    System.out.println(new String(b3.array()));
    System.out.println("b1 addr:" + b1.array());
    System.out.println("b2 addr:" + b2.array());
    System.out.println("b3 addr:" + b3.array());

    ByteBuf nb1 = Unpooled.buffer(10);
    nb1.writeBytes(byte1);
    ByteBuf nb2 = Unpooled.buffer(10);
    nb2.writeBytes(byte2);
    //        nb2.array();
    ByteBuf nb3 = Unpooled.wrappedBuffer(nb1, nb2);
    nb3.array();                    #3
    //讀取內容                       #4
    byte[] bytes = new byte[20];
    for(int i =0; i< nb3.capacity(); i++) {
        bytes[i] = nb3.getByte(i);
    }
    System.out.println(new String(bytes));

}
輸出:
he     llo     
b1 addr:[B@4aa298b7
b2 addr:[B@7d4991ad
b3 addr:[B@28d93b30
he     llo   複製程式碼

可以看到,如果使用java.nio.ByteBuffer進行拼接,需要在#2的地方進行陣列記憶體的拷貝,為了進一步提高這種場景下的系統效能,在使用Unpooled.wrappedBuffer(ByteBuf... buffers)進行拼接時並沒有進行記憶體的拷貝,所以會在#3的地方丟擲UnsupportedOperationException異常,因為此時b3中已經沒有一個陣列是儲存自身實際內容了,Unpooled.wrappedBuffer返回的物件是io.netty.buffer.CompositeByteBuf.CompositeByteBuf的例項(具體邏輯見程式碼2)。

//程式碼2
public static ByteBuf wrappedBuffer(int maxNumComponents, ByteBuf... buffers) {
    switch (buffers.length) {
    case 0:
        break;
    case 1:
        ByteBuf buffer = buffers[0];
        if (buffer.isReadable()) {
            return wrappedBuffer(buffer.order(BIG_ENDIAN));
        } else {
            buffer.release();
        }
        break;
    default:
        for (int i = 0; i < buffers.length; i++) {
            ByteBuf buf = buffers[i];
            if (buf.isReadable()) {
                return new CompositeByteBuf(ALLOC, false, maxNumComponents, buffers, i, buffers.length);
            }
            buf.release();
        }
        break;
    }
    return EMPTY_BUFFER;
}複製程式碼

當然你也可以使用程式碼1中#1行的方法,建立一個ByteBuffer的陣列,由於Java的陣列中使用「引用」來指向其成員物件,這樣就防止了記憶體拷貝,但這會帶來另一個問題,在進行拼接之後,其結果是一個「ByteBuffer陣列」而非「ByteBuffer物件」,這樣會給實際程式設計帶來很多不便。而使用ByteBuf的拼接則能在返回一個ByteBuf物件的同時又防止了記憶體拷貝,這就是Netty中所謂的零拷貝。

同時,更多的Netty中自定義的這些Buffer類可以帶來的好處如下:

  1. 根據這些類你可以自定義自己的Buffer類。
  2. 透明的零拷貝實現。
  3. ByteBuf提供了很多開箱即用的「訪問特定型別位元組陣列(如getChar(int index))」特性。
  4. 不需要每次都呼叫flip()來轉換讀與寫。
  5. ByteBuffer的效能要更好(初始化時不寫0,不用GC)。

二、垃圾回收(GC)

你可能已經發現了,上一節中舉得例子(要在拼接字串的時候,得到一個同一型別——此處假設為Aclass——的物件,且實現零記憶體拷貝)中,完全可以在類Aclass中定義一個ByteBuffer陣列,然後再增加對於Aclass中陣列索引到這個ByteBuffer陣列中的索引對映就好了。實際上,Netty中也是這麼實現的。

//程式碼3
//org.jboss.netty.buffer.CompositeChannelBuffer.getByte(int)的實現
public byte getByte(int index) {
    //找到相應的子物件
    int componentId = componentId(index);
    //返回子物件中的位元組陣列中的相應位元組
    return components[componentId].getByte(index - indices[componentId]);
}複製程式碼

而Netty中關於ByteBuf更有爭議的部分在於,在ByteBuf的記憶體的管理上,它實現了自己的物件池。

自定義物件池,To be or not be

Netty的物件池是基於ThreadLocal的,所以執行緒與執行緒之間的池是不相關的。

Netty做了這麼複雜的事情想優化記憶體的使用,以至於在Netty4中又進一步引入了自定義的物件池。在這個池中,Netty實現了自己的記憶體管理(分配和釋放)。按照Netty文件上的說法,在處理網路事件時,往往需要在短時間內分配大量的、生命週期很短的物件,而如果要等待JVM的GC來回收這些物件,速度會很慢,同時GC本身也是要消耗資源的。

熟悉垃圾回收演算法的朋友對於「引用計數」一定不陌生,iOS的Runtime中垃圾回收使用的就是引用計數,它是一種更高效、更原始的垃圾回收方法。高效體現在它的記憶體回收更「實時而直接」,原始體現在你需要在程式中顯式地對引用計數進行增加和減少。當一個物件的引用計數降為0,則其記憶體會被回收。這種方式在手機這種「資源相而言更緊張」的裝置上會帶來很好的效能表現。

而JVM中採用的是「基於分代垃圾回收的構建引用樹」的方法,所有不在這棵樹上的物件則可回收,可想而知,構建這棵樹本身就會消耗一定的資源,另外「分代垃圾回收」較「引用計數」也更復雜。

對於某些有這種需求(短時間內分配大量的、生命週期很短)的物件,Netty中使用引用計數來管理這些物件的分配和釋放。具體的方法大致如下:

  1. 首先Netty在JVM堆上申請一塊較大的記憶體。
  2. Netty的一直儲存著指向這塊記憶體中物件的引用,使得JVM的GC不去回收這塊記憶體。
  3. 當在Netty中需要申請一塊生命週期較短的物件時(如ByteBuf),其真實記憶體就放入這塊記憶體,同時維護一個這個物件的引用計數,在Netty中其初始值為1。
  4. 當某個物件的引用計數降為0時,將這塊記憶體標識為可用。

應用程式構建自己的記憶體池的做法是有爭議的,往往會帶來記憶體洩漏的結果,也不能獲得JVM的GC演算法帶來的好處。但Netty的記憶體池已經證明,合理的使用記憶體池能夠帶來更好的效能。

直接I/O(Direct I/O),To be or not to be

在介紹I/O模型「從I/O模型到Netty(一)」時,就提到過Direct I/O,它帶來的好處是在做I/O操作時,不需要把記憶體從使用者空間拷貝到核心空間,節省了一部分資源,但在JVM的環境中申請Direct I/O要比在堆上分配記憶體消耗更多的效能。而利用Netty的物件池,剛好可以抵消這部分消耗,由池管理的Direct I/O的記憶體分配節省了GC的消耗。

有些地方會使用「零拷貝」來指代Direct I/O相對於Buffered I/O省去的那次拷貝(在使用者空間和核心空間之間進行拷貝)

三、事件模型

假設在某種場景下,整個程式的目的都是處理單一的事情(比如一個web伺服器的目的只是處理請求),我們可以將「與處理請求無關」的邏輯封裝到一個框架內,在每次請求處理完後,都執行一次事件的分發和處理,這就是event loop了。很多語言中都有這種概念,如nodejs中的event loop,iOS中的run loop。

這是在「從I/O模型到Netty(一)」中提到過的EventLoop的概念,在Netty4中,則真正實現了這樣一個概念。在Netty4的類裡赫然能看到EventLoop的介面,但Netty4裡的EventLoop和其他語言中Runtime級別的EventLoop還是有很大的區別的,其更像是一個執行預定義佇列中任務的執行緒(繼承自java.util.concurrent.ScheduledExecutorService,看下圖中EventLoop的繼承結構。

從 I/O 模型到 Netty(三)
EventLoop介面的繼承結構

其中,io.netty.channel.SingleThreadEventLoop比較重要,從它的名字就能看出來,它指的是單個執行緒的EventLoop,在Netty4的事件模型中,每一個EventLoop都有一個分配的執行緒,所有的I/O操作(也會使用事件進行傳遞)和事件的處理都是在這個執行緒中完成的。其執行邏輯如下圖所示:

在本文之後的內容中有時候會不區分「EventLoop、EventExecutor和執行緒」,「EventExecutorGroup和執行緒池」的概念

從 I/O 模型到 Netty(三)
事件在EventLoop中的執行邏輯

程式碼4中是上圖中邏輯的實現程式碼。

//程式碼4
@Override
public void execute(Runnable task) {
    if (task == null) {
        throw new NullPointerException("task");
    }

    boolean inEventLoop = inEventLoop();
    if (inEventLoop) {
        addTask(task);
    } else {
        startThread();
        addTask(task);
        if (isShutdown() && removeTask(task)) {
            reject();
        }
    }

    if (!addTaskWakesUp && wakesUpForTask(task)) {
        wakeup(inEventLoop);
    }
}複製程式碼

四、執行緒模型

執行緒模型直接反應了一個程式在執行時是如何去「分配和執行任務」的。對於Netty而言,其基本的執行緒模型可以理解為之前介紹到的Reactor模型,只是在其之上做了一些擴充套件。比如說在服務端,最重要的I/O事件應該算是連線請求(CONNECT)事件了,它直接關係到了服務端程式的吞吐量,所以在Netty的執行緒模型中就設計了一個單獨的執行緒去處理這個請求,其基本模型如下圖所示:

從 I/O 模型到 Netty(三)
改進的Reactor模型

由於篇幅所限,本文討論的執行緒模型將只關注服務端,客戶端當然也同樣重要。

Netty中的事件流

簡單來說,Netty中的管道(ChannelPipe)可以認為就是一個Handler的容器,裡邊存放了兩種EventHandler(io.netty.channel.ChannelInboundHandlerio.netty.channel.ChannelOutboundHandler)。一個網路請求從建立連線開始到得到回覆的過程,就是在這個管道中流入然後流出的過程,Netty的文件中是這麼描述管道的:

                                                 從Channel或者
                                            ChannelHandlerContext
                                                 而來的I/O請求
                                                      |
  +---------------------------------------------------+---------------+
  |                           ChannelPipeline         |               |
  |                                                  \|/              |
  |    +---------------------+            +-----------+----------+    |
  |    | Inbound Handler  N  |            | Outbound Handler  1  |    |
  |    +----------+----------+            +-----------+----------+    |
  |              /|\                                  |               |
  |               |                                  \|/              |
  |    +----------+----------+            +-----------+----------+    |
  |    | Inbound Handler N-1 |            | Outbound Handler  2  |    |
  |    +----------+----------+            +-----------+----------+    |
  |              /|\                                  .               |
  |               .                                   .               |
  | ChannelHandlerContext.fireIN_EVT() ChannelHandlerContext.OUT_EVT()|
  |        [ method call]                       [method call]         |
  |               .                                   .               |
  |               .                                  \|/              |
  |    +----------+----------+            +-----------+----------+    |
  |    | Inbound Handler  2  |            | Outbound Handler M-1 |    |
  |    +----------+----------+            +-----------+----------+    |
  |              /|\                                  |               |
  |               |                                  \|/              |
  |    +----------+----------+            +-----------+----------+    |
  |    | Inbound Handler  1  |            | Outbound Handler  M  |    |
  |    +----------+----------+            +-----------+----------+    |
  |              /|\                                  |               |
  +---------------+-----------------------------------+---------------+
                  |                                  \|/
  +---------------+-----------------------------------+---------------+
  |       [ Socket.read() ]                    [ Socket.write() ]     |
  +-------------------------------------------------------------------+複製程式碼

簡單說,就是一個事件在管道里的順序是從第一個Inbound的Handler開始,執行到最後一個,當一個Outbound事件發生時,它是從相反的方向執行到第一個。

實際的實現是這樣的,管道可以被認為是一個有序的Handler的序列(連結串列,見程式碼5),當一個Inbound事件發生時,它會從序列的最頭部依次通過每一個Handler,如果這個Handler是Inbound型別,那麼就被執行,否則依次往後。當一個Outbound事件發生時,它會從這個序列的當前位置開始執行,判斷是否是Outbound型別,直至最頭部。

//程式碼5
@Override
public final ChannelPipeline addLast(EventExecutorGroup group, String name, ChannelHandler handler) {
    final AbstractChannelHandlerContext newCtx;
    synchronized (this) {
        checkMultiplicity(handler);

        newCtx = newContext(group, filterName(name, handler), handler);

        addLast0(newCtx);
        。。。省略一些別的程式碼
    }
}
。。。省略一些別的程式碼
private void addLast0(AbstractChannelHandlerContext newCtx) {
    AbstractChannelHandlerContext prev = tail.prev;
    newCtx.prev = prev;
    newCtx.next = tail;
    prev.next = newCtx;
    tail.prev = newCtx;
}複製程式碼

每一個事件往管道的更深處傳送需要Handler自身顯式觸發。

Netty3中的管道

在Netty3裡,Inbound和Outbound的概念分別叫Upstream和Downstream,從上一節的圖中能很清晰的看出來,一個往上,一個往下。

如果在上邊所說的某一個Handler中包含一個很耗時的操作,那麼處理I/O的執行緒就會造成阻塞,導致這個執行緒遲遲不能被回收並用以處理新的請求,所以在Netty3中引入了除I/O執行緒池之外的另一個執行緒池來處理業務邏輯。使用者可以通過org.jboss.netty.handler.execution.ExecutionHandler來實現自己的業務執行緒池,它同時實現了UpstreamHandler和DownstreamHandler。

ExecutionHandler的引入給Netty帶來很多問題,本來Netty一直秉持著I/O處理序列化(一個事件只被一個執行緒處理)的理念,但是在ExecutionHandler的場景下則會有多個執行緒參與到這個事件的處理中來,同時也增加了開發的複雜度,使用者需要關心額外的多執行緒程式設計的東西。

Netty3中還有一個介面org.jboss.netty.channel.ChannelSink用來提供統一的「把Downstream寫入底層」的API,在Netty4中已經看不到了。

Netty4的執行緒模型

我在自己的電腦上執行Netty4中自帶的Echo的例子,使用VisualVM檢視其執行緒列表,截圖如下:

從 I/O 模型到 Netty(三)
Netty4中自帶的Echo服務端

然後依次啟動10個client對這個server進行連線,截圖如下:

從 I/O 模型到 Netty(三)
同時連線10個客戶端

本文中所有的講述都是基於NIO的,所以這裡看到啟動的執行緒池是io.netty.channel.nio.NioEventLoopGroup的例項,其繼承了類io.netty.util.concurrent.DefaultThreadFactory,Netty4中不再需要使用Java的執行緒池,所以執行緒的名稱和Netty3中有些不同,大致的規則是執行緒的名稱為<執行緒池的類名(第一個字母小寫)>-<執行緒池啟動的順序>-<執行緒啟動的順序>

可以看到Netty執行起來之後有這樣幾個執行緒:

  1. 一些無關的執行緒:JDK的執行緒,網路連線的執行緒,JMX的執行緒
  2. 1個Boss執行緒(nioEventLoopGroup-2-1)
  3. 8個Worker執行緒(nioEventLoopGroup-3-*)

一個小的細節,從上圖可以看到Boss執行緒池是第二個被例項化的,其實還有一個執行緒池GlobalEventExecutor會被第一個例項化,它在Netty的整個生命週期都會存在。

在Netty4中執行緒是按照如下方式工作的:

  1. 對於每一個埠的監聽,會有一個單獨的執行緒(Boss)去監聽並處理其I/O事件。
  2. Boss執行緒為這個事件生成對應的Channel,並繫結其對應的Pipeline,然後交給Worker(childGroup)。
  3. Worker會將此Channel繫結到某個EventLoop(I/O執行緒)上,之後所有這個Channel上的事件預設都要在此EventLoop上執行。
  4. 當事件需要執行耗時的工作時,為了不阻塞I/O執行緒,往往會自定義一個EventExecutorGroup(Netty4提供了io.netty.util.concurrent.DefaultEventExecutorGroup),將耗時的Handler放入其中執行。
  5. 對於沒有指定EventExecutorGroup的Handler,將預設指定為Channel上繫結的EventLoop。

其流程如下圖所示:

從 I/O 模型到 Netty(三)
Netty4執行緒的執行緒模型

Netty3與Netty4的不同

Netty3與4相比,大致的思想都是一樣的,但是實現上有一些略微的不同,在Netty3.7原始碼中的EchoServer執行後其執行緒列表如下:

從 I/O 模型到 Netty(三)
Netty3中自帶的Echo服務端

Netty3與Netty4不同的地方有:

  1. Netty3中ServerBootstrap的建立需要使用JDK的執行緒池,而Netty4封裝了執行緒池,增加了很多如EventLoop的概念,這點可以從執行緒的命名上看得出來。
  2. 由於#1的原因,導致了Pipeline中的Handler沒有被約束在某個執行緒內執行,會出現多執行緒同步的問題。
  3. 由於#1的原因,在Netty3中可以生成大量的業務執行緒來做Handler的處理,有時候看這樣做可以提升系統的效能,但是其實這樣做破壞了Netty只處理網路I/O事件的設計,整個Handler的執行過程變得很複雜,增加了系統開發和維護的複雜度。
  4. Netty3中在Pipeline中切換執行緒可以使用org.jboss.netty.handler.execution.ExecutionHandler,而在新的執行緒模型中,Netty提供了io.netty.util.concurrent.DefaultEventExecutorGroup來實現這種切換。

五、一些關於Netty的周邊

  1. 第一次看到Netty時想,WTH,它跟Jetty有什麼關係,怎麼長得這麼像。

  2. 後來去逛了Netty的網站,看(xiang)了(xi)一(yue)看(du)最新的User Guide,看到了一段話讓我一下子樂了。

    Some users might already have found other network application framework that claims to have the same advantage, and you might want to ask what makes Netty so different from them. The answer is the philosophy it is built on. Netty is designed to give you the most comfortable experience both in terms of the API and the implementation from the day one. It is not something tangible but you will realize that this philosophy will make your life much easier as you read this guide and play with Netty.

    粗體的文字大意是說,「Netty的好,不能用言語來表達,但是隻要你去使用它,你能體會的到蘊藏在其中的哲學,它會讓你的生活更加容易。」這個X裝的我給?。

  3. 因為要準備這篇內容,去搜了一下Netty的歷史,原來它最早是一個叫Trustin Lee的人寫的。然後用了十多年的積累才造就了今天Netty這樣一個開箱即用的基於事件驅動的NIO網路框架。

相關文章