深入分析Netty高性能特性

C10K與C10M問題

C10K&C10M解決方案

C10K問題

關於C10K的問題,在先前的epoll技術分析文章已經有講述過,C10K是屬於一個優化問題,即要讓單個web服務支撐1w的併發連接,關於C10K的性能與可伸縮性問題,摘錄C100M的博文並加入自己的理解:

  • 採用線程連接架構TBA模型,也就是1個客戶端連接對應1個線程,那麼對於內核而言,假設這個時候需要10k個連接,那麼也就意味着要10k個線程,此時內核需要從這個10k個線程中輪詢遍歷哪個線程是有數據流量進來的,對於服務器本身而言,不論線程數量多少,線程上下文切換的時間是恆定的,即使再多的連接分配給再多的線程,其性能也不會上去,線程調度仍然無法擴展,除了本身線程資源的瓶頸之外,我們可以看到的一個現場就是線程調度無法擴展.
  • 相對地,採用選擇/輪詢來處理連接事件,也就是面向事件驅動設計EDA模式,我們在分析select/poll/epoll技術中講到,它們都是對一個socket集合fds進行監聽,每個數據包都會經過socket套接字,即使套接字增加,我們同樣可以通過選擇和輪詢的方式來遍歷socket數據流量進來的事件,這個時候單線程是可以完成一個選擇和輪詢就緒事件的操作,同時還可以實現連接的擴展性,隨着IO技術的發展,現代服務器都會引入可擴展的epoll技術與異步IO Compeletion Port在指定時間內查詢就緒的socket集合並返回給應用程序.

因此,優化一個C10K的問題可以從以下幾個方面考慮:

  • 選用的IO模型能夠支持web實現可伸縮性
  • 結合IO模型設計的線程模型,能夠通過增加適當的線程數量來支撐web服務更多的併發連接
  • 最後一個可以理解爲性能問題,一個Web服務的性能可以參考以下幾個因素: 數據複製拷貝問題/線程上下文切換問題/內存分配問題以及鎖爭用(無鎖編程是一個我們理想的選擇)

C10M問題

同理地,C10K問題的解決,隨着互聯網技術發展,又提出了一個C10M的優化問題,即如何讓我們的單臺機器支撐1000w的併發連接,這個時候Errata Security首席執行官Robert Graham從歷史的角度出發講述Unix最開始設計不是通用的服務器OS,而是作爲電話網絡的控制系統,實際上是電話網絡在控制數據傳輸,因而控制平面與數據平面存在清晰的分隔,於是指出一個問題,即當前我們使用的Unix服務器是作爲數據平面的一部分,這也是他所說的內核不是解決方案,而是問題所在,什麼意思呢?不要讓內核承擔所有繁重的工作.將數據包處理,內存管理和處理器調度從內核中移出,並將其放入應用程序中,可以在其中高效地完成它.讓Linux處理控制平面,讓應用程序處理數據平面.對此,一個C10M關注的問題有以下幾個方面:

  • 1000w個併發連接
  • 支撐一個持續時間約爲10s的100w併發連接
  • 1000萬個數據包/秒-期望當前的服務器每秒處理5萬個數據包,這將達到更高的水平。過去服務器每秒能夠處理100K次中斷,每個數據包都會引起中斷。
  • 10微秒延遲-可伸縮服務器可能會處理規模,但延遲會增加。
  • 10微秒抖動-限制最大延遲
  • 10個連貫的CPU內核-軟件應擴展到更大數量的內核。通常,軟件只能輕鬆擴展到四個內核。服務器可以擴展到更多的內核,因此需要重寫軟件以支持更大的內核計算機

基於上述的敘述,爲了構建一個能夠支撐1000w/s的併發連接系統,我們需要讓數據平面系統能夠處理1000w/s個數據包,而對於一個控制平面系統而言,持續10s的最多也就只能處理100w個併發連接,爲了實現這個目標,我們借鑑C10K問題的解決方案,C10K問題主要是從構建一個可伸縮性的IO模型的web服務來達到支撐10K併發連接的目的,同時也引入線程模型與性能優化手段來配合實現達到目的,從這裏我們也可以看到可伸縮性是我們設計的目標,同時爲了支撐1000w的連接,我們不能將性能優化外包給操作系統,那麼我們要編寫一個可伸縮性的軟件來達到上述的目標就需要解決以下的問題:

  • 數據包可擴展: 編寫一個自定義驅動程序以繞過TCP堆棧,直接將數據包發送到應用程序.如PF_RING,Netmap,Intel DPDK
  • 多核可擴展: 多核編碼並不是多線程編碼,而是讓我們的應用程序分佈在每個CPU核心上,保證我們能夠隨着內核的增加以線性擴展我們應用程序的處理能力.即一個是保持每個cpu核數的數據結構,一個是每個cpu保證原子性操作,一個是使用無鎖技術的數據結構,一個是使用線程模型完成流水工作,最後一個是利用處理器的親和力,即保持運行在每個cpu核數上分配的線程是固定的,即每個cpu對應着專有的線程來完成工作.
  • 內存可擴展: 一個是使用連續內存分配技術,增加數據的緩存命中率,一個是分頁表運用高效的緩存數據結果並對數據壓縮,一個是使用池化技術管理內存,一個是合理分配線程以降低內存訪問延遲,最後一個是使用預分配的內存技術.

因此,我們可以借鑑C10K與C10M的優化思路來推導一個具備高併發,高性能且可伸縮性的web服務設計思路展開,高併發連接調度我們可以從IO模型以及線程模型思考,高性能的指標我們可以從計算機資源分配管理與優化方面思考(比如內存/無鎖編程),而一個可伸縮性的web服務我們會從物理資源角度來考慮,通過增加相關的資源配置是否能夠得到線性的性能提升.接接下來我們開始分析Netty是如何實現高併發,高性能以及如何體現可伸縮性的.

高併發問題

高併發關注指標

  • 響應時間(Response Time):發起一個request請求,執行這個request請求從開始到最後返回響應結果所花費的總體時間,也就是客戶端發起請求到最後收到服務端返回響應結果的時間.比如http請求響應時間爲200ms,200ms表示RT.
  • 每秒併發連接(併發用戶數): 每秒可支撐的連接調度/同時承載正常使用系統功能的用戶數量,併發連接/用戶數更關注的是能夠處理調度連接而不在於處理速度.
  • QPS/TPS(每秒查詢量/每秒事物處理量): 比如現在客戶端發起一個下單操作(用戶鑑權/訂單校驗/下單操作三個步驟),這個下單操作形成一個TPS,而下單裏的每個步驟形成一個QPS,也就是說TPS包含3個QPS操作,因而對於TPS理解是一個完整的事物請求的操作結果,而QPS是針對一個request請求的操作結果,對此TPS是衡量軟件測試結果的度量單位,而QPS是特定的查詢服務器在指定的時間段內處理流量度量標準的數量,域名服務器的機器性能通常用QPS來衡量,QPS與TPS更關注處理速度.
  • 吞吐量(Throughput): 取決於我們關注系統的業務指標,比如我們關注的是軟件測量結果相關的處理能力(處理速度),那麼這個時候的吞吐量我們需要關注的是TPS,如果是關注機器性能的流量,那麼我們關注的吞吐量是QPS,如果我們對接的是接入層的服務,那麼我們可能需要關注的是併發連接的調度,此時關注的吞吐量是支撐的併發連接調度數據.

併發連接/QPS/TPS

基於上述的高併發指標的理解,現將併發連接/QPS/TPS的區分通過以下圖解的方式展開:
在這裏插入圖片描述

  • 併發連接: 主要體現在服務端程序高效的連接調度機制上,也就是說服務端能夠在一定的時間段內能夠正確地響應給每個連接的請求即可,至於何時響應以及如何響應不是併發連接關注的事情.
  • QPS/TPS: 主要體現在處理速度上,要求能夠正常完成對請求響應的處理,不僅是要對請求結果正確響應,同時還要求處理能力能夠儘可能快速.

IO與線程模型實現高併發連接調度

  • 基於先前的高性能IO編程設計並結合上述的C10K與C10M問題,實現一個支撐高併發連接調度的web服務需要藉助具備可伸縮性的NIO或者AIO技術完成,通過監聽socket的數據流量出入事件來響應給應用程序,並且輪詢事件通過單線程的方式也能夠處理,還能實現擴展,只要操作系統的fd資源配置足夠大即可.
  • 其次,爲了支撐更多更快的響應連接調度處理,我們可以適當地加入多線程處理方式來擴展上述單線程處理連接事件的能力.同時也會看到在IO相關設計,基於事件的編程,爲了簡化應用開發者編寫代碼的複雜度以及具備更好的擴展性,引入了基於EDA的Reactor與Proactor的模式設計.
C10K與C10M提升性能優化因素

