簡單談談Netty的高性能之道

傳統RPC 調用性能差的三宗罪

網絡傳輸方式問題:傳統的RPC 框架或者基於RMI 等方式的遠程服務(過程)調用採用了同步阻塞IO,當客戶端的併發壓力或者網絡時延增大以後,同步阻塞IO 會因爲頻繁的wait 致使IO 線程常常性的阻塞,因爲線程沒法高效的工做,IO 處理能力天然降低。下面,咱們經過BIO 通訊模型圖看下BIO 通訊的弊端:react

  採用BIO 通訊模型的服務端,一般由一個獨立的Acceptor 線程負責監聽客戶端的鏈接,接收到客戶端鏈接以後爲客戶端鏈接建立一個新的線程處理請求消息,處理完成以後,返回應答消息給客戶端,線程銷燬,這就是典型的一請求一應答模型。該架構最大的問題就是不具有彈性伸縮能力,當併發訪問量增長後,服務端的線程個數和併發訪問數成線性正比,因爲線程是JAVA 虛擬機很是寶貴的系統資源,當線程數膨脹以後,系統的性能急劇降低,隨着併發量的繼續增長,可能會發生句柄溢出、線程堆棧溢出等問題,並致使服務器最終宕機。算法

序列化方式問題:Java 序列化存在以下幾個典型問題:編程

  1. Java 序列化機制是Java 內部的一種對象編解碼技術,沒法跨語言使用;例如對於異構系統之間的對接,Java 序列化後的碼流須要可以經過其它語言反序列化成原始對象(副本),目前很難支持;
  2. 相比於其它開源的序列化框架,Java 序列化後的碼流太大,不管是網絡傳輸仍是持久化到磁盤,都會致使額外的資源佔用;
  3. 序列化性能差(CPU 資源佔用高)。

線程模型問題:因爲採用同步阻塞IO,這會致使每一個TCP 鏈接都佔用1 個線程,因爲線程資源是JVM 虛擬機很是寶貴的資源,當IO 讀寫阻塞致使線程沒法及時釋放時,會致使系統性能急劇降低,嚴重的甚至會致使虛擬機沒法建立新的線程。後端

高性能的三個主題:

  1. 傳輸:用什麼樣的通道將數據發送給對方,BIO、NIO 或者AIO,IO 模型在很大程度上決定了框架的性能。
  2. 協議:採用什麼樣的通訊協議,HTTP 或者內部私有協議。協議的選擇不一樣,性能模型也不一樣。相比於公有協議,內部私有協議的性能一般能夠被設計的更優。
  3. 線程:數據報如何讀取?讀取以後的編解碼在哪一個線程進行,編解碼後的消息如何派發,Reactor 線程模型的不一樣,對性能的影響也很是大。

Netty 驚人的性能數據:

  經過使用Netty(NIO 框架)相比於傳統基於Java 序列化+BIO(同步阻塞IO)的通訊框架,性能提高了8 倍多。經過選擇合適的NIO 框架,精心的設計Reactor 線程模型,達到上述性能指標是徹底有可能的。數組

