簡單談談Netty的高性能之道


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

網絡傳輸方式問題:傳統的RPC 框架或者基於RMI 等方式的遠程服務(過程)調用采用了同步阻塞IO,當客戶端的並發壓力或者網絡時延增大之后,同步阻塞IO 會由於頻繁的wait 導致IO 線程經常性的阻塞,由於線程無法高效的工作,IO 處理能力自然下降。下面,我們通過BIO 通信模型圖看下BIO 通信的弊端:

  采用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的高性能是由以上主要的八點所共同支撐的。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM