深入淺出 gRPC 03:gRPC 線程模型分析

1. RPC 線程模型

1.1 BIO 線程模型

在 JDK 1.4 推出 Java NIO 之前,基於 Java 的所有 Socket 通信都採用了同步阻塞模式(BIO),這種一請求一應答的通信模型簡化了上層的應用開發,但是在性能和可靠性方面卻存在着巨大的瓶頸。

因此,在很長一段時間裏,大型的應用服務器都採用 C 或者 C++ 語言開發,因爲它們可以直接使用操作系統提供的異步 I/O 或者 AIO 能力。

當併發訪問量增大、響應時間延遲增大之後,採用 Java BIO 開發的服務端軟件只有通過硬件的不斷擴容來滿足高併發和低時延。

它極大地增加了企業的成本,並且隨着集羣規模的不斷膨脹,系統的可維護性也面臨巨大的挑戰,只能通過採購性能更高的硬件服務器來解決問題,這會導致惡性循環。

傳統採用 BIO 的 Java Web 服務器如下所示(典型的如 Tomcat 的 BIO 模式):
 

採用該線程模型的服務器調度特點如下:

  • 服務端監聽線程 Acceptor 負責客戶端連接的接入,每當有新的客戶端接入,就會創建一個新的 I/O 線程負責處理 Socket;
  • 客戶端請求消息的讀取和應答的發送,都有 I/O 線程負責;
  • 除了 I/O 讀寫操作,默認情況下業務的邏輯處理,例如 DB 操作等,也都在 I/O 線程處理;
  • I/O 操作採用同步阻塞操作,讀寫沒有完成,I/O 線程會同步阻塞。

BIO 線程模型主要存在如下三個問題:

  • 性能問題:一連接一線程模型導致服務端的併發接入數和系統吞吐量受到極大限制;
  • 可靠性問題:由於 I/O 操作採用同步阻塞模式,當網絡擁塞或者通信對端處理緩慢會導致 I/O 線程被掛住,阻塞時間無法預測;
  • 可維護性問題:I/O 線程數無法有效控制、資源無法有效共享(多線程併發問題),系統可維護性差。

爲了解決同步阻塞 I/O 面臨的一個鏈路需要一個線程處理的問題,通常會對它的線程模型進行優化,後端通過一個線程池來處理多個客戶端的請求接入,形成客戶端個數 “M” 與線程池最大線程數 “N” 的比例關係,其中 M 可以遠遠大於 N,通過線程池可以靈活的調配線程資源,設置線程的最大值,防止由於海量併發接入導致線程耗盡,它的工作原理如下所示:
 

優化之後的 BIO 模型採用了線程池實現,因此避免了爲每個請求都創建一個獨立線程造成的線程資源耗盡問題。但是由於它底層的通信依然採用同步阻塞模型,阻塞的時間取決於對方 I/O 線程的處理速度和網絡 I/O 的傳輸速度。

本質上來講,無法保證生產環境的網絡狀況和對端的應用程序能足夠快,如果應用程序依賴對方的處理速度,它的可靠性就非常差,優化之後的 BIO 線程模型仍然無法從根本上解決性能線性擴展問題。

1.2 異步非阻塞線程模型

從 JDK1.0 到 JDK1.3,Java 的 I/O 類庫都非常原始,很多 UNIX 網絡編程中的概念或者接口在 I/O 類庫中都沒有體現,例如 Pipe、Channel、Buffer 和 Selector 等。2002 年發佈 JDK1.4 時,NIO 以 JSR-51 的身份正式隨 JDK 發佈。它新增了個 java.nio 包,提供了很多進行異步 I/O 開發的 API 和類庫,主要的類和接口如下:

  • 進行異步 I/O 操作的緩衝區 ByteBuffer 等;
  • 進行異步 I/O 操作的管道 Pipe;
  • 進行各種 I/O 操作(異步或者同步)的 Channel,包括 ServerSocketChannel 和 SocketChannel;
  • 多種字符集的編碼能力和解碼能力;
  • 實現非阻塞 I/O 操作的多路複用器 selector;
  • 基於流行的 Perl 實現的正則表達式類庫;
  • 文件通道 FileChannel。

新的 NIO 類庫的提供,極大地促進了基於 Java 的異步非阻塞編程的發展和應用, 也誕生了很多優秀的 Java NIO 框架,例如 Apache 的 Mina、以及當前非常流行的 Netty。

Java NIO 類庫的工作原理如下所示:

在 Java NIO 類庫中,最重要的就是多路複用器 Selector,它是 Java NIO 編程的基礎,熟練地掌握 Selector 對於掌握 NIO 編程至關重要。多路複用器提供選擇已經就緒的任務的能力。

簡單來講,Selector 會不斷地輪詢註冊在其上的 Channel,如果某個 Channel 上面有新的 TCP 連接接入、讀和寫事件,這個 Channel 就處於就緒狀態,會被 Selector 輪詢出來,然後通過 SelectionKey 可以獲取就緒 Channel 的集合,進行後續的 I/O 操作。

通常一個 I/O 線程會聚合一個 Selector,一個 Selector 可以同時註冊 N 個 Channel, 這樣單個 I/O 線程就可以同時併發處理多個客戶端連接。另外,由於 I/O 操作是非阻塞的,因此也不會受限於網絡速度和對方端點的處理時延,可靠性和效率都得到了很大提升。

典型的 NIO 線程模型(Reactor 模式)如下所示:
 

1.3 RPC 性能三原則

影響 RPC 框架性能的三個核心要素如下:

  • I/O 模型:用什麼樣的通道將數據發送給對方,BIO、NIO 或者 AIO,IO 模型在很大程度上決定了框架的性能;
  • 協議:採用什麼樣的通信協議,Rest+ JSON 或者基於 TCP 的私有二進制協議,協議的選擇不同,性能模型也不同,相比於公有協議,內部私有二進制協議的性能通常可以被設計的更優;
  • 線程:數據報如何讀取?讀取之後的編解碼在哪個線程進行,編解碼後的消息如何派發,通信線程模型的不同,對性能的影響也非常大。

在以上三個要素中,線程模型對性能的影響非常大。隨着硬件性能的提升,CPU 的核數越來越越多,很多服務器標配已經達到 32 或 64 核。

通過多線程併發編程,可以充分利用多核 CPU 的處理能力,提升系統的處理效率和併發性能。但是如果線程創建或者管理不當,頻繁發生線程上下文切換或者鎖競爭,反而會影響系統的性能。

線程模型的優劣直接影響了 RPC 框架的性能和併發能力,它也是大家選型時比較關心的技術細節之一。下面我們一起來分析和學習下 gRPC 的線程模型。

2. gRPC 線程模型分析

gRPC 的線程模型主要包括服務端線程模型和客戶端線程模型,其中服務端線程模型主要包括:

  • 服務端監聽和客戶端接入線程(HTTP/2 Acceptor)
  • 網絡 I/O 讀寫線程
  • 服務接口調用線程

客戶端線程模型主要包括:

  • 客戶端連接線程(HTTP/2 Connector)
  • 網絡 I/O 讀寫線程
  • 接口調用線程
  • 響應回調通知線程

2.1 服務端線程模型

gRPC 服務端線程模型整體上可以分爲兩大類:

  • 網絡通信相關的線程模型,基於 Netty4.1 的線程模型實現
  • 服務接口調用線程模型,基於 JDK 線程池實現

2.1.1 服務端線程模型概述

gRPC 服務端線程模型和交互圖如下所示:

其中,HTTP/2 服務端創建、HTTP/2 請求消息的接入和響應發送都由 Netty 負責,gRPC 消息的序列化和反序列化、以及應用服務接口的調用由 gRPC 的 SerializingExecutor 線程池負責。

2.1.2 I/O 通信線程模型

gRPC 的做法是服務端監聽線程和 I/O 線程分離的 Reactor 多線程模型,它的代碼如下所示(NettyServer 類):

