关于阿里云:通过-HTTP2-协议案例学习-Java-Netty-性能调优工具技巧与方法论

2次阅读

共计 13598 个字符,预计需要花费 34 分钟才能阅读完成。

作者:梁倍宁 Apache Dubbo Contributor、陈有为 Apache Dubbo PMC

摘要

Dubbo3 Triple 协定是参考 gRPC、gRPC-Web、Dubbo2 等协定特点设计而来,它汲取各自协定特点,齐全兼容 gRPC、Streaming 通信、且无缝反对 HTTP/1 和浏览器。

当你在 Dubbo 框架中应用 Triple 协定,而后你就能够间接应用 Dubbo 客户端、gRPC 客户端、curl、浏览器等拜访你公布的服务,不须要任何额定组件与配置。

除易用性以外,Dubbo3 Triple 在性能调优方面做了大量工作,本文将偏重对 Triple 协定背地的高性能机密进行深刻解说,波及一些有价值的性能调优工具、技巧及代码实现;在下一篇文章中,咱们将具体开展 Triple 协定在易用性方面的一些具体应用场景。

为什么要优化 Triple 协定的性能?

自 2021 年开始 Dubbo3 就曾经作为下一代服务框架逐渐开始取代阿里外部宽泛应用的 HSF 框架,截止目前,阿里以淘宝、天猫等电商为代表的绝大多数外围利用曾经胜利降级到 Dubbo3。作为过来两年撑持阿里双十一万亿级服务调用的要害框架,Triple 通信协议的性能间接影响整个零碎的运行效率。

前置常识

1. Triple 协定简介

Triple 协定是参考 gRPC 与 gRPC-Web 两个协定设计而来,它汲取了两个协定各自的个性和长处,将它们整合在一起,成为一个齐全兼容 gRPC 且反对 Streaming 通信的协定,同时 Triple 还反对 HTTP/1、HTTP/2。

Triple 协定的设计指标如下:

  • Triple 设计为对人类敌对、开发调试敌对的一款基于 HTTP 的协定,尤其是对 unary 类型的 RPC 申请。
  • 齐全兼容基于 HTTP/2 的 gRPC 协定,因而 Dubbo Triple 协定实现能够 100% 与 gRPC 体系互调互通。

当你在 Dubbo 框架中应用 Triple 协定,而后你就能够间接应用 Dubbo 客户端、gRPC 客户端、curl、浏览器等拜访你公布的服务。

以下是应用 curl 客户端拜访 Dubbo 服务端一个 Triple 协定服务的示例:

curl \
  --header "Content-Type: application/json"\
  --data '{"sentence":"Hello Dubbo."}'\
https://host:port/org.apache.dubbo.sample.GreetService/sayHello

在具体实现上,Dubbo Triple 反对 Protobuf Buffer 但并不绑定,比方 Dubbo Java 反对以 Java Interface 定义 Triple 服务,这对于关注特定语言易用性的开发者将更容易上手。另外,Dubbo 以后曾经提供了 Java、Go、Rust 等语言实现,目前正在推动 Node.js 等语言的协定实现,咱们打算通过多语言和 Triple 协定买通挪动端、浏览器、后端微服务体系。

在 Triple 的实现中外围的组件有以下几个:

TripleInvoker 是 Triple 协定的外围组件之一,用于申请调用 Triple 协定的服务端。其中外围办法为 doInvoke,该办法会依据申请类型如 UNARY、BiStream 等,发动不一样类型的申请。如 UNARY 在 SYNC 下即同步阻塞调用,一个申请对应一个响应。BiStream 则是双向通信,客户端能够继续发送申请,而服务端同样也能够继续推送音讯,他们之间通过回调 StreamObserver 组件的办法实现交互。

T ripleClientStream 是 Triple 协定的外围组件之一,该组件与 HTTP/2 中的 Stream 概念与之对应,每次发动一个新的申请均会创立一个新的 TripleClientStream,同理与之对应的 HTTP/2 的 Stream 也是不雷同的。TripleClientStream 提供外围的办法有 sendHeader 用来发送头部帧 Header Frame,以及 sendMessage 用来发送数据帧 Data Frame。

