软件开发架构师

Netty和RPC框架线程模型分析

架构 16 2019-03-11 10:59

《Netty 进阶之路》、《分布式服务框架原理与实践》作者李林锋深入剖析 Netty 和 RPC 框架线程模型。李林锋已在 InfoQ 上开设 Netty 专题持续出稿,感兴趣的同学可以持续关注。

1. 背景

1.1 线程模型的重要性

对于 RPC 框架而言,影响其性能指标的主要有三个要素:

  1. I/O 模型:采用的是同步 BIO、还是非阻塞的 NIO、以及全异步的事件驱动 I/O(AIO)。

  2. 协议和序列化方式:它主要影响消息的序列化、反序列化性能,以及消息的通信效率。

  3. 线程模型:主要影响消息的读取和发送效率、以及调度的性能。

除了对性能有影响,在一些场景下,线程模型的变化也会影响到功能的正确性,例如 Netty 从 3.X 版本升级到 4.X 版本之后,重构和优化了线程模型。当业务没有意识到线程模型发生变化时,就会踩到一些性能和功能方面的坑。

1.2 Netty 和 RPC 框架的线程模型关系

作为一个高性能的 NIO 通信框架,Netty 主要关注的是 I/O 通信相关的线程工作策略,以及提供的用户扩展点 ChannelHandler 的执行策略,示例如下:

图 1 Netty 多线程模型

该线程模型的工作特点如下:

  1. 有专门一个(一组)NIO 线程 -Acceptor 线程用于监听服务端,接收客户端的 TCP 连接请求。

  2. 网络 I/O 操作 - 读、写等由一个 NIO 线程池负责,线程池可以采用标准的 JDK 线程池实现,它包含一个任务队列和 N 个可用的线程,由这些 NIO 线程负责消息的读取、解码、编码和发送。

  3. 1 个 NIO 线程可以同时处理 N 条链路,但是 1 个链路只对应 1 个 NIO 线程,防止发生并发操作问题。

对于 RPC 框架,它的线程模型会更复杂一些,除了通信相关的 I/O 线程模型,还包括服务接口调用、服务订阅 / 发布等相关的业务侧线程模型。对于基于 Netty 构建的 RPC 框架,例如 gRPC、Apache ServiceComb 等,它在重用 Netty 线程模型的基础之上,也扩展实现了自己的线程模型。

2. Netty 线程模型

2.1 线程模型的变更

2.1.1 Netty 3.X 版本线程模型

Netty 3.X 的 I/O 操作线程模型比较复杂,它的处理模型包括两部分:

  1. Inbound:主要包括链路建立事件、链路激活事件、读事件、I/O 异常事件、链路关闭事件等。

  2. Outbound:主要包括写事件、连接事件、监听绑定事件、刷新事件等。

我们首先分析下 Inbound 操作的线程模型:

图 2 Netty 3 Inbound 操作线程模型

从上图可以看出,Inbound 操作的主要处理流程如下:

  1. I/O 线程(Work 线程)将消息从 TCP 缓冲区读取到 SocketChannel 的接收缓冲区中。

  2. 由 I/O 线程负责生成相应的事件,触发事件向上执行,调度到 ChannelPipeline 中。

  3. I/O 线程调度执行 ChannelPipeline 中 Handler 链的对应方法,直到业务实现的 Last Handler。

  4. Last Handler 将消息封装成 Runnable,放入到业务线程池中执行,I/O 线程返回,继续读 / 写等 I/O 操作。

  5. 业务线程池从任务队列中弹出消息,并发执行业务逻辑。

通过对 Netty 3 的 Inbound 操作进行分析我们可以看出,Inbound 的 Handler 都是由 Netty 的 I/O Work 线程负责执行。

下面我们继续分析 Outbound 操作的线程模型:

图 3 Netty 3 Outbound 操作线程模型