1.異步非阻塞通訊:

  在IO 編程過程當中,當須要同時處理多個客戶端接入請求時,能夠利用多線程或者IO 多路複用技術進行處理。IO 多路複用技術經過把多個IO 的阻塞複用到同一個select 的阻塞上,從而使得系統在單線程的狀況下能夠同時處理多個客戶端請求。與傳統的多線程/多進程模型比,I/O 多路複用的最大優點是系統開銷小,系統不須要建立新的額外進程或者線程,也不須要維護這些進程和線程的運行,下降了系統的維護工做量,節省了系統資源。JDK1.4 提供了對非阻塞IO(NIO)的支持,JDK1.5_update10 版本使用epoll 替代了傳統的select/poll,極大的提高了NIO 通訊的性能。JDK NIO 通訊模型以下所示:安全

  與Socket 類和ServerSocket 類相對應,NIO 也提供了SocketChannel 和ServerSocketChannel 兩種不一樣的套接字通道實現。這兩種新增的通道都支持阻塞和非阻塞兩種模式。阻塞模式使用很是簡單,可是性能和可靠性都很差,非阻塞模式正好相反。開發人員通常能夠根據本身的須要來選擇合適的模式,通常來講,低負載、低併發的應用程序能夠選擇同步阻塞IO 以下降編程複雜度。可是對於高負載、高併發的網絡應用,須要使用NIO 的非阻塞模式進行開發。Netty 架構按照Reactor 模式設計和實現。服務器

  它的服務端通訊序列圖以下:網絡

  客戶端通訊序列圖以下:多線程

  Netty 的IO 線程NioEventLoop 聚合了多路複用器Selector,能夠同時併發處理成百上千個客戶端Channel,因爲讀寫操做都是非阻塞的,這就能夠充分提高IO 線程的運行效率,避免因爲頻繁IO 阻塞致使的線程掛起。另外,因爲Netty採用了異步通訊模式,一個IO 線程能夠併發處理N 個客戶端鏈接和讀寫操做,這從根本上解決了傳統同步阻塞IO 一鏈接一線程模型,架構的性能、彈性伸縮能力和可靠性都獲得了極大的提高。架構

2.零拷貝

Netty 的「零拷貝」主要體如今以下三個方面:

  • 1) Netty 的接收和發送ByteBuffer 採用DIRECT BUFFERS,使用堆外直接內存進行Socket 讀寫,不須要進行字節緩衝區的二次拷貝。若是使用傳統的堆內存(HEAP BUFFERS)進行Socket 讀寫,JVM 會將堆內存Buffer 拷貝一份到直接內存中,而後才寫入Socket 中。相比於堆  外直接內存,消息在發送過程當中多了一次緩衝區的內存拷貝。當進行Socket IO 讀寫的時候,爲了不從堆內存拷貝一份副本到直接內存,Netty 的ByteBuf 分配器直接建立非堆內存避免緩衝區的二次拷貝,經過「零拷貝」來提高讀寫性能。
  • 2) Netty 提供了組合Buffer 對象,能夠聚合多個ByteBuffer 對象,用戶能夠像操做一個Buffer 那樣方便的對組合Buffer進行操做,避免了傳統經過內存拷貝的方式將幾個小Buffer 合併成一個大的Buffer。
  • 3) Netty 的文件傳輸採用了transferTo()方法,它能夠直接將文件緩衝區的數據發送到目標Channel,避免了傳統經過循環write()方式致使的內存拷貝問題。對於不少操做系統它直接將文件緩衝區的內容發送到目標Channel 中,而不須要經過拷貝的方式,這是一種更加高效的傳輸方式,它實現了文件傳輸的「零拷貝」

3.內存池

  三個維度:

  • Pooled與UnPooled(池化與非池化)
  • UnSafe和非UnSafe(底層讀寫與應用程序讀寫)
  • Heap和Direct(堆內存與堆外內存)

  隨着JVM 虛擬機和JIT 即時編譯技術的發展,對象的分配和回收是個很是輕量級的工做。可是對於緩衝區Buffer,狀況卻稍有不一樣,特別是對於堆外直接內存的分配和回收,是一件耗時的操做。爲了儘可能重用緩衝區,Netty 提供了基於內存池的緩衝區重用機制。下面咱們一塊兒看下Netty ByteBuf 的實現:

  Netty 提供了多種內存管理策略,經過在啓動輔助類中配置相關參數,能夠實現差別化的定製。下面經過性能測試,咱們看下基於內存池循環利用的ByteBuf 和普通ByteBuf 的性能差別。

  用例一,使用內存池分配器建立直接內存緩衝區:

final byte[] CONTENT = new byte[1024];
int loop = 1800000;
long startTime = System.currentTimeMillis();
ByteBuf poolBuffer = null;
for (int i = 0; i < loop; i++) {
  poolBuffer = PooledByteBufAllocator.DEFAULT.directBuffer(1024);
  poolBuffer.writeBytes(CONTENT);
  poolBuffer.release();
}
long endTime
= System.currentTimeMillis(); System.out.println("內存池分配緩衝區耗時" + (endTime - startTime) + "ms.");

  用例二,使用非堆內存分配器建立的直接內存緩衝區:

long startTime2 = System.currentTimeMillis();
ByteBuf buffer = null;
for (int i = 0; i < loop; i++) {
  buffer = Unpooled.directBuffer(1024);
  buffer.writeBytes(CONTENT);
  buffer.release(); }
endTime
= System.currentTimeMillis(); System.out.println("非內存池分配緩衝區耗時" + (endTime - startTime2) + "ms.");

  性能測試經驗代表,採用內存池的ByteBuf 相比於朝生夕滅的ByteBuf,性能高了很多(性能數據與使用場景強相關)。下面咱們一塊兒簡單分析下Netty 內存池的內存分配:

public ByteBuf directBuffer(int initialCapacity, int maxCapacity) {
        if (initialCapacity == 0 && maxCapacity == 0) {
            return this.emptyBuf;
        } else {
            validate(initialCapacity, maxCapacity);
            return this.newDirectBuffer(initialCapacity, maxCapacity);
        }
    }

  繼續看newDirectBuffer 方法,咱們發現它是一個抽象方法,由AbstractByteBufAllocator 的子類負責具體實現,代碼以下:

  代碼跳轉到PooledByteBufAllocator 的newDirectBuffer 方法,從Cache 中獲取內存區域PoolArena,調用它的allocate方法進行內存分配:

protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {
        PoolThreadCache cache = (PoolThreadCache)this.threadCache.get();
        PoolArena<ByteBuffer> directArena = cache.directArena;
        Object buf;
        if (directArena != null) {
            buf = directArena.allocate(cache, initialCapacity, maxCapacity);
        } else {
            buf = PlatformDependent.hasUnsafe() ? UnsafeByteBufUtil.newUnsafeDirectByteBuf(this, initialCapacity, maxCapacity) : new UnpooledDirectByteBuf(this, initialCapacity, maxCapacity);
        }

        return toLeakAwareBuffer((ByteBuf)buf);
    }

  PoolArena 的allocate 方法以下:

PooledByteBuf<T> allocate(PoolThreadCache cache, int reqCapacity, int maxCapacity) {
        PooledByteBuf<T> buf = this.newByteBuf(maxCapacity);
        this.allocate(cache, buf, reqCapacity);
        return buf;
}

  咱們重點看newByteBuf 的實現,它一樣是個抽象方法:

  由子類DirectArena 和HeapArena 來實現不一樣類型的緩衝區分配,因爲測試用例使用的是堆外內存,所以重點分析DirectArena 的實現:若是沒有開啓使用sun 的unsafe:

protected PooledByteBuf<ByteBuffer> newByteBuf(int maxCapacity) {
    return (PooledByteBuf)(HAS_UNSAFE ? PooledUnsafeDirectByteBuf.newInstance(maxCapacity) : PooledDirectByteBuf.newInstance(maxCapacity));
}

  則執行PooledDirectByteBuf 的newInstance 方法,代碼以下:

static PooledDirectByteBuf newInstance(int maxCapacity) {
        PooledDirectByteBuf buf = (PooledDirectByteBuf)RECYCLER.get();
        buf.reuse(maxCapacity);
        return buf;
}

  經過RECYCLER 的get 方法循環使用ByteBuf 對象,若是是非內存池實現,則直接建立一個新的ByteBuf 對象。從緩衝池中獲取ByteBuf 以後,調用AbstractReferenceCountedByteBuf 的setRefCnt 方法設置引用計數器,用於對象的引用計數和內存回收(相似JVM 垃圾回收機制)。而 Unpooled.directBuffer(1024) 則是每次都要new