WriteQueue 是 Triple 协定中用于写出音讯的缓冲队列,其外围逻辑就是将各种操作命令 QueueCommand 增加到外部保护的队列中,并尝试将这些 QueueCommand 对应的工作提交到 Netty 的 EventLoop 线程中单线程、有序的执行。

QueueCommand 是专门用于提交到 WriteQueue 的工作抽象类,不同的 Command 对应了不同的执行逻辑。

TripleServerStream 是 Triple 协定中服务端的 Stream 形象,该组件与 HTTP/2 中的 Stream 概念与之对应,客户端每通过一个新的 Stream 发动申请,服务端便会创立一个与之对应的 TripleServerStream,以便解决客户端发来的申请信息。

2. HTTP/2

HTTP/2 是一种新一代的 HTTP 协定,是 HTTP/1.1 的替代品,HTTP/2 相较于 HTTP/1.1 的最大改良在于缩小了资源的耗费进步了性能。HTTP/1.1 中,浏览器只能在一个 TCP 连贯中发送一个申请。如果浏览器须要加载多个资源,那么浏览器就须要建设多个 TCP 连贯。这种形式会导致一些问题,例如 TCP 连贯的建设和断散会减少网络提早,而且浏览器可能会在同一时间内发送多个申请导致网络拥塞。

相同,HTTP/2 容许浏览器在一个 TCP 连贯中同时发送多个申请,多个申请对应多个 Stream 流,多个流之间互相独立,并以并行的形式流转。而在每个流中,这些申请会被拆分成多个 Frame 帧,这些帧在同一个流中以串行的形式流转,严格的保障了帧的有序性。因而客户端能够并行发送多个申请,而服务器也能够并行发送多个响应,这有助于缩小网络连接数,以及网络提早和进步性能。

HTTP/2 还反对服务器推送,这意味着服务器能够在浏览器申请之前预加载资源。例如,如果服务器晓得浏览器将要申请一个特定的资源,那么服务器能够在浏览器申请之前将该资源推送到浏览器。这有助于进步性能,因为浏览器不须要期待资源的申请和响应。

HTTP/2 还反对头部压缩,这意味着 HTTP 头部中的反复信息能够被压缩。这有助于缩小网络带宽的应用。

3. Netty

Netty 是一个高性能异步事件驱动的网络框架,次要用于疾速开发可保护的高性能协定服务器和客户端。它的次要特点是易于应用、灵活性强、性能高、可扩展性好。Netty 应用 NIO 作为根底,能够轻松地实现异步、非阻塞的网络编程,反对 TCP、UDP、HTTP、SMTP、WebSocket、SSL 等多种协定。Netty 的外围组件包含 Channel、EventLoop、ChannelHandler 和 ChannelPipeline。

Channel 是一个传输数据的双向通道,能够用来解决网络 I/O 操作。Netty 的 Channel 实现了 Java NIO 的 Channel 接口,并在此基础上增加了一些性能,例如反对异步敞开、绑定多个本地地址、绑定多个事件处理器等。

EventLoop 是 Netty 的外围组件之一,它负责解决所有 I/O 事件和工作。一个 EventLoop 能够治理多个 Channel,每个 Channel 都有一个对应的 EventLoop。EventLoop 应用单线程模型来处理事件,防止了线程之间的竞争和锁的应用,从而进步了性能。

ChannelHandler 是连贯到 ChannelPipeline 的处理器,它能够解决入站和出站的数据,例如编码、解码、加密、解密等。一个 Channel 能够有多个 ChannelHandler,ChannelPipeline 会依照增加的程序顺次调用它们来解决数据。

ChannelPipeline 是 Netty 的另一个外围组件,它是一组按程序连贯的 ChannelHandler,用于解决入站和出站的数据。每个 Channel 都有本人独占的 ChannelPipeline,当数据进入或来到 Channel 时,会通过所有的 ChannelHandler,由它们来实现解决逻辑。

工具筹备

为了对代码进行调优,咱们须要借助一些工具来找到 Triple 协定性能瓶颈的地位,例如阻塞、热点办法。而本次调优用到的工具次要有 VisualVM 以及 JFR。

Visual VM

Visual VM 是一个能够监督本地和近程的 Java 虚拟机的性能和内存应用状况的图形化工具。它是一个开源我的项目,能够用于辨认和解决 Java 应用程序的性能问题。

Visual VM 能够显示 Java 虚拟机的运行状况,包含 CPU 使用率、线程数、内存应用状况、垃圾回收等。它还能够显示每个线程的 CPU 应用状况和堆栈跟踪,以便辨认瓶颈。

Visual VM 还能够剖析堆转储文件,以辨认内存透露和其余内存应用问题。它能够查看对象的大小、援用和类型,以及对象之间的关系。

Visual VM 还能够在运行时监督应用程序的性能,包含办法调用次数、耗时、异样等。它还能够生成 CPU 和内存应用状况的快照,以便进一步剖析和优化。

JFR

JFR 全称为 Java Flight Recorder,是 JDK 提供的性能剖析工具。JFR 是一种轻量级的、低开销的事件记录器,它能够用来记录各种事件,包含线程的生命周期、垃圾回收、类加载、锁竞争等等。JFR 的数据能够用来剖析应用程序的性能瓶颈,以及辨认内存透露等问题。与其余性能剖析工具相比,JFR 的特点在于它的开销非常低,能够始终开启记录,而不会对应用程序自身的性能产生影响。

JFR 的应用非常简单,只须要在启动 JVM 时增加启动参数 -XX:+UnlockCommercialFeatures -XX:+FlightRecorder,就能够开启 JFR 的记录性能。当 JVM 运行时,JFR 会自动记录各种事件,并将它们保留到一个文件中。记录完结后,咱们能够应用工具 JDK Mission Control 来剖析这些数据。例如,咱们能够查看 CPU 的使用率、内存的应用状况、线程的数量、锁竞争状况等等。JFR 还提供了一些高级的性能,例如事件过滤、自定义事件、事件堆栈跟踪等等。

在本次性能调优中,咱们重点关注 Java 中能显著影响性能的事件:Monitor Blocked、Monitor Wait、Thread Park、Thread Sleep。

  • Monitor Blocked 事件由 synchronized 块触发,示意有线程进入了同步代码块
  • Monitor Wait 事件由 Object.wait 触发,示意有代码调用了该办法
  • Thread Park 事件由 LockSupport.park 触发,示意有线程被挂起
  • Thread Sleep 事件由 Thread.sleep() 触发,示意代码中存在手动调用该办法的状况

调优思路

1. 非阻塞

高性能的关键点之一是编码时必须是非阻塞的,代码中如果呈现了 sleep、await 等相似办法的调用,将会阻塞线程并间接影响到程序的性能,所以在代码中应尽可能防止应用阻塞式的 API,而是应用非阻塞的 API。

2. 异步

在调优思路中,异步是其中一个关键点。在代码中,咱们能够应用异步的编程形式,例如应用 Java8 中的 CompletableFuture 等。这样做的益处在于能够防止线程的阻塞,从而进步程序的性能。

3. 分治

在调优过程中,分治也是一个很重要的思路。例如能够将一个大的工作分解成若干个小工作,而后应用多线程并行的形式来解决这些工作。这样做的益处在于能够进步程序的并行度,从而充分利用多核 CPU 的性能,达到优化性能的目标。

4. 批量

在调优思路中,批量也是一个很重要的思路。例如能够将多个小的申请合并成一个大的申请,而后一次性发送给服务器,这样能够缩小网络申请的次数,从而升高网络提早和进步性能。另外,在解决大量数据时,也能够应用批量解决的形式,例如一次性将一批数据读入内存,而后进行解决,这样能够缩小 IO 操作的次数,从而进步程序的性能。

高性能的基石:非阻塞

不合理的 syncUninterruptibly

通过间接查看代码,咱们发现了一处显著显著会阻塞以后线程的办法 syncUninterruptibly。而应用 DEBUG 的形式能够很轻松的得悉该代码会在用户线程中进行,其中源码如下所示。

private WriteQueue createWriteQueue(Channel parent) {final Http2StreamChannelBootstrap bootstrap = new Http2StreamChannelBootstrap(parent);
  final Future<Http2StreamChannel> future = bootstrap.open().syncUninterruptibly();
  if (!future.isSuccess()) {throw new IllegalStateException("Create remote stream failed. channel:" + parent);
  }
  final Http2StreamChannel channel = future.getNow();
  channel.pipeline()
    .addLast(new TripleCommandOutBoundHandler())
    .addLast(new TripleHttp2ClientResponseHandler(createTransportListener()));
  channel.closeFuture()
    .addListener(f -> transportException(f.cause()));
  return new WriteQueue(channel);
}

此处代码逻辑如下:

  • 通过 TCP Channel 结构出 Http2StreamChannelBootstrap
  • 通过调用 Http2StreamChannelBootstrap 的 open 办法失去 Future
  • 通过调用 syncUninterruptibly 阻塞办法期待 Http2StreamChannel 构建实现
  • 失去 Http2StreamChannel 后再结构其对应的 ChannelPipeline

而在前置常识中咱们提到了 Netty 中大部分的工作都是在 EventLoop 线程中以单线程的形式执行的,同样的当用户线程调用 open 时将会把创立 HTTP2 Stream Channel 的工作提交到 EventLoop 中,并在调用 syncUninterruptibly 办法时阻塞用户线程直到工作实现。

而提交后的工作只是提交到一个工作队列中并非立刻执行,因为此时的 EventLoop 可能还在执行 Socket 读写工作或其余工作,那么提交后很可能因为其余工作占用的工夫较多,从而导致迟迟没有执行创立 Http2StreamChannel 这个工作,那么阻塞用户线程的工夫就会变大。

而从一个申请的整体的流程剖析来看,Stream  Channel 还没创立实现用户线程就被阻塞了,在真正发动申请后还须要再次进行阻塞期待响应,一次 UNARY 申请中就呈现了两次显著的阻塞行为,这将会极大的制约了 Triple 协定的性能,那么咱们能够大胆假如:此处的阻塞是不必要的。为了证实咱们的推断,咱们能够应用 VisualVM 对其进行采样,剖析热点中阻塞创立 Stream Channel 的耗时。以下是 Triple Consumer Side 的采样后果。

从图中咱们能够看到 HttpStreamChannelBootstrap$1.run 创立 StreamChannel 办法在整个 EventLoop 的耗时里有着不小的占比,开展后能够看到这些耗时基本上耗费在了 notifyAll 上,即唤醒用户线程。

优化计划

至此咱们曾经理解到了性能的妨碍点之一是创立 StreamChannel,那么优化计划便是将创立 StreamChannel 异步化,以便打消 syncUninterruptibly 办法的调用。革新后的代码如下所示,将创立 StreamChannel 的工作形象成了 CreateStreamQueueCommand 并提交到了 WriteQueue 中,后续发动申请的 sendHeader、sendMessage 也是将其提交到 WriteQueue 中,这样便能够轻松保障在创立 Stream 后才会执行发送申请的工作。

private TripleStreamChannelFuture initHttp2StreamChannel(Channel parent) {TripleStreamChannelFuture streamChannelFuture = new TripleStreamChannelFuture(parent);
    Http2StreamChannelBootstrap bootstrap = new Http2StreamChannelBootstrap(parent);
    bootstrap.handler(new ChannelInboundHandlerAdapter() {
            @Override
            public void handlerAdded(ChannelHandlerContext ctx) throws Exception {Channel channel = ctx.channel();
                channel.pipeline().addLast(new TripleCommandOutBoundHandler());
                channel.pipeline().addLast(new TripleHttp2ClientResponseHandler(createTransportListener()));
                channel.closeFuture().addListener(f -> transportException(f.cause()));
            }
        });
    CreateStreamQueueCommand cmd = CreateStreamQueueCommand.create(bootstrap, streamChannelFuture);
    this.writeQueue.enqueue(cmd);
    return streamChannelFuture;
}

其中 CreateStreamQueueCommand 的外围逻辑如下,通过保障在 EventLoop 中执行以打消不合理的阻塞办法调用。

public class CreateStreamQueueCommand extends QueuedCommand {
    ......
    @Override
    public void run(Channel channel) {
        // 此处的逻辑能够保障在 EventLoop 下执行,所以 open 后能够间接获取后果而不须要阻塞
        Future<Http2StreamChannel> future = bootstrap.open();
        if (future.isSuccess()) {streamChannelFuture.complete(future.getNow());
        } else {streamChannelFuture.completeExceptionally(future.cause());
        }
    }
}

不失当的 synchronized 锁竞争

此时简略的看源码曾经不能发现显著的性能瓶颈了,接下来咱们须要借助 Visual VM 工具来找到性能瓶颈。