从上图可以看出,Outbound 操作的主要处理流程如下:

  1. 业务线程发起 Channel Write 操作,发送消息。

  2. Netty 将写操作封装成写事件,触发事件向下传播。

  3. 写事件被调度到 ChannelPipeline 中,由业务线程按照 Handler Chain 串行调用支持 Downstream 事件的 Channel Handler。

  4. 执行到系统最后一个 ChannelHandler,将编码后的消息 Push 到发送队列中,业务线程返回。

  5. Netty 的 I/O 线程从发送消息队列中取出消息,调用 SocketChannel 的 write 方法进行消息发送。

2.1.2 Netty 4.X 版本线程模型

相比于 Netty 3.X 系列版本,Netty 4.X 的 I/O 操作线程模型比较简答,它的原理图如下所示:

图 4 Netty 4 Inbound 和 Outbound 操作线程模型

从上图可以看出,Outbound 操作的主要处理流程如下:

  1. I/O 线程 NioEventLoop 从 SocketChannel 中读取数据报,将 ByteBuf 投递到 ChannelPipeline,触发 ChannelRead 事件。

  2. I/O 线程 NioEventLoop 调用 ChannelHandler 链,直到将消息投递到业务线程,然后 I/O 线程返回,继续后续的读写操作。

  3. 业务线程调用 ChannelHandlerContext.write(Object msg) 方法进行消息发送。

  4. 如果是由业务线程发起的写操作,ChannelHandlerInvoker 将发送消息封装成 Task,放入到 I/O 线程 NioEventLoop 的任务队列中,由 NioEventLoop 在循环中统一调度和执行。放入任务队列之后,业务线程返回。

  5. I/O 线程 NioEventLoop 调用 ChannelHandler 链,进行消息发送,处理 Outbound 事件,直到将消息放入发送队列,然后唤醒 Selector,进而执行写操作。

通过流程分析,我们发现 Netty 4 修改了线程模型,无论是 Inbound 还是 Outbound 操作,统一由 I/O 线程 NioEventLoop 调度执行。

2.1.3 新老线程模型对比

在进行新老版本线程模型对比之前,首先还是要熟悉下串行化设计的理念:

我们知道当系统在运行过程中,如果频繁的进行线程上下文切换,会带来额外的性能损耗。多线程并发执行某个业务流程,业务开发者还需要时刻对线程安全保持警惕,哪些数据可能会被并发修改,如何保护?这不仅降低了开发效率,也会带来额外的性能损耗。

为了解决上述问题,Netty 4 采用了串行化设计理念,从消息的读取、编码以及后续 Handler 的执行,始终都由 I/O 线程 NioEventLoop 负责,这就意外着整个流程不会进行线程上下文的切换,数据也不会面临被并发修改的风险,对于用户而言,甚至不需要了解 Netty 的线程细节,这确实是个非常好的设计理念,它的工作原理图如下:

图 5 Netty 4 的串行化设计理念

一个 NioEventLoop 聚合了一个多路复用器 Selector,因此可以处理成百上千的客户端连接,Netty 的处理策略是每当有一个新的客户端接入,则从 NioEventLoop 线程组中顺序获取一个可用的 NioEventLoop,当到达数组上限之后,重新返回到 0,通过这种方式,可以基本保证各个 NioEventLoop 的负载均衡。一个客户端连接只注册到一个 NioEventLoop 上,这样就避免了多个 I/O 线程去并发操作它。

Netty 通过串行化设计理念降低了用户的开发难度,提升了处理性能。利用线程组实现了多个串行化线程水平并行执行,线程之间并没有交集,这样既可以充分利用多核提升并行处理能力,同时避免了线程上下文的切换和并发保护带来的额外性能损耗。

了解完了 Netty 4 的串行化设计理念之后,我们继续看 Netty 3 线程模型存在的问题,总结起来,它的主要问题如下:

  1. Inbound 和 Outbound 实质都是 I/O 相关的操作,它们的线程模型竟然不统一,这给用户带来了更多的学习和使用成本。

  2. Outbound 操作由业务线程执行,通常业务会使用线程池并行处理业务消息,这就意味着在某一个时刻会有多个业务线程同时操作 ChannelHandler,我们需要对 ChannelHandler 进行并发保护,通常需要加锁。如果同步块的范围不当,可能会导致严重的性能瓶颈,这对开发者的技能要求非常高,降低了开发效率。

  3. Outbound 操作过程中,例如消息编码异常,会产生 Exception,它会被转换成 Inbound 的 Exception 并通知到 ChannelPipeline,这就意味着业务线程发起了 Inbound 操作!它打破了 Inbound 操作由 I/O 线程操作的模型,如果开发者按照 Inbound 操作只会由一个 I/O 线程执行的约束进行设计,则会发生线程并发访问安全问题。由于该场景只在特定异常时发生,因此错误非常隐蔽!一旦在生产环境中发生此类线程并发问题,定位难度和成本都非常大。