public static ByteBuffer allocateDirect(int capacity) {
        return new DirectByteBuffer(capacity);
}

4.高效的Reactor 線程模型

  經常使用的Reactor 線程模型有三種,分別以下:

Reactor 單線程模型;

Reactor 多線程模型;

主從Reactor 多線程模型

  Reactor 單線程模型,指的是全部的IO 操做都在同一個NIO 線程上面完成,NIO 線程的職責以下:

  1. 做爲NIO 服務端,接收客戶端的TCP 鏈接;
  2. 做爲NIO 客戶端,向服務端發起TCP 鏈接;
  3. 讀取通訊對端的請求或者應答消息;
  4. 向通訊對端發送消息請求或者應答消息。

  Reactor 單線程模型示意圖以下所示:

  因爲Reactor 模式使用的是異步非阻塞IO,全部的IO 操做都不會致使阻塞,理論上一個線程能夠獨立處理全部IO 相關的操做。從架構層面看,一個NIO 線程確實能夠完成其承擔的職責。例如,經過Acceptor 接收客戶端的TCP 鏈接請求消息,鏈路創建成功以後,經過Dispatch 將對應的ByteBuffer 派發到指定的Handler 上進行消息解碼。用戶Handler能夠經過NIO 線程將消息發送給客戶端。對於一些小容量應用場景,可使用單線程模型。可是對於高負載、大併發的應用卻不合適,主要緣由以下:

  1. 一個NIO 線程同時處理成百上千的鏈路,性能上沒法支撐,即使NIO 線程的CPU 負荷達到100%,也沒法知足海量消息的編碼、解碼、讀取和發送;
  2. 當NIO 線程負載太重以後,處理速度將變慢,這會致使大量客戶端鏈接超時,超時以後每每會進行重發,這更加劇了NIO 線程的負載,最終會致使大量消息積壓和處理超時,NIO 線程會成爲系統的性能瓶頸;
  3. 可靠性問題:一旦NIO 線程意外跑飛,或者進入死循環,會致使整個系統通訊模塊不可用,不能接收和處理外部消息,形成節點故障。

  爲了解決這些問題,演進出了Reactor 多線程模型,下面咱們一塊兒學習下Reactor 多線程模型。Rector 多線程模型與單線程模型最大的區別就是有一組NIO 線程處理IO 操做,它的原理圖以下:

Reactor 多線程模型的特色:

  1. 有專門一個NIO 線程-Acceptor 線程用於監聽服務端,接收客戶端的TCP 鏈接請求;
  2. 網絡IO 操做-讀、寫等由一個NIO 線程池負責,線程池能夠採用標準的JDK 線程池實現,它包含一個任務隊列和N個可用的線程,由這些NIO 線程負責消息的讀取、解碼、編碼和發送;
  3. 1 個NIO 線程能夠同時處理N 條鏈路,可是1 個鏈路只對應1 個NIO 線程,防止發生併發操做問題。

在絕大多數場景下,Reactor 多線程模型均可以知足性能需求;可是,在極特殊應用場景中,一個NIO 線程負責監聽和處理全部的客戶端鏈接可能會存在性能問題。例如百萬客戶端併發鏈接,或者服務端須要對客戶端的握手消息進行安全認證,認證自己很是損耗性能。在這類場景下,單獨一個Acceptor 線程可能會存在性能不足問題,爲了解決性能問題,產生了第三種Reactor 線程模型-主從Reactor 多線程模型。

主從Reactor 線程模型的特色是:

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

  利用主從NIO 線程模型,能夠解決1 個服務端監聽線程沒法有效處理全部客戶端鏈接的性能不足問題。所以,在Netty的官方demo 中,推薦使用該線程模型。事實上,Netty 的線程模型並不是固定不變,經過在啓動輔助類中建立不一樣的EventLoopGroup 實例並經過適當的參數配置,就能夠支持上述三種Reactor 線程模型。正是由於Netty 對Reactor 線程模型的支持提供了靈活的定製能力,因此能夠知足不一樣業務場景的性能訴求。