关上工具后咱们能够选中须要采集的过程,这里咱们采集的是 Triple Consumer 的过程,并选中选项卡中的 Sampler,点击 CPU 开始采样 CPU 的耗时热点办法。以下是咱们采样 CPU 热点办法的后果,咱们开展了耗时最为显著的 EventLoop 线程的调用堆栈。

通过层层开展,咱们能够从图中发现一个十分的不合理耗时办法——ensureWriteOpen,这个办法名看上去不就是一个判断 Socket 是否可写的办法吗,为什么耗时的占比会这么大?咱们带着疑难关上了 JDK8 中 sun.nio.ch.SocketChannelImpl 的 isConnected 办法,其代码如下。

public boolean isConnected() {synchronized (stateLock) {return (state == ST_CONNECTED);
  }
}

能够看到这个办法中没有任何逻辑,然而有着要害字眼 synchronized,所以咱们能够判定:EventLoop 线程呈现了大量的同步锁竞争!那么咱们下一步思路便是找到在同一时刻竞争该锁的办法。咱们的办法也比较简单粗犷,那就是通过 DEBUG 条件断点的形式找出该办法。如下图所示咱们给 isConnected 这个办法里打上条件断点,进入断点的条件是:以后线程不是 EventLoop 线程。

断点打上后咱们启动并发动申请,能够很清晰的看到咱们的办法调用堆栈中呈现了 TripleInvoker.isAvailable 的办法调用,最终会调用到 sun.nio.ch.SocketChannelImpl 的 isConnected,从而呈现了 EventLoop 线程的锁竞争耗时的景象。

优化计划

通过以上的剖析,咱们接下来的批改思路就很清晰了,那就是批改 isAvailable 的判断逻辑,本身保护一个 boolean 值示意是否能够用,以便打消锁竞争,晋升 Triple 协定的性能。

不可漠视的开销:线程上下文切换

咱们持续察看 VisualVM 采样的快照,查看整体线程的耗时状况,如下图:

从图中咱们能够提取到以下信息:

  • 耗时最大的线程为 NettyClientWorker-2-1
  • 压测期间有大量非消费者线程即 tri-protocol-214783647-thread-xxx
  • 消费者线程的整体耗时较高且线程数多
  • 用户线程的耗时非常低

咱们任意开展其中一个消费者线程后也能看到消费者线程次要是做反序列化以及交付反序列化后果 (DeadlineFuture.received),如下图所示:

从以上信息来看仿佛并不能看到瓶颈点,接下来咱们尝试应用 JFR(Java Flight Recorder)监控过程信息。下图是 JFR 的日志剖析。

1. Monitor Blocked 事件

其中咱们能够先查看 JFR 的简要剖析,点击 Java Blocking 查看可能存在的阻塞点,该事件示意有线程进入了 synchronized 代码块,其中后果如下图所示。

能够看到这里有一个总阻塞耗时达 39 秒的 Class,点击后能够看到图中 Thread 一栏,被阻塞的线程全都是 benchmark 发申请的线程。再往下看火焰图 Flame View 中展现的办法堆栈,能够剖析出这只是在期待响应后果,该阻塞是必要的,该阻塞点能够疏忽。

接着点击左侧菜单的 Event Browser 查看 JFR 收集到的事件日志,并过滤闻名为 java 的事件类型列表,咱们首先查看 Java Monitor Blocked 事件,后果如下图所示。

能够看到被阻塞的线程全都是 benchmark 发动申请的线程,阻塞的点也只是期待响应,能够排除该事件。

2. Monitor Wait 事件

持续查看 Java Monitor Wait 事件,Monitor Wait 示意有代码调用了 Object.wait 办法,后果如下图所示。

从上图咱们能够失去这些信息:benchmark 申请线程均被阻塞,均匀期待耗时约为 87ms,阻塞对象均是同一个 DefaultPromise,阻塞的切入办法为 Connection.isAvailable。接着咱们查看该办法的源码,其源码如下所示。很显然,这个阻塞的耗时只是首次建设连贯的耗时,对整体性能不会有太大的影响。所以这里的 Java Monitor Wait 事件也能够排除。