讲了这么多,似乎 Netty 4 完胜 Netty 3 的线程模型,其实并不尽然。在特定的场景下,Netty 3 的性能可能更高,如果编码和其它 Outbound 操作非常耗时,由多个业务线程并发执行,性能肯定高于单个 NioEventLoop 线程。

但是,这种性能优势不是不可逆转的,如果我们修改业务代码,将耗时的 Handler 操作前置,Outbound 操作不做复杂业务逻辑处理,性能同样不输于 Netty 3, 但是考虑内存池优化、不会反复创建 Event、不需要对 Handler 加锁等 Netty 4 的优化,整体性能 Netty 4 版本肯定会更高。

2.2 Netty 4.X 版本线程模型实践经验

2.2.1 时间可控的简单业务直接在 I/O 线程上处理

如果业务非常简单,执行时间非常短,不需要与外部网元交互、访问数据库和磁盘,不需要等待其它资源,则建议直接在业务 ChannelHandler 中执行,不需要再启业务的线程或者线程池。避免线程上下文切换,也不存在线程并发问题。

2.2.2 复杂和时间不可控业务建议投递到后端业务线程池统一处理

对于此类业务,不建议直接在业务 ChannelHandler 中启动线程或者线程池处理,建议将不同的业务统一封装成 Task,统一投递到后端的业务线程池中进行处理。

过多的业务 ChannelHandler 会带来开发效率和可维护性问题,不要把 Netty 当作业务容器,对于大多数复杂的业务产品,仍然需要集成或者开发自己的业务容器,做好和 Netty 的架构分层。

2.2.3 业务线程避免直接操作 ChannelHandler

对于 ChannelHandler,I/O 线程和业务线程都可能会操作,因为业务通常是多线程模型,这样就会存在多线程操作 ChannelHandler。为了尽量避免多线程并发问题,建议按照 Netty 自身的做法,通过将操作封装成独立的 Task 由 NioEventLoop 统一执行,而不是业务线程直接操作。

3. gRPC 线程模型

gRPC 的线程模型主要包括服务端线程模型和客户端线程模型,其中服务端线程模型主要包括:

  • 服务端监听和客户端接入线程(HTTP /2 Acceptor)。

  • 网络 I/O 读写线程。

  • 服务接口调用线程。

客户端线程模型主要包括:

  • 客户端连接线程(HTTP/2 Connector)。

  • 网络 I/O 读写线程。

  • 接口调用线程。

  • 响应回调通知线程。

3.1 服务端线程模型

gRPC 服务端线程模型整体上可以分为两大类:

  • 网络通信相关的线程模型,基于 Netty4.1 的线程模型实现。

  • 服务接口调用线程模型,基于 JDK 线程池实现。

3.1.1 服务端线程模型概述

gRPC 服务端线程模型和交互图如下所示:

图 6 gRPC 服务端线程模型

其中,HTTP/2 服务端创建、HTTP/2 请求消息的接入和响应发送都由 Netty 负责,gRPC 消息的序列化和反序列化、以及应用服务接口的调用由 gRPC 的 SerializingExecutor 线程池负责。

3.1.2 服务调度线程模型

gRPC 服务调度线程主要职责如下:

  • 请求消息的反序列化,主要包括:HTTP/2 Header 的反序列化,以及将 PB(Body) 反序列化为请求对象。

  • 服务接口的调用,method.invoke(非反射机制)。

  • 将响应消息封装成 WriteQueue.QueuedCommand,写入到 Netty Channel 中,同时,对响应 Header 和 Body 对象做序列化。