結合之前的高性能IO編程文章以及C10K與C10M問題,我們可以考慮設計一個高性能的Web服務可以從以下幾個方面思考:

數據包的存儲

  • socket接收數據流量的時候我們要考慮如何將數據包直接傳輸到應用程序,儘量避免數據的拷貝問題.
  • 應用程序接收數據包的時候能不能緩存起來,同時如果加入緩存的話,有沒有辦法提高命中率.
  • 數據存儲的區域能否重複利用,即使用池化技術進行管理分配,減少向計算機申請資源的性能.

應用程序的處理能力

對於處理處理能力,我們可以用一個詞來說明,那就是吞吐量,既然想要提升吞吐量,那麼我們的目標其實也是很明確的,即“快”.

  • 充分利用CPU資源,避免CPU一直處於空閒假死狀態(線程阻塞/空輪詢/線程過多)
  • 根據高性能IO設計的一文,我們可以在競爭環境下使用併發庫,底層原子操作等手段有助於提升IO的吞吐量
  • 同步環境下能夠使用無鎖來處理任務

Netty線程模型

在Netty技術中主要是採用NIO實現多連接的單線程複用機制以及藉助多線程異步處理方式來提升支撐併發連接調度的處理能力,在C10M問題中已經指出,爲了優化C10M問題,我們應該考慮在應用程序方面去設計數據平面系統來構建一個支撐1000W併發連接的調度處理機制.

可伸縮的IO模型
  • NIO多路複用技術具備可伸縮性,通過C10K問題的分析,我們知道單線程能夠處理更多的socket就緒事件,也就是說單線程面向事件驅動設計的複用技術實現可擴展性且能支撐更多併發連接的請求調度處理,這裏與線程連接不同的是我們關注的是事件而不是線程本身,因而不會受限於線程資源以及線程的調度分配問題.
  • 其次Netty框架是基於Reactor模式進行演變,但於Reactor模式不同的是Netty是多線程異步處理,更像是Proactor模式,但異步處理是在應用程序通過回調的方式完成的,而Proactor是基於AIO的方式將異步操作傳輸到內核並在內核中進行回調返回.
Netty之Reactor模式

關於Netty框架的線程模式架構設計圖如下所示:


現在我們基於宏觀上對Netty的線程模型有一個基本認知之後,結合先前文章對Netty組件源碼以及事件流程的分析可知,在Netty中存在EventLoopGroup,通過EventLoopGroup來分配EventLoop,而每一個EventLoop既具備線程池的功能又承擔着事件輪詢的工作,同時每個EventLoop都分配對應的一個FastThread專有線程來負責對處理當前EventLoop的pipeline的流水工作,由於每一個啓動EventLoop都綁定專有的一個線程FastThread,那麼對於EventLoop處理的一系列流水工作也將會在當前的線程執行,從而保證了單線程資源無競爭高效串行化流水任務的執行,簡單點就是無鎖流水工作,這個在我們上述講到的C10M優化方案中體現的一個多核擴展問題,Netty框架很好地運用這一理念來提升我們web服務支撐高併發連接的調度處理.

關於Netty處理的單線程無鎖串行化的流水工作流程示意圖如下:

在瞭解上述的無鎖串行化任務執行流程之後,我們還需要關注Netty另一個問題,即在多Reactor模式中,我們看到服務端channel其實只完成一次創建,初始化以及註冊,相比客戶端channel,提供給客戶端的EventLoopGroup由於在客戶端有新連接進來的時候就會在Acceptor進行註冊,而我們也分析channel的註冊流程,註冊的時候會在EventLoopGroup根據選舉策略分配一個EventLoop來完成channel到EventLoop的綁定,對此,我們知道對於客戶端channel而言,EventLoopGroup的作用是類似於我們分佈式的“集羣”機器服務來對外提供服務的,分擔高併發的連接壓力,那麼對於服務端channel而言呢,提供EventLoopGroup如果指定的線程數量大於1,這個時候EventLoopGroup又起到什麼作用呢?其實對於服務端的channel,我們很多時候並不僅僅是處理連接的接收,還要在處理連接之前做一些鑑權校驗抑或是風控等安全措施的處理,如果這些過程會比較耗時,那麼就需要在我們處理的handler上添加從Group選舉一個新的EventLoop事件輪詢活動來緩解我們併發連接調度處理能力,其實說到底Group還是類似於分佈式系統中的“集羣”來緩解併發調度的壓力.

於是基於上述的分析,我們對Netty支撐高併發採用的技術手段總結如下:

  • 使用NIO模型實現多連接的可伸縮性擴展,同時引入Reactor模式以及責任鏈設計在原有的基礎上使得Netty可伸縮性更爲靈活,能夠支撐更多的併發連接調度.
  • 其次,Netty設計通過爲每個執行的事件輪詢EventLoop分配獨有的線程,保證了每個事件輪詢器之間處理的流水工作相互獨立,同時也保證了在當前EventLoop下執行的所有流水工作都是專屬於專有的線程,不存在資源競爭以及鎖爭用的情況,基於此,在多核環境下我們可以充分利用多核技術進一步去提升我們的併發連接調度處理能力.
  • 最後一個就是Netty通過EventLoopGroup的“集羣”手段來分擔我們web服務的併發連接調度處理能力,有效緩解對單個線程處理併發連接的壓力,提升併發連接調度的處理能力.

Netty高性能之ByteBuf

堆外內存

對於linux操作系統讀取數據塊一般流程是:先從硬件設備將數據塊加載數據到內核緩衝區,然後由內核將內核緩衝區的數據複製到用戶空間的緩衝區,最後喚醒應用程序讀取用戶空間的緩衝區,對於Java程序而言,其無法直接操作OS系統內存區域,必須通過JVM堆申請內存區域來存放數據塊,於是需要再從OS內存中的數據緩衝區將數據塊複製到JVM堆中才能夠進行操作數據,於是對於JVM操作socket的數據包,數據包拷貝的路徑如下圖示:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-Sj6FUEy7-1588916010586)(https://raw.githubusercontent.com/xiaokunliu/xiaokunliu.github.io/feature/writing/websites/zimages/netty/feature/netty_direct_bytebuf.jpg)]

網卡設備接收到數據包流量事件,內核將數據塊加載到內核緩衝區中,並且通過socket傳輸數據到用戶空間的緩衝區,最後JVM要操作socket緩衝區的數據,需要將其讀取到JVM堆中存儲,這個時候需要再JVM堆中申請一個內存區域用於存放數據包數據,而如果直接通過堆外內存讀取數據,則可以減少一次數據的拷貝以及內存資源的損耗,如下圖所示:

Netty的堆外內存操作通過底層操作系統Unsfe的方式獲取其內存位置來直接操作內存,相比使用堆內存分配更爲高性能便利,同時也減少了數據拷貝,直接通過Unsafe指向的堆外內存引用來進行操作.

零拷貝機制
// 假設buffer1以及buffer2都存儲在堆外內存,堆內內存同理(只是在JVM中)
ByteBuf httpHeader = buffer1.silice(OFFSET_PAYLOAD, buffer1.readableBytes() - OFFSET_PAYLOAD);
ByteBuf httpBody = buffer2.silice(OFFSET_PAYLOAD, buffer2.readableBytes() - OFFSET_PAYLOAD);
// 邏輯上的複製,header與body仍然存儲在原有的內存區域中,http爲JVM在堆中創建的對象,指向一個邏輯結構上的ByteBuf
ByteBuf http = ChannelBuffers.wrappedBuffer(httpHeader, httpBody);

上述的零拷貝機制示意圖如下:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-OmfFCvey-1588916010587)(https://raw.githubusercontent.com/xiaokunliu/xiaokunliu.github.io/feature/writing/websites/zimages/netty/feature/zero_copy.jpg)]

這個時候在應用程序中可以直接通過http的ByteBuf操作合併之後的header+body的ByteBuf緩衝區,http的byteBuf是屬於邏輯上的合併,實際上並沒有發生數據拷貝,只是在JVM中創建一個http的ByteBuf引用指向並操作合併之後的bytebuf.

動態擴容
@Override
public ByteBuf writeBytes(byte[] src) {
  writeBytes(src, 0, src.length);
  return this;
}

@Override
public ByteBuf writeBytes(byte[] src, int srcIndex, int length) {
  ensureWritable(length);
  setBytes(writerIndex, src, srcIndex, length);
  writerIndex += length;
  return this;
}

// minWritableBytes = byte.length
final void ensureWritable0(int minWritableBytes) {
  final int writerIndex = writerIndex();
  final int targetCapacity = writerIndex + minWritableBytes;
  if (targetCapacity <= capacity()) {
    ensureAccessible();
    return;
  }
  if (checkBounds && targetCapacity > maxCapacity) {
    ensureAccessible();
    throw new IndexOutOfBoundsException(String.format(
      "writerIndex(%d) + minWritableBytes(%d) exceeds maxCapacity(%d): %s",
      writerIndex, minWritableBytes, maxCapacity, this));
  }

  // Normalize the target capacity to the power of 2.
  final int fastWritable = maxFastWritableBytes();
  
  // 動態擴容
  // 需要在java中配置io.netty.buffer.checkBounds=false
  // 默認爲4M
  // > 4M進行擴容, 
  // < 4M的話要進行擴容爲3072byte
  int newCapacity = fastWritable >= minWritableBytes ? writerIndex + fastWritable
    : alloc().calculateNewCapacity(targetCapacity, maxCapacity);

  // Adjust to the new capacity.
  capacity(newCapacity);
}
引用計數與資源管理

在ByteBuf添加引用計數能夠計算當前對象持有的資源引用活動情況,通常以活動的引用計數爲1作爲開始,當引用計數大於0的時候,就能夠保證對象不會被釋放,當引用計數減少到0的時候說明當前對象實例就會被釋放,將會被JVM的GC進行回收,對於池化技術而言則是存放到內存池中以便於重複利用.因此使用池化技術的PooledByteBufAllocator而言,使用引用計數能夠降低內存分配的開銷,有助於優化內存使用和性能的提升.

  • ByteBuf的實現接口ReferenceCounted
// ByteBuf.java
public abstract class ByteBuf implements ReferenceCounted, Comparable<ByteBuf>{
  boolean isAccessible() {
        return refCnt() != 0;
    }
}

//AbstractByteBuf.java
public abstract class AbstractByteBuf extends ByteBuf {
  // 對於引用計數爲0的實例將無法訪問,會拋出異常IllegalReferenceCountException
  protected final void ensureAccessible() {
        if (checkAccessible && !isAccessible()) {
            throw new IllegalReferenceCountException(0);
        }
    }
}

//ReferenceCounted.java
public interface ReferenceCounted {
		// 調用retain(increament) 將會增加引用計數increament
   // 調用release(increament)將會減少引用計數increament
}

  • ChannelHandler的資源管理
// 對於入站事件,如果當前消費入站數據並且沒有事件進行傳播的話,那麼就需要手動釋放資源
public void channelRead(ChannelHandlerContext ctx, Object msg){
  // ...
  // not call fireChannelRead,事件傳播在當前handler終止,這個時候需要手動清除
  ReferenceCountUtil.release(msg);
  // SimpleChannelInboundHandler能夠手動清除,但是一般入站事件我個人習慣用ChannelInboundHandlerAdapter並且自己手動管理,方法單一,處理簡單,可以手動管理,同理出站事件也是用Adapter
}

// 對於出站事件,如果當前需要對非法消息採取丟棄操作,則也需要手動進行處理釋放資源
public void channelWrite(ChannelHandlerContext ctx, Object msg, ChannelPromise promise){
  ReferenceCountUtil.release(msg);
  promise.setSuccess(); // 丟棄消息意味着不會將數據傳輸到出站事件的責任鏈上,這個時候FutureListener無法監聽到消息處理情況,需要手動通知處理結果
}
  • Netty的資源監控類ResourceLeakDetector
## 關於監控類的級別詳細查看Netty類下的ResourceLeakDetector
## 通過java配置並執行可以查看資源泄漏情況以及輸出報告
java -Dio.netty.leakDetection.level=ADVANCED
內存分配算法
  • 入口程序

首先,Netty處理讀寫事件默認分配的內存Allocator源碼如下:

// 創建Channel的時候會創建默認的AdaptiveRecvByteBufAllocator,不論是客戶端還是服務端channel
// DefaultChannelConfig.java
public DefaultChannelConfig(Channel channel) {
  this(channel, new AdaptiveRecvByteBufAllocator());
}

上述的DefaultChannelConfig類圖如下:

其次,我們關注socket的讀寫事件,也就是NioSocketChannel的相關事件,在NioEventLoop下的run方法定位到對應的unsafe的read方法,如下:

// AbstractNioByteChannel.java
void read(){
  // 根據上述的config可以定位到是默認使用池化的內存分配器,默認爲池化分配且爲堆外內存分配
  final ByteBufAllocator allocator = config.getAllocator();
  // RecvByteBufAllocator默認爲AdaptiveRecvByteBufAllocator
  // allocHandle爲AdaptiveRecvByteBufAllocator下的HandleImp,而HandleImp繼承MaxMessageHandle
  final RecvByteBufAllocator.Handle allocHandle = recvBufAllocHandle();
  
  //我們關注byteBuf的分配,調用上述的handler的allocate方法
  byteBuf = allocHandle.allocate(allocator);
}

// MaxMessageHandle.java
@Override
public ByteBuf allocate(ByteBufAllocator alloc) {
  // 使用池化的分配器創建ioBuffer
  // 根據數據包的大小計算要申請的內存區域大小,每個區域大小都存儲在一個table表中進行存儲,每次計算都會通過二分查找來搜索適合當前數據包存儲的數據
  return alloc.ioBuffer(guess());
}

接着,我們現在需要關注的是池化分配器的ioBuffer方法,其源碼如下:

// AbstractByteBufAllocator.java
@Override
public ByteBuf ioBuffer(int initialCapacity) {
  // 根據上述傳遞進來的數據包進行創建
  if (PlatformDependent.hasUnsafe() || isDirectBufferPooled()) {
    // 默認執行方式
    return directBuffer(initialCapacity);
  }
  return heapBuffer(initialCapacity);
}

// PooledByteBufAllocator.java
public class PooledByteBufAllocator extends AbstractByteBufAllocator implements ByteBufAllocatorMetricProvider {
  
 	// 默認使用堆外內存策略
    public static final PooledByteBufAllocator DEFAULT =
            new PooledByteBufAllocator(PlatformDependent.directBufferPreferred());
  
  // 分配bytebuf策略
   @Override
    protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {
        // 使用線程緩存技術,類似於jemalloc的可伸縮的內存分配策略
        PoolThreadCache cache = threadCache.get();
        PoolArena<ByteBuffer> directArena = cache.directArena;

        final ByteBuf 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(buf);
    }
}

最後我們可以看到,Netty採用的ByteBuf是使用池化且堆外內存分配的方式,如果OS支持Unsafe操作則默認爲Unsafe操作,接下來我們來關注分配算法的核心代碼

  • 內存分配算法的核心代碼
//  buf = directArena.allocate(cache, initialCapacity, maxCapacity);
PooledByteBuf<T> allocate(PoolThreadCache cache, int reqCapacity, int maxCapacity) {
  PooledByteBuf<T> buf = newByteBuf(maxCapacity);
  allocate(cache, buf, reqCapacity);
  return buf;
}

// 可以看到上述存在兩個部分,一個是創建池化的ByteBuf,一個是從內存中申請資源存儲數據
  1. 創建池化的ByteBuf源碼
// PoolArena.java
@Override
protected PooledByteBuf<ByteBuffer> newByteBuf(int maxCapacity) {
  if (HAS_UNSAFE) {
    // 直接創建一個ByteBuf
    return PooledUnsafeDirectByteBuf.newInstance(maxCapacity);
  } else {
    return PooledDirectByteBuf.newInstance(maxCapacity);
  }
}

// PooledUnsafeDirectByteBuf.java
static PooledUnsafeDirectByteBuf newInstance(int maxCapacity) {
  // 這裏面的代碼不深挖
  // 核心處理流程: 先從線程緩存獲取棧,從棧獲取buf,如果不存在則將創建ByteBuf並存儲棧中,最後更新棧數據並一併更新到到線程的cache中
  PooledUnsafeDirectByteBuf buf = RECYCLER.get();
  // 重置buf的引用計數
  buf.reuse(maxCapacity);
  return buf;
}
  1. 分配內存算法源碼
// PoolArena.java
private void allocate(PoolThreadCache cache, PooledByteBuf<T> buf, final int reqCapacity) {
  // 計算合適的一個區域
  // 比如現在申請一個資源爲19byte,則會爲其創建一個2的臨近整數方,這個時候會分配一個32byte的數據
  final int normCapacity = normalizeCapacity(reqCapacity);

  // 申請的容量小於8kb
  if (isTinyOrSmall(normCapacity)) { 
    int tableIdx;
    PoolSubpage<T>[] table;
    boolean tiny = isTiny(normCapacity);
    // 容量小於512byte
    if (tiny) {
      // 從緩存中獲取
      if (cache.allocateTiny(this, buf, reqCapacity, normCapacity)) {
        return;
      }
      tableIdx = tinyIdx(normCapacity);
      table = tinySubpagePools;
    } else {
      // 分配的容量大於512byte小於8kb
      if (cache.allocateSmall(this, buf, reqCapacity, normCapacity)) {
        return;
      }
      tableIdx = smallIdx(normCapacity);
      table = smallSubpagePools;
    }

    final PoolSubpage<T> head = table[tableIdx];
    synchronized (head) {
      final PoolSubpage<T> s = head.next;
      if (s != head) {
        assert s.doNotDestroy && s.elemSize == normCapacity;
        long handle = s.allocate();
        assert handle >= 0;
        s.chunk.initBufWithSubpage(buf, null, handle, reqCapacity);
        incTinySmallAllocation(tiny);
        return;
      }
    }
    synchronized (this) {
      allocateNormal(buf, reqCapacity, normCapacity);
    }

    incTinySmallAllocation(tiny);
    return;
  }

  // 容量大於等於8kb小於16M
  if (normCapacity <= chunkSize) {
    if (cache.allocateNormal(this, buf, reqCapacity, normCapacity)) {
      return;
    }
    synchronized (this) {
      allocateNormal(buf, reqCapacity, normCapacity);
      ++allocationsNormal;
    }
  } else {
    // > 16M,直接從操作系統中申請資源並且不做緩存和池化處理,於是不會添加到arena中
    // Huge allocations are never served via the cache so just call allocateHuge
    allocateHuge(buf, reqCapacity);
  }
}
  1. 摘錄實際分配內存源碼
// PoolArena.java
 private void allocateNormal(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) {
   if (q050.allocate(buf, reqCapacity, normCapacity) || q025.allocate(buf, reqCapacity, normCapacity) ||
       q000.allocate(buf, reqCapacity, normCapacity) || qInit.allocate(buf, reqCapacity, normCapacity) ||
       q075.allocate(buf, reqCapacity, normCapacity)) {
     return;
   }

   // Add a new chunk.
   // pageSize = 8kb
   // maxOrder = 11
   // pageShifts = 18
   // chunkSize = 16M
   PoolChunk<T> c = newChunk(pageSize, maxOrder, pageShifts, chunkSize);
   boolean success = c.allocate(buf, reqCapacity, normCapacity);
   assert success;
   qInit.add(c);
 }

// PoolChunk.java
boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int normCapacity) {
  // 創建一個subpage
  final long handle;
  if ((normCapacity & subpageOverflowMask) != 0) { // >= pageSize
    handle =  allocateRun(normCapacity);
  } else {
    // < 8kb
    handle = allocateSubpage(normCapacity);
  }

  if (handle < 0) {
    return false;
  }
  // 從緩存隊列獲取nioBuffer
  ByteBuffer nioBuffer = cachedNioBuffers != null ? cachedNioBuffers.pollLast() : null;
  
  // 填充實際數據
  initBuf(buf, nioBuffer, handle, reqCapacity);
  return true;
}

// 關注allocateSubpage以及allocateRun方法,最終會執行allocateNode,這裏只分析allocateSubpage
private long allocateSubpage(int normCapacity) {
  // 從arena查詢對應的區域類型的PoolSubPage
  PoolSubpage<T> head = arena.findSubpagePoolHead(normCapacity);
  int d = maxOrder; 
  synchronized (head) {
    int id = allocateNode(d);
    if (id < 0) {
      return id;
    }

    // 存儲subpage的池大小爲8kb
    final PoolSubpage<T>[] subpages = this.subpages;
    final int pageSize = this.pageSize;
    freeBytes -= pageSize;
    int subpageIdx = subpageIdx(id);
    PoolSubpage<T> subpage = subpages[subpageIdx];
    if (subpage == null) {
      // 創建一個容量大小爲normCapacity的subpage來存儲數據
      subpage = new PoolSubpage<T>(head, this, id, runOffset(id), pageSize, normCapacity);
      subpages[subpageIdx] = subpage;
    } else {
      subpage.init(head, normCapacity);
    }
    // 完成分配並返回subpage的bitmap
    return subpage.allocate();
  }
}
  1. 分配的數據存儲到線程緩存的源碼
// 對於池化技術,有一點就是一旦數據釋放的時候將會將資源進行回收重複利用.於是當調用byteBuf進行數據回收的時候,會執行以下動作
// 入口代碼
ReferenceCountUtil.release(msg);

// 在上述我們已經知道默認使用PooledByteBuf,於是如果msg爲PooledByteBuf,當進行資源回收的時候,就會執行以下的動作

// PooledByteBuf
@Override
protected final void deallocate() {
  if (handle >= 0) {
    final long handle = this.handle;
    this.handle = -1;
    memory = null;
    // 這裏進行資源回收
    chunk.arena.free(chunk, tmpNioBuf, handle, maxLength, cache);
    tmpNioBuf = null;
    chunk = null;
    recycle();
  }
}

void free(PoolChunk<T> chunk, ByteBuffer nioBuffer, long handle, int normCapacity, PoolThreadCache cache) {
  if (chunk.unpooled) {
    int size = chunk.chunkSize();
    destroyChunk(chunk);
    activeBytesHuge.add(-size);
    deallocationsHuge.increment();
  } else {
    SizeClass sizeClass = sizeClass(normCapacity);
    // 可以看到這裏將數據添加到線程緩存中
    if (cache != null && cache.add(this, chunk, nioBuffer, handle, normCapacity, sizeClass)) {
      // cached so not free it.
      return;
    }

    freeChunk(chunk, handle, sizeClass, nioBuffer, false);
  }
}

至此,Netty從分配到回收一個池化的ByteBuf工作流程如下:


Netty內存分配邏輯結構視圖:

  1. 從宏觀上看,線程與Arena之間的關係:

  1. 從微觀上看每個arena存儲數據過程,在上述源碼中我們看到在沒有使用線程緩存的時候,會創建一個PoolChunk對象,在這個PoolChunk中對於小於8kb的數據會通過維護着一個subpage類型的數組來組成一個page,我們可以認爲把存儲數據的buffer存放在一個chunk的一個page,並且每個page的容量都是2冪次方且單位爲byte,在chunk爲了便於搜索可用的page,於是在邏輯上將page以完全二叉樹的數據結構進行存儲,方便進行搜索查詢,每個二叉樹節點存儲對應一個可分配的容量,根容量爲16M,深度每增加1,容量就減半.如下圖所示:

  1. 最後我們看下線程緩存存儲的邏輯結構(基於可伸縮性的jemalloc算法):

    上述的Tiny MemoryRegionCache對應於TinySubPageCache,Small MemoryRegionCache對應於SmallSubPageCache,而Normal MemoryRegionCache對應於NormalCache.

最後,我們根據源碼將內存分配策略如下:

Netty高效處理機制

解決空輪詢的源碼
// NioEventLoop.java
// 僅摘錄部分代碼
static{
  // 可配置select的循環次數,當網絡數據包一直不可達的時候,通過次數控制減少當前selector不斷無結果的空輪詢,一旦超過次數將會重建selector,將原有的selector關閉,避免cpu飆升.
    int selectorAutoRebuildThreshold = SystemPropertyUtil.getInt("io.netty.selectorAutoRebuildThreshold", 512);
    if (selectorAutoRebuildThreshold < MIN_PREMATURE_SELECTOR_RETURNS) {
      selectorAutoRebuildThreshold = 0;
    }

    SELECTOR_AUTO_REBUILD_THRESHOLD = selectorAutoRebuildThreshold;
}
void run(){
  for(;;){
     try{
       select();
     }catch(){}
     
     selectCnt++;
    
    // 處理就緒事件
    processSelectedKeys();
    // 執行任務
    ranTasks = runAllTasks();
    
    if (ranTasks || strategy > 0) {
      if (selectCnt > MIN_PREMATURE_SELECTOR_RETURNS && logger.isDebugEnabled()) {
       logger.debug(...);
      selectCnt = 0;
    } else if (unexpectedSelectorWakeup(selectCnt)) { // Unexpected wakeup (unusual case)
      selectCnt = 0;
    }
  } 
}

// unexpectedSelectorWakeup方法
private boolean unexpectedSelectorWakeup(int selectCnt) {
       
  if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&
      selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
    // 超過一定的次數之後重建selector,如何重建這裏不貼代碼
    rebuildSelector();
    return true;
  }
  return false;
}
使用責任鏈機制實現無鎖串行化任務

基於事件輪詢器的源碼與線程模型可知,分配給每個EventLoop的專屬線程都會負責處理select之後的就緒事件集合以及所有在阻塞隊列中的任務,且線程與EventLoop通過FastThreadLocal進行綁定,也就是說所有事件的處理與任務的執行都是處於一個線程中,從而保證事件處理與任務處理都是保持在同一個線程中,同時與了保持一個channelHandler實例能夠共享於多個pipeline中,需要通過註解@Shareble方式來保證線程安全.於是對於Netty處理的任務還是channelHandler下的完成事件處理都是能夠得到線程安全的保證,於是對於無鎖串行化的描述如下圖:

在這裏插入圖片描述

使用併發庫

在先前的高性能IO設計一文中有說到,在資源競爭的環境下,使用併發庫甚至是無鎖編程能夠提升程序的性能,避免鎖的爭搶與等待.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章