public boolean isAvailable() {if (isClosed()) {return false;}
  Channel channel = getChannel();
  if (channel != null && channel.isActive()) {return true;}
  if (init.compareAndSet(false, true)) {connect();
  }

  this.createConnectingPromise();
  //87ms 左右的耗时来自这里
  this.connectingPromise.awaitUninterruptibly(this.connectTimeout, TimeUnit.MILLISECONDS);
  // destroy connectingPromise after used
  synchronized (this) {this.connectingPromise = null;}

  channel = getChannel();
  return channel != null && channel.isActive();}

3. Thread Sleep 事件

接下来咱们再查看 Java Thread Sleep 事件,该事件示意代码中存在手动调用 Thread.sleep,查看是否存在阻塞工作线程的行为。从下图能够看到,很显然并没有阻塞消费者线程或 benchmark 申请线程,这个被动调用 sleep 的线程次要用于申请超时场景,对整体性能没有影响,同样也能够排除 Java Thread Sleep 事件。

4. Thread Park 事件

最初咱们再查看 Java Thread Park 事件,park 事件示意线程被挂起。下图是 park 事件列表。

能够看到 park 事件有 1877 个,并且大多都是消费者线程池里的线程,从火焰图中的办法堆栈能够得悉这些线程都在期待工作,并且没有取到工作的持续时间偏长。由此能够阐明一个问题:消费者线程池中大部分线程都是没有执行工作的,消费者线程池的利用率非常低。

而要进步线程池的利用率则能够缩小消费者线程池的线程数,然而在 dubbo 中消费者线程池并不能间接缩小,咱们尝试在 UNARY 场景下将消费者线程池包装成 SerializingExecutor,该 Executor 能够使得提交的工作被串行化执行,变相将线程池大小放大。咱们再查看缩减后的后果如下。

从以上后果中能够看到曾经缩小了大量的消费者线程,线程利用率大幅度提高, 并且 Java Thread Park 事件也是大幅度缩小,性能却进步了约 13%。

由此可见多线程切换对程序性能影响较大,但也带来了另一个问题,咱们通过 SerializingExecutor 将大部分的逻辑集中到了大量的消费者线程上是否正当?带着这个疑难咱们开展其中一条消费者线程的调用堆栈进行剖析。通过开展办法调用堆栈能够看到 deserialize 的字样 (如下图所示)。

很显然咱们尽管进步了性能,但却把不同申请的响应体反序列化行为都集中在了大量的消费者线程上解决,会导致反序列化被”串行”执行了,当反序列化大报文时耗时会显著上涨。

所以能不能想方法把反序列化的逻辑再次派发到多个线程上并行处理呢?带着这个疑难咱们首先梳理出以后的线程交互模型,如下图所示。

依据以上的线程交互图,以及 UNARY SYNC“一个申请对应一个响应”的特点, 咱们能够大胆推断—— ConsumerThread 不是必要的! 咱们能够间接将所有非 I/O 类型的工作都交给用户线程执行,能够无效利用多线程资源并行处理,并且也能大幅度缩小不必要的线程上下文的切换。所以此处最佳的线程交互模型应如下图所示。

5. 优化计划

梳理出该线程交互模型后,咱们的改变思路就比较简单了。依据 TripleClientStream 的源码得悉,每当接管到响应后,I/O 线程均会把工作提交到与 TripleClientStream 绑定的 Callback Executor 中,该 Callback Executor 默认即消费者线程池,那么咱们只须要替换为 ThreadlessExecutor 即可。其改变如下:

缩小 I/O 的利器:批量

咱们后面介绍到 triple 协定是一个基于 HTTP/2 协定实现的,并且齐全兼容 gRPC,由此可见 gRPC 是一个不错的参照对象。于是咱们将 triple 与 gRPC 做比照,环境统一仅协定不同,最终后果发现 triple 与 gRPC 的性能有肯定的差距,那么差别点在哪里呢?带着这个问题,咱们对这两者持续压测,同时尝试应用 tcpdump 对两者进行抓包,其后果如下。

triple

gRPC

从以上的后果咱们能够看到 gRPC 与 triple 的抓包差别十分大,gRPC 中一个工夫点发送了一大批不同 Stream 的数据,而 triple 则是十分规矩的申请“一来一回”。所以咱们能够大胆猜想 gRPC 的代码实现中肯定会有批量发送的行为,一组数据包被当作一个整体进行发送,大幅度的缩小了 I/O 次数。为了验证咱们的猜测,咱们须要对 gRPC 的源码深刻理解。最终发现 gRPC 中批量的实现位于 WriteQueue 中,其外围源码片段如下:

private void flush() {PerfMark.startTask("WriteQueue.periodicFlush");
  try {
    QueuedCommand cmd;
    int i = 0;
    boolean flushedOnce = false;
    while ((cmd = queue.poll()) != null) {cmd.run(channel);
      if (++i == DEQUE_CHUNK_SIZE) {
        i = 0;
        // Flush each chunk so we are releasing buffers periodically. In theory this loop
        // might never end as new events are continuously added to the queue, if we never
        // flushed in that case we would be guaranteed to OOM.
        PerfMark.startTask("WriteQueue.flush0");
        try {channel.flush();
        } finally {PerfMark.stopTask("WriteQueue.flush0");
        }
        flushedOnce = true;
      }
    }
    // Must flush at least once, even if there were no writes.
    if (i != 0 || !flushedOnce) {PerfMark.startTask("WriteQueue.flush1");
      try {channel.flush();
      } finally {PerfMark.stopTask("WriteQueue.flush1");
      }
    }
  } finally {PerfMark.stopTask("WriteQueue.periodicFlush");
    // Mark the write as done, if the queue is non-empty after marking trigger a new write.
    scheduled.set(false);
    if (!queue.isEmpty()) {scheduleFlush();
    }
  }
}

能够看到 gRPC 的做法是将一个个数据包形象为 QueueCommand,用户线程发动申请时并非真的间接写出,而是先提交到 WriteQueue 中,并手动调度 EventLoop 执行工作,EventLoop 须要执行的逻辑便是从 QueueCommand 的队列中取出并执行,当写入数据达到 DEQUE_CHUNK_SIZE (默认 128) 时,才会调用一次 channel.flush,将缓冲区的内容刷写到对端。当队列的 Command 都生产结束后,还会按需执行一次兜底的 flush 避免音讯失落。以上便是 gRPC 的批量写入逻辑。

同样的,咱们查看了 triple 模块的源码发现也有一个名为 WriteQueue 的类,其目标同样是批量写入音讯,缩小 I/O 次数。但从 tcpdump 的后果来看,该类的逻辑仿佛并没有达到预期,音讯仍旧是一个个按序发送并没有批量。

咱们能够将断点打在 triple 的 WriteQueue 结构器中,查看 triple 的 WriteQueue 为什么没有达到批量写的预期。如下图所示。

能够看到 WriteQueue 会在 TripleClientStream 结构器中实例化,而 TripleClientStream 则是与 HTTP/2 中的 Stream 对应,每次发动一个新的申请都须要构建一个新的 Stream,也就意味着每个 Stream 都应用了不同的 WriteQueue 实例,多个 Stream 提交 Command 时并没有提交到一块去,使得不同的 Stream 发动申请在完结时都会间接 flush,导致 I/O 过高,重大的影响了 triple 协定的性能。

剖析出起因后,优化改变就比拟清晰了,那便是将 WriteQueue 作为连贯级共享,而不是一个连贯下不同的 Stream 各自持有一个 WriteQueue 实例。当 WriteQueue 连贯级别单例后,能够充分利用其持有的 ConcurrentLinkedQueue 队列作为缓冲,实现一次 flush 即可将多个不同 Stream 的数据刷写到对端,大幅度 triple 协定的性能。

调优成绩

最初咱们来看一下 triple 本次优化后成绩吧。能够看到小报文场景下性能进步显著, 最高晋升率达 45%! 而遗憾的是较大报文的场景晋升率无限,同时较大报文场景也是 triple 协定将来的优化指标之一。

总结

性能解密之外,在下一篇文章中咱们将会带来 Triple 易用性、互联互通等方面的设计与应用案例,将次要围绕以下两点开展,敬请期待。

  • 在 Dubbo 框架中应用 Triple 协定,能够间接应用 Dubbo 客户端、gRPC 客户端、curl、浏览器等拜访你公布的服务,不须要任何额定组件与配置。
  • Dubbo 以后曾经提供了 Java、Go、Rust 等语言实现,目前正在推动 Node.js 等语言的协定实现,咱们打算通过多语言和 Triple 协定买通挪动端、浏览器、后端微服务体系。
正文完
 0