5.無鎖化的串行設計理念

  在大多數場景下,並行多線程處理能夠提高系統的併發性能。可是,若是對於共享資源的併發訪問處理不當,會帶來嚴重的鎖競爭,這最終會致使性能的降低。爲了儘量的避免鎖競爭帶來的性能損耗,能夠經過串行化設計,即消息的處理儘量在同一個線程內完成,期間不進行線程切換,這樣就避免了多線程競爭和同步鎖。

  爲了儘量提高性能,Netty 採用了串行無鎖化設計,在IO 線程內部進行串行操做,避免多線程競爭致使的性能降低。表面上看,串行化設計彷佛CPU 利用率不高,併發程度不夠。可是,經過調整NIO 線程池的線程參數,能夠同時啓動多個串行化的線程並行運行,這種局部無鎖化的串行線程設計相比一個隊列-多個工做線程模型性能更優。Netty 的串行化設計工做原理圖以下:

  Netty 的NioEventLoop 讀取到消息以後,直接調用ChannelPipeline 的fireChannelRead(Object msg),只要用戶不主動切換線程,一直會由NioEventLoop 調用到用戶的Handler,期間不進行線程切換,這種串行化處理方式避免了多線程操做致使的鎖的競爭,從性能角度看是最優的。

6.高效的併發編程

  Netty 的高效併發編程主要體如今以下幾點:

  1. volatile 的大量、正確使用;
  2. CAS 和原子類的普遍使用;
  3. 線程安全容器的使用;
  4. 經過讀寫鎖提高併發性能。

7.高性能的序列化框架

  影響序列化性能的關鍵因素總結以下:

  1. 序列化後的碼流大小(網絡帶寬的佔用);
  2. 序列化&反序列化的性能(CPU 資源佔用);
  3. 是否支持跨語言(異構系統的對接和開發語言切換)。

  Netty 默認提供了對Google Protobuf 的支持,經過擴展Netty 的編解碼接口,用戶能夠實現其它的高性能序列化框架,例如Thrift 的壓縮二進制編解碼框架。下面咱們一塊兒看下不一樣序列化&反序列化框架序列化後的字節數組對比:

  從上圖能夠看出,Protobuf 序列化後的碼流只有Java 序列化的1/4 左右。正是因爲Java 原生序列化性能表現太差,才催生出了各類高性能的開源序列化技術和框架(性能差只是其中的一個緣由,還有跨語言、IDL 定義等其它因素)。

8.靈活的TCP 參數配置能力

  合理設置TCP 參數在某些場景下對於性能的提高能夠起到顯著的效果,例如SO_RCVBUF 和SO_SNDBUF。若是設置不當,對性能的影響是很是大的。下面咱們總結下對性能影響比較大的幾個配置項:

  1. SO_RCVBUF 和SO_SNDBUF:一般建議值爲128K 或者256K;
  2. SO_TCPNODELAY:NAGLE 算法經過將緩衝區內的小封包自動相連,組成較大的封包,阻止大量小封包的發送阻塞網絡,從而提升網絡應用效率。可是對於時延敏感的應用場景須要關閉該優化算法;
  3. 軟中斷:若是Linux 內核版本支持RPS(2.6.35 以上版本),開啓RPS 後能夠實現軟中斷,提高網絡吞吐量。RPS根據數據包的源地址,目的地址以及目的和源端口,計算出一個hash 值,而後根據這個hash 值來選擇軟中斷運行的cpu,從上層來看,也就是說將每一個鏈接和cpu 綁定,並經過這個hash 值,來均衡軟中斷在多個cpu 上,提高網絡並行處理性能。

  Netty 在啓動輔助類中能夠靈活的配置TCP 參數,知足不一樣的用戶場景。相關配置接口定義以下:

   基本上對於Netty的高性能是由以上主要的八點所共同支撐的。

相關文章
相關標籤/搜索