服务端调度的核心是 SerializingExecutor,它同时实现了 JDK 的 Executor 和 Runnable 接口,既是一个线程池,同时也是一个 Task。

SerializingExecutor 聚合了 JDK 的 Executor,由 Executor 负责 Runnable 的执行,代码示例如下:

其中,Executor 默认使用的是 JDK 的 CachedThreadPool,在构建 ServerImpl 的时候进行初始化,代码如下:

当服务端接收到客户端 HTTP/2 请求消息时,由 Netty 的 NioEventLoop 线程切换到 gRPC 的 SerializingExecutor,进行消息的反序列化、以及服务接口的调用,代码示例如下:

相关的调用堆栈,示例如下:

响应消息的发送,由 SerializingExecutor 发起,将响应消息头和消息体序列化,然后分别封装成 SendResponseHeadersCommand 和 SendGrpcFrameCommand,调用 Netty NioSocketChannle 的 write 方法,发送到 Netty 的 ChannelPipeline 中,由 gRPC 的 NettyServerHandler 拦截之后,真正写入到 SocketChannel 中,代码如下所示:

响应消息体的发送堆栈如下所示:

Netty I/O 线程和服务调度线程的运行分工界面以及切换点如下所示:

图 7 网络 I/O 线程和服务调度线程交互图

事实上,在实际服务接口调用过程中,NIO 线程和服务调用线程切换次数远远超过 4 次,频繁的线程切换对 gRPC 的性能带来了一定的损耗。

3.2 客户端线程模型

gRPC 客户端的线程主要分为三类:

  1. 业务调用线程。

  2. 客户端连接和 I/O 读写线程。

  3. 请求消息业务处理和响应回调线程。

3.2.1 客户端线程模型概述

gRPC 客户端线程模型工作原理如下图所示(同步阻塞调用为例):

图 8 客户端调用线程模型

客户端调用主要涉及的线程包括:

  • 应用线程,负责调用 gRPC 服务端并获取响应,其中请求消息的序列化由该线程负责。

  • 客户端负载均衡以及 Netty Client 创建,由 grpc-default-executor 线程池负责。

  • HTTP/2 客户端链路创建、网络 I/O 数据的读写,由 Netty NioEventLoop 线程负责。

  • 响应消息的反序列化由 SerializingExecutor 负责,与服务端不同的是,客户端使用的是 ThreadlessExecutor,并非 JDK 线程池。

  • SerializingExecutor 通过调用 responseFuture 的 set(value),唤醒阻塞的应用线程,完成一次 RPC 调用。

3.2.2 客户端调用线程模型

客户端调用线程交互流程如下所示:

图 9 客户端线程交互原理图

请求消息的发送由用户线程发起,相关代码示例如下:

HTTP/2 Header 的创建、以及请求参数反序列化为 Protobuf,均由用户线程负责完成,相关代码示例如下:

用户线程将请求消息封装成 CreateStreamCommand 和 SendGrpcFrameCommand,发送到 Netty 的 ChannelPipeline 中,然后返回,完成线程切换。后续操作由 Netty NIO 线程负责,相关代码示例如下:

客户端响应消息的接收,由 gRPC 的 NettyClientHandler 负责,相关代码如下所示:

接收到 HTTP/2 响应之后,Netty 将消息投递到 SerializingExecutor,由 SerializingExecutor 的 ThreadlessExecutor 负责响应的反序列化,以及 responseFuture 的设值,相关代码示例如下:

3.3 线程模型总结

消息的序列化和反序列化均由 gRPC 线程负责,而没有在 Netty 的 Handler 中做 CodeC,原因如下:Netty4 优化了线程模型,所有业务 Handler 都由 Netty 的 I/O 线程负责,通过串行化的方式消除锁竞争,原理如下所示:

图 10 Netty4 串行执行 Handler

如果大量的 Handler 都在 Netty I/O 线程中执行,一旦某些 Handler 执行比较耗时,则可能会反向影响 I/O 操作的执行,像序列化和反序列化操作,都是 CPU 密集型操作,更适合在业务应用线程池中执行,提升并发处理能力。因此,gRPC 并没有在 I/O 线程中做消息的序列化和反序列化。