public void start(ServerListener serverListener) throws IOException {
    listener = checkNotNull(serverListener, "serverListener");
    allocateSharedGroups();
    ServerBootstrap b = new ServerBootstrap();
    b.group(bossGroup, workerGroup);
    b.channel(channelType);
    if (NioServerSocketChannel.class.isAssignableFrom(channelType)) {
      b.option(SO_BACKLOG, 128);
      b.childOption(SO_KEEPALIVE, true);

它的工作原理如下:

步驟 1:業務線程發起創建服務端操作,在創建服務端的時候實例化了 2 個 EventLoopGroup,1 個 EventLoopGroup 實際就是一個 EventLoop 線程組,負責管理 EventLoop 的申請和釋放。

EventLoopGroup 管理的線程數可以通過構造函數設置,如果沒有設置,默認取 -Dio.netty.eventLoopThreads,如果該系統參數也沒有指定,則爲“可用的 CPU 內核 * 2”。

bossGroup 線程組實際就是 Acceptor 線程池,負責處理客戶端的 TCP 連接請求,如果系統只有一個服務端端口需要監聽,則建議 bossGroup 線程組線程數設置爲 1。workerGroup 是真正負責 I/O 讀寫操作的線程組,通過 ServerBootstrap 的 group 方法進行設置,用於後續的 Channel 綁定。

步驟 2:服務端 Selector 輪詢,監聽客戶端連接,代碼示例如下(NioEventLoop 類):

int selectedKeys = selector.select(timeoutMillis);
 selectCnt ++;

步驟 3:如果監聽到客戶端連接,則創建客戶端 SocketChannel 連接,從 workerGroup 中隨機選擇一個 NioEventLoop 線程,將 SocketChannel 註冊到該線程持有的 Selector,代碼示例如下(NioServerSocketChannel 類):

protected int doReadMessages(List<Object> buf) throws Exception {
        SocketChannel ch = SocketUtils.accept(javaChannel());
        try {
            if (ch != null) {
                buf.add(new NioSocketChannel(this, ch));
                return 1;
            }

步驟 4:通過調用 EventLoopGroup 的 next() 獲取一個 EventLoop(NioEventLoop),用於處理網絡 I/O 事件。

Netty 線程模型的核心是 NioEventLoop,它的職責如下:

  • 作爲服務端 Acceptor 線程,負責處理客戶端的請求接入
  • 作爲 I/O 線程,監聽網絡讀操作位,負責從 SocketChannel 中讀取報文
  • 作爲 I/O 線程,負責向 SocketChannel 寫入報文發送給對方,如果發生寫半包,會自動註冊監聽寫事件,用於後續繼續發送半包數據,直到數據全部發送完成
  • 作爲定時任務線程,可以執行定時任務,例如鏈路空閒檢測和發送心跳消息等
  • 作爲線程執行器可以執行普通的任務線程(Runnable)NioEventLoop 處理網絡 I/O 操作的相關代碼如下:
    try {
              int readyOps = k.readyOps();
              if ((readyOps & SelectionKey.OP_CONNECT) != 0) {
                  int ops = k.interestOps();
                  ops &= ~SelectionKey.OP_CONNECT;
                  k.interestOps(ops);
    
                  unsafe.finishConnect();
              }
              if ((readyOps & SelectionKey.OP_WRITE) != 0) {
                                ch.unsafe().forceFlush();
              }
              if ((readyOps & (SelectionKey.OP_READ | SelectionKey.OP_ACCEPT)) != 0 || readyOps == 0) {
                  unsafe.read();
              }
    

除了處理 I/O 操作,NioEventLoop 也可以執行 Runnable 和定時任務。NioEventLoop 繼承 SingleThreadEventExecutor,這就意味着它實際上是一個線程個數爲 1 的線程池,類繼承關係如下所示:

SingleThreadEventExecutor 聚合了 JDK 的 java.util.concurrent.Executor 和消息隊列 Queue,自定義提供線程池功能,相關代碼如下(SingleThreadEventExecutor 類):

private final Queue<Runnable> taskQueue;
    private volatile Thread thread;
    @SuppressWarnings("unused")
    private volatile ThreadProperties threadProperties;
    private final Executor executor;
    private volatile boolean interrupted;

直接調用 NioEventLoop 的 execute(Runnable task) 方法即可執行自定義的 Task,代碼示例如下(SingleThreadEventExecutor 類):

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

除了 SingleThreadEventExecutor,NioEventLoop 同時實現了 ScheduledExecutorService 接口,這意味着它也可以執行定時任務,相關接口定義如下: 

爲了防止大量 Runnable 和定時任務執行影響網絡 I/O 的處理效率,Netty 提供了一個配置項:ioRatio,用於設置 I/O 操作和其它任務執行的時間比例,默認爲 50%,相關代碼示例如下(NioEventLoop 類):

final long ioTime = System.nanoTime() - ioStartTime;
runAllTasks(ioTime * (100 - ioRatio) / ioRatio);

NioEventLoop 同時支持 I/O 操作和 Runnable 執行的原因如下:避免鎖競爭,例如心跳檢測,往往需要週期性的執行,如果 NioEventLoop 不支持定時任務執行,則用戶需要自己創建一個類似 ScheduledExecutorService 的定時任務線程池或者定時任務線程,週期性的發送心跳,發送心跳需要網絡操作,就要跟 I/O 線程所持有的資源進行交互,例如 Handler、ByteBuf、NioSocketChannel 等,這樣就會產生鎖競爭,需要考慮併發安全問題。原理如下:
 

2.1.3 服務調度線程模型

gRPC 服務調度線程主要職責如下:

  • 請求消息的反序列化,主要包括:HTTP/2 Header 的反序列化,以及將 PB(Body) 反序列化爲請求對象;
  • 服務接口的調用,method.invoke(非反射機制);
  • 將響應消息封裝成 WriteQueue.QueuedCommand,寫入到 Netty Channel 中,同時,對響應 Header 和 Body 對象做序列化;
  • 服務端調度的核心是 SerializingExecutor,它同時實現了 JDK 的 Executor 和 Runnable 接口,既是一個線程池,同時也是一個 Task。

SerializingExecutor 聚合了 JDK 的 Executor,由 Executor 負責 Runnable 的執行,代碼示例如下(SerializingExecutor 類):

public final class SerializingExecutor implements Executor, Runnable {
  private static final Logger log =
      Logger.getLogger(SerializingExecutor.class.getName());
private final Executor executor;
  private final Queue<Runnable> runQueue = new ConcurrentLinkedQueue<Runnable>();

其中,Executor 默認使用的是 JDK 的 CachedThreadPool,在構建 ServerImpl 的時候進行初始化,代碼如下:
 

當服務端接收到客戶端 HTTP/2 請求消息時,由 Netty 的 NioEventLoop 線程切換到 gRPC 的 SerializingExecutor,進行消息的反序列化、以及服務接口的調用,代碼示例如下(ServerTransportListenerImpl 類):

final Context.CancellableContext context = createContext(stream, headers, statsTraceCtx);
      final Executor wrappedExecutor;
      if (executor == directExecutor()) {
        wrappedExecutor = new SerializeReentrantCallsDirectExecutor();
      } else {
        wrappedExecutor = new SerializingExecutor(executor);
      }
      final JumpToApplicationThreadServerStreamListener jumpListener
          = new JumpToApplicationThreadServerStreamListener(wrappedExecutor, stream, context);
      stream.setListener(jumpListener);
      wrappedExecutor.execute(new ContextRunnable(context) {
          @Override
          public void runInContext() {
            ServerStreamListener listener = NOOP_LISTENER;
            try {
              ServerMethodDefinition<?, ?> method = registry.lookupMethod(methodName);
...

相關的調用堆棧,示例如下:

響應消息的發送,由 SerializingExecutor 發起,將響應消息頭和消息體序列化,然後分別封裝成 SendResponseHeadersCommand 和 SendGrpcFrameCommand,調用 Netty NioSocketChannle 的 write 方法,發送到 Netty 的 ChannelPipeline 中,由 gRPC 的 NettyServerHandler 攔截之後,真正寫入到 SocketChannel 中,代碼如下所示(NettyServerHandler 類):

public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise)
      throws Exception {
    if (msg instanceof SendGrpcFrameCommand) {
      sendGrpcFrame(ctx, (SendGrpcFrameCommand) msg, promise);
    } else if (msg instanceof SendResponseHeadersCommand) {
      sendResponseHeaders(ctx, (SendResponseHeadersCommand) msg, promise);
    } else if (msg instanceof CancelServerStreamCommand) {
      cancelStream(ctx, (CancelServerStreamCommand) msg, promise);
    } else if (msg instanceof ForcefulCloseCommand) {
      forcefulClose(ctx, (ForcefulCloseCommand) msg, promise);
    } else {
      AssertionError e =
          new AssertionError("Write called for unexpected type: " + msg.getClass().getName());
      ReferenceCountUtil.release(msg);

響應消息體的發送堆棧如下所示:
 

Netty I/O 線程和服務調度線程的運行分工界面以及切換點如下所示:

事實上,在實際服務接口調用過程中,NIO 線程和服務調用線程切換次數遠遠超過 4 次,頻繁的線程切換對 gRPC 的性能帶來了一定的損耗。

2.2 客戶端線程模型

gRPC 客戶端的線程主要分爲三類:

  • 業務調用線程
  • 客戶端連接和 I/O 讀寫線程
  • 請求消息業務處理和響應回調線程

2.2.1 客戶端線程模型概述

gRPC 客戶端線程模型工作原理如下圖所示(同步阻塞調用爲例):
 

客戶端調用主要涉及的線程包括:

  • 應用線程,負責調用 gRPC 服務端並獲取響應,其中請求消息的序列化由該線程負責;
  • 客戶端負載均衡以及 Netty Client 創建,由 grpc-default-executor 線程池負責;
  • HTTP/2 客戶端鏈路創建、網絡 I/O 數據的讀寫,由 Netty NioEventLoop 線程負責;
  • 響應消息的反序列化由 SerializingExecutor 負責,與服務端不同的是,客戶端使用的是 ThreadlessExecutor,並非 JDK 線程池;
  • SerializingExecutor 通過調用 responseFuture 的 set(value),喚醒阻塞的應用線程,完成一次 RPC 調用。

2.2.2 I/O 通信線程模型

相比於服務端,客戶端的線程模型簡單一些,它的工作原理如下: 

第 1 步,由 grpc-default-executor 發起客戶端連接,示例代碼如下(NettyClientTransport 類):

Bootstrap b = new Bootstrap();
    b.group(eventLoop);
    b.channel(channelType);
    if (NioSocketChannel.class.isAssignableFrom(channelType)) {
      b.option(SO_KEEPALIVE, true);
    }
    for (Map.Entry<ChannelOption<?>, ?> entry : channelOptions.entrySet()) {
      b.option((ChannelOption<Object>) entry.getKey(), entry.getValue());
    }

相比於服務端,客戶端只需要創建一個 NioEventLoop,因爲它不需要獨立的線程去監聽客戶端連接,也沒必要通過一個單獨的客戶端線程去連接服務端。

Netty 是異步事件驅動的 NIO 框架,它的連接和所有 I/O 操作都是非阻塞的,因此不需要創建單獨的連接線程。

另外,客戶端使用的 work 線程組並非通常意義的 EventLoopGroup,而是一個 EventLoop:即 HTTP/2 客戶端使用的 work 線程並非一組線程(默認線程數爲 CPU 內核 * 2),而是一個 EventLoop 線程。

這個其實也很容易理解,一個 NioEventLoop 線程可以同時處理多個 HTTP/2 客戶端連接,它是多路複用的,對於單個 HTTP/2 客戶端,如果默認獨佔一個 work 線程組,將造成極大的資源浪費,同時也可能會導致句柄溢出(併發啓動大量 HTTP/2 客戶端)。

第 2 步,發起連接操作,判斷連接結果,判斷連接結果,如果沒有連接成功,則監聽連接網絡操作位 SelectionKey.OP_CONNECT。如果連接成功,則調用 pipeline().fireChannelActive() 將監聽位修改爲 READ。代碼如下(NioSocketChannel 類):

protected boolean doConnect(SocketAddress remoteAddress, SocketAddress localAddress) throws Exception {
        if (localAddress != null) {
            doBind0(localAddress);
        }
        boolean success = false;
        try {
            boolean connected = SocketUtils.connect(javaChannel(), remoteAddress);
            if (!connected) {
                selectionKey().interestOps(SelectionKey.OP_CONNECT);
            }
            success = true;
            return connected;

第 3 步,由 NioEventLoop 的多路複用器輪詢連接操作結果,判斷連接結果,如果或連接成功,重新設置監聽位爲 READ(AbstractNioChannel 類):

protected void doBeginRead() throws Exception {
        final SelectionKey selectionKey = this.selectionKey;
        if (!selectionKey.isValid()) {
            return;
        }
        readPending = true;
        final int interestOps = selectionKey.interestOps();
        if ((interestOps & readInterestOp) == 0) {
            selectionKey.interestOps(interestOps | readInterestOp);
        }

第 4 步,由 NioEventLoop 線程負責 I/O 讀寫,同服務端。

2.2.3 客戶端調用線程模型

客戶端調用線程交互流程如下所示: 

請求消息的發送由用戶線程發起,相關代碼示例如下(GreeterBlockingStub 類):

public io.grpc.examples.helloworld.HelloReply sayHello(io.grpc.examples.helloworld.HelloRequest request) {
      return blockingUnaryCall(
          getChannel(), METHOD_SAY_HELLO, getCallOptions(), request);
    }

HTTP/2 Header 的創建、以及請求參數反序列化爲 Protobuf,均由用戶線程負責完成,相關代碼示例如下(ClientCallImpl 類):

public void sendMessage(ReqT message) {
    Preconditions.checkState(stream != null, "Not started");
    Preconditions.checkState(!cancelCalled, "call was cancelled");
    Preconditions.checkState(!halfCloseCalled, "call was half-closed");
    try {
      InputStream messageIs = method.streamRequest(message);
      stream.writeMessage(messageIs);
...

用戶線程將請求消息封裝成 CreateStreamCommand 和 SendGrpcFrameCommand,發送到 Netty 的 ChannelPipeline 中,然後返回,完成線程切換。後續操作由 Netty NIO 線程負責,相關代碼示例如下(NettyClientHandler 類):

public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise)
          throws Exception {
    if (msg instanceof CreateStreamCommand) {
      createStream((CreateStreamCommand) msg, promise);
    } else if (msg instanceof SendGrpcFrameCommand) {
      sendGrpcFrame(ctx, (SendGrpcFrameCommand) msg, promise);
    } else if (msg instanceof CancelClientStreamCommand) {
      cancelStream(ctx, (CancelClientStreamCommand) msg, promise);
    } else if (msg instanceof SendPingCommand) {
      sendPingFrame(ctx, (SendPingCommand) msg, promise);
    } else if (msg instanceof GracefulCloseCommand) {
      gracefulClose(ctx, (GracefulCloseCommand) msg, promise);
    } else if (msg instanceof ForcefulCloseCommand) {
      forcefulClose(ctx, (ForcefulCloseCommand) msg, promise);
    } else if (msg == NOOP_MESSAGE) {
      ctx.write(Unpooled.EMPTY_BUFFER, promise);
...

客戶端響應消息的接收,由 gRPC 的 NettyClientHandler 負責,相關代碼如下所示: 

接收到 HTTP/2 響應之後,Netty 將消息投遞到 SerializingExecutor,由 SerializingExecutor 的 ThreadlessExecutor 負責響應的反序列化,以及 responseFuture 的設值,相關代碼示例如下(UnaryStreamToFuture 類):

public void onClose(Status status, Metadata trailers) {
      if (status.isOk()) {
        if (value == null) {
          // No value received so mark the future as an error
          responseFuture.setException(
              Status.INTERNAL.withDescription("No value received for unary call")
                  .asRuntimeException(trailers));
        }
        responseFuture.set(value);
      } else {
        responseFuture.setException(status.asRuntimeException(trailers));
      }

3. 線程模型總結

3.1 優點

3.1.1 Netty 線程模型

Netty4 之後,對線程模型進行了優化,通過串行化的設計避免線程競爭:當系統在運行過程中,如果頻繁的進行線程上下文切換,會帶來額外的性能損耗。

多線程併發執行某個業務流程,業務開發者還需要時刻對線程安全保持警惕,哪些數據可能會被併發修改,如何保護?這不僅降低了開發效率,也會帶來額外的性能損耗。

爲了解決上述問題,Netty 採用了串行化設計理念,從消息的讀取、編碼以及後續 Handler 的執行,始終都由 I/O 線程 NioEventLoop 負責,這就意外着整個流程不會進行線程上下文的切換,數據也不會面臨被併發修改的風險,對於用戶而言,甚至不需要了解 Netty 的線程細節,這確實是個非常好的設計理念,它的工作原理圖如下:

一個 NioEventLoop 聚合了一個多路複用器 Selector,因此可以處理成百上千的客戶端連接,Netty 的處理策略是每當有一個新的客戶端接入,則從 NioEventLoop 線程組中順序獲取一個可用的 NioEventLoop,當到達數組上限之後,重新返回到 0,通過這種方式,可以基本保證各個 NioEventLoop 的負載均衡。一個客戶端連接只註冊到一個 NioEventLoop 上,這樣就避免了多個 I/O 線程去併發操作它。

Netty 通過串行化設計理念降低了用戶的開發難度,提升了處理性能。利用線程組實現了多個串行化線程水平並行執行,線程之間並沒有交集,這樣既可以充分利用多核提升並行處理能力,同時避免了線程上下文的切換和併發保護帶來的額外性能損耗。

Netty 3 的 I/O 事件處理流程如下:
 

Netty 4 的 I/O 消息處理流程如下所示:
 

Netty 4 修改了 Netty 3 的線程模型:在 Netty 3 的時候,upstream 是在 I/O 線程裏執行的,而 downstream 是在業務線程裏執行。

當 Netty 從網絡讀取一個數據報投遞給業務 handler 的時候,handler 是在 I/O 線程裏執行,而當我們在業務線程中調用 write 和 writeAndFlush 向網絡發送消息的時候,handler 是在業務線程裏執行,直到最後一個 Header handler 將消息寫入到發送隊列中,業務線程才返回。

Netty4 修改了這一模型,在 Netty 4 裏 inbound(對應 Netty 3 的 upstream) 和 outbound(對應 Netty 3 的 downstream) 都是在 NioEventLoop(I/O 線程) 中執行。

當我們在業務線程裏通過 ChannelHandlerContext.write 發送消息的時候,Netty 4 在將消息發送事件調度到 ChannelPipeline 的時候,首先將待發送的消息封裝成一個 Task,然後放到 NioEventLoop 的任務隊列中,由 NioEventLoop 線程異步執行。

後續所有 handler 的調度和執行,包括消息的發送、I/O 事件的通知,都由 NioEventLoop 線程負責處理。

3.1.2 gRPC 線程模型

消息的序列化和反序列化均由 gRPC 線程負責,而沒有在 Netty 的 Handler 中做 CodeC,原因如下:Netty4 優化了線程模型,所有業務 Handler 都由 Netty 的 I/O 線程負責,通過串行化的方式消除鎖競爭,原理如下所示:

如果大量的 Handler 都在 Netty I/O 線程中執行,一旦某些 Handler 執行比較耗時,則可能會反向影響 I/O 操作的執行,像序列化和反序列化操作,都是 CPU 密集型操作,更適合在業務應用線程池中執行,提升併發處理能力。因此,gRPC 並沒有在 I/O 線程中做消息的序列化和反序列化。

3.2 改進點思考

3.2.1 時間可控的接口調用直接在 I/O 線程上處理

gRPC 採用的是網絡 I/O 線程和業務調用線程分離的策略,大部分場景下該策略是最優的。但是,對於那些接口邏輯非常簡單,執行時間很短,不需要與外部網元交互、訪問數據庫和磁盤,也不需要等待其它資源的,則建議接口調用直接在 Netty /O 線程中執行,不需要再投遞到後端的服務線程池。避免線程上下文切換,同時也消除了線程併發問題。

例如提供配置項或者接口,系統默認將消息投遞到後端服務調度線程,但是也支持短路策略,直接在 Netty 的 NioEventLoop 中執行消息的序列化和反序列化、以及服務接口調用。

3.2.2 減少鎖競爭

當前 gRPC 的線程切換策略如下:
 

優化之後的 gRPC 線程切換策略:

通過線程綁定技術(例如採用一致性 hash 做映射), 將 Netty 的 I/O 線程與後端的服務調度線程做綁定,1 個 I/O 線程綁定一個或者多個服務調用線程,降低鎖競爭,提升性能。

3.2.3 關鍵點總結

RPC 調用涉及的主要隊列如下:

  • Netty 的消息發送隊列(客戶端和服務端都涉及);
  • gRPC SerializingExecutor 的消息隊列(JDK 的 BlockingQueue);
  • gRPC 消息發送任務隊列 WriteQueue。
發佈了410 篇原創文章 · 獲贊 1345 · 訪問量 208萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章