《Netty 进阶之路》、《分布式服务框架原理与实践》作者李林锋深入剖析Netty和RPC框架线程模型。李林锋已在 InfoQ 上开设 Netty 专题持续出稿,感兴趣的同学可以持续关注。
1. 背景
1.1 线程模型的重要性
对于RPC框架而言,影响其性能指标的主要有三个要素:
I/O模型:采用的是同步BIO、还是非阻塞的NIO、以及全异步的事件驱动I/O(AIO)。
协议和序列化方式:它主要影响消息的序列化、反序列化性能,以及消息的通信效率。
线程模型:主要影响消息的读取和发送效率、以及调度的性能。
除了对性能有影响,在一些场景下,线程模型的变化也会影响到功能的正确性,例如Netty从3.X版本升级到4.X版本之后,重构和优化了线程模型。当业务没有意识到线程模型发生变化时,就会踩到一些性能和功能方面的坑。
1.2 Netty和RPC框架的线程模型关系
作为一个高性能的NIO通信框架,Netty主要关注的是I/O通信相关的线程工作策略,以及提供的用户扩展点ChannelHandler的执行策略,示例如下:
图1 Netty 多线程模型
该线程模型的工作特点如下:
有专门一个(一组)NIO线程-Acceptor线程用于监听服务端,接收客户端的TCP连接请求。
网络I/O操作-读、写等由一个NIO线程池负责,线程池可以采用标准的JDK线程池实现,它包含一个任务队列和N个可用的线程,由这些NIO线程负责消息的读取、解码、编码和发送。
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操作线程模型比较复杂,它的处理模型包括两部分:
Inbound:主要包括链路建立事件、链路激活事件、读事件、I/O异常事件、链路关闭事件等。
Outbound:主要包括写事件、连接事件、监听绑定事件、刷新事件等。
我们首先分析下Inbound操作的线程模型:
图2 Netty 3 Inbound操作线程模型
从上图可以看出,Inbound操作的主要处理流程如下:
I/O线程(Work线程)将消息从TCP缓冲区读取到SocketChannel的接收缓冲区中。
由I/O线程负责生成相应的事件,触发事件向上执行,调度到ChannelPipeline中。
I/O线程调度执行ChannelPipeline中Handler链的对应方法,直到业务实现的Last Handler。
Last Handler将消息封装成Runnable,放入到业务线程池中执行,I/O线程返回,继续读/写等I/O操作。
业务线程池从任务队列中弹出消息,并发执行业务逻辑。
通过对Netty 3的Inbound操作进行分析我们可以看出,Inbound的Handler都是由Netty的I/O Work线程负责执行。
下面我们继续分析Outbound操作的线程模型:
图3 Netty 3 Outbound操作线程模型
从上图可以看出,Outbound操作的主要处理流程如下:
业务线程发起Channel Write操作,发送消息。
Netty将写操作封装成写事件,触发事件向下传播。
写事件被调度到ChannelPipeline中,由业务线程按照Handler Chain串行调用支持Downstream事件的Channel Handler。
执行到系统最后一个ChannelHandler,将编码后的消息Push到发送队列中,业务线程返回。
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操作的主要处理流程如下:
I/O线程NioEventLoop从SocketChannel中读取数据报,将ByteBuf投递到ChannelPipeline,触发ChannelRead事件。
I/O线程NioEventLoop调用ChannelHandler链,直到将消息投递到业务线程,然后I/O线程返回,继续后续的读写操作。
业务线程调用ChannelHandlerContext.write(Object msg)方法进行消息发送。
如果是由业务线程发起的写操作,ChannelHandlerInvoker将发送消息封装成Task,放入到I/O线程NioEventLoop的任务队列中,由NioEventLoop在循环中统一调度和执行。放入任务队列之后,业务线程返回。
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线程模型存在的问题,总结起来,它的主要问题如下:
Inbound和Outbound实质都是I/O相关的操作,它们的线程模型竟然不统一,这给用户带来了更多的学习和使用成本。
Outbound操作由业务线程执行,通常业务会使用线程池并行处理业务消息,这就意味着在某一个时刻会有多个业务线程同时操作ChannelHandler,我们需要对ChannelHandler进行并发保护,通常需要加锁。如果同步块的范围不当,可能会导致严重的性能瓶颈,这对开发者的技能要求非常高,降低了开发效率。
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客户端的线程主要分为三类:
业务调用线程。
客户端连接和I/O读写线程。
请求消息业务处理和响应回调线程。
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的线程模型特点如下:
线程池用于执行同步模式的业务逻辑。
网络收发及reactive模式的业务逻辑在Eventloop中执行,与线程池无关。
默认所有同步方法都在一个全局内置线程池中执行。
如果业务有特殊的需求,可以指定使用自定义的全局线程池,并且可以根据schemaId或operationId指定各自使用独立的线程池,实现隔离仓的效果。
基于ServiceComb定制线程池策略实现的微服务隔离仓效果如下所示:
图12 基于ServiceComb的微服务故障隔离仓
4.2 异步模式
ServiceComb的异步模式即纯Reactive机制,它的代码示例如下:
1
2 1public interface Intf{ CompletableFuture\u0026lt;String\u0026gt; hello(String name);}@GetMapping(path = \u0026quot;/hello/{name}\u0026quot;)public CompletableFuture\u0026lt;String\u0026gt; hello(@PathVariable(name = \u0026quot;name\u0026quot;) String name){ CompletableFuture\u0026lt;String\u0026gt; future = new CompletableFuture\u0026lt;\u0026gt;(); intf.hello(name).whenComplete((result, exception) -\u0026gt; { if (exception == null) { future.complete(\u0026quot;from remote: \u0026quot; + result); return; } future.completeExceptionally(exception); }); return future;
2
与之对应的线程调度流程如下所示:
图13 基于ServiceComb的Reactive线程模型
它的特点总结如下:
所有功能都在eventloop中执行,并不会进行线程切换。
橙色箭头走完后,对本线程的占用即完成了,不会阻塞等待应答,该线程可以处理其他任务。
当收到远端应答后,由网络数据驱动开始走红色箭头的应答流程。
只要有任务,线程就不会停止,会一直执行任务,可以充分利用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