4. Apache ServiceComb 微服务框架线程模型

Apache ServiceComb 底层通信框架基于 Vert.X(Netty) 构建,它重用了 Netty 的 EventLoop 线程模型,考虑到目前同步 RPC 调用仍然是主流模式,因此,针对同步 RPC 调用,在 Vert.X 线程模型基础之上,提供了额外的线程模型封装。

下面我们分别对同步和异步模式的线程模型进行分析。

4.1 同步模式

核心设计理念是 I/O 线程(协议栈)和微服务调用线程分离,线程调度模型如下所示:

图 11 ServiceComb 内置线程池

同步模式下 ServiceComb 的线程模型特点如下:

  1. 线程池用于执行同步模式的业务逻辑。

  2. 网络收发及 reactive 模式的业务逻辑在 Eventloop 中执行,与线程池无关。

  3. 默认所有同步方法都在一个全局内置线程池中执行。

  4. 如果业务有特殊的需求,可以指定使用自定义的全局线程池,并且可以根据 schemaId 或 operationId 指定各自使用独立的线程池,实现隔离仓的效果。

基于 ServiceComb 定制线程池策略实现的微服务隔离仓效果如下所示:

图 12 基于 ServiceComb 的微服务故障隔离仓

4.2 异步模式

ServiceComb 的异步模式即纯 Reactive 机制,它的代码示例如下:

复制代码
public interface Intf{
CompletableFuture< String> hello( String name);
}
@GetMapping(path = "/hello/{name}") public CompletableFuture< String> hello( @PathVariable(name = "name") String name){
CompletableFuture< String> future = new CompletableFuture<>();
intf.hello(name).whenComplete((result, exception) -> {
if (exception == null) {
future.complete( "from remote: " + result);
return;
}
future.completeExceptionally(exception);
});
return future;

与之对应的线程调度流程如下所示:

图 13 基于 ServiceComb 的 Reactive 线程模型

它的特点总结如下:

  1. 所有功能都在 eventloop 中执行,并不会进行线程切换。

  2. 橙色箭头走完后,对本线程的占用即完成了,不会阻塞等待应答,该线程可以处理其他任务。

  3. 当收到远端应答后,由网络数据驱动开始走红色箭头的应答流程。

  4. 只要有任务,线程就不会停止,会一直执行任务,可以充分利用 cpu 资源,也不会产生多余的线程切换,去无谓地消耗 cpu。

4.3. 线程模型总结

ServiceComb 的同步和异步 RPC 调用对应的线程模型存在差异,对于纯 Reactive 的异步,I/O 读写与微服务业务逻辑执行共用同一个 EventLoop 线程,在一次服务端 RPC 调用时不存在线程切换,性能最优。但是,这种模式也存在一些约束,例如要求微服务业务逻辑执行过程中不能有任何可能会导致同步阻塞的操作,包括但不限于数据库操作、缓存读写、第三方 HTTP 服务调用、本地 I/O 读写等(本质就是要求全栈异步)。

对于无法做到全栈异步的业务,可以使用 ServiceComb 同步编程模型,同时根据不同微服务接口的重要性和优先级,利用定制线程池策略,实现接口级的线程隔离。

需要指出的是,ServiceComb 根据接口定义来决定采用哪种线程模型,如果返回值是 CompletableFuture,业务又没有对接口指定额外的线程池,则默认采用 Reactive 模式,即业务微服务接口由 Vert.X 的 EventLoop 线程执行。

5. 作者简介

李林锋,10 年 Java NIO、平台中间件设计和开发经验,精通 Netty、Mina、分布式服务框架、API Gateway、PaaS 等,《Netty 进阶之路》、《分布式服务框架原理与实践》作者。目前在华为终端应用市场负责业务微服务化、云化、全球化等相关设计和开发工作。

联系方式:新浪微博 Nettying 微信:Nettying

Email:neu_lilinfeng@sina.com

文章评论