实践案例丨Netty案例集锦之多线程篇(续)

Posted InfoQ

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了实践案例丨Netty案例集锦之多线程篇(续)相关的知识,希望对你有一定的参考价值。

Netty构建推送服务问题


1.1. 问题描述


最近在使用Netty构建推送服务的过程中,遇到了一个问题如何正确的处理业务逻辑?问题主要来源于文章《Netty系列之Netty线程模型》,文中提到 “2.4Netty线程开发最佳实践中 2.4.2复杂和时间不可控业务建议投递到后端业务线程池统一处理。对于此类业务,不建议直接在业务ChannelHandler中启动线程或者线程池处理,建议将不同的业务统一封装成Task,统一投递到后端的业务线程池中进行处理。”


不太理解“统一投递到后端的业务线程池中进行处理”具体如何操作?像下面这样做是否可行:

private ExecutorService 
executorService =
Executors.newFixedThreadPool(4);
@Override public void channelRead
(final ChannelHandlerContext ctx,
final Object msg)
throws Exception
{executorService.execute
(new Runnable()
{@Override public void run()
{doSomething();

真实生产环境中如何将业务逻辑与Netty网络处理部分很好的作隔离,有没有通用的做法?


  • 关注InfoQ[ID:infoqchina]了解更多技术领域干货内容。

1.2. 答疑解惑


Netty的ChannelHandler链由I/O线程执行,如果在I/O线程做复杂的业务逻辑操作,可能会导致I/O线程无法及时进行read()或者write()操作。所以,比较通用的做法如下:


  • 在ChannelHanlder的Codec中进行编解码,由I/O线程做CodeC;

  • 将数据报反序列化成业务Object对象之后,将业务消息封装到Task中,投递到业务线程池中进行处理,I/O线程返回。


不建议的做法:


实践案例丨Netty案例集锦之多线程篇(续)

图1-1 不推荐业务和I/O线程共用同一个线程


推荐做法:


实践案例丨Netty案例集锦之多线程篇(续)

图1-2 建议业务线程和I/O线程隔离


1.3. 问题总结


事实上,并不是说业务ChannelHandler一定不能由NioEventLoop线程执行,如果业务ChannelHandler处理逻辑比较简单,执行时间是受控的,业务I/O线程的负载也不重,在这种应用场景下,业务ChannelHandler可以和I/O操作共享同一个线程。使用这种线程模型会带来两个优势:


  1. 开发简单:开发业务ChannelHandler的不需要关注Netty的线程模型,只负责ChannelHandler的业务逻辑开发和编排即可,对开发人员的技能要求会低一些;

  2. 性能更高:因为减少了一次线程上下文切换,所以性能会更高。


在实际项目开发中,一些开发人员往往喜欢照葫芦画瓢,并不会分析自己的ChannelHandler更适合在哪种线程模型下处理。如果在ChannelHandler中进行数据库等同步I/O操作,很有可能会导致通信模块被阻塞。所以,选择什么样的线程模型还需要根据项目的具体情况而定,一种比较好的做法是支持策略配置,例如阿里的Dubbo,支持通过配置化的方式让用户选择业务在I/O线程池还是业务线程池中执行,比较灵活。


Netty客户端连接问题


2.1. 问题描述


Netty客户端想同时连接多个服务端,使用如下方式,是否可行,我简单测试了下,暂时没有发现问题。代码如下:


EventLoopGroup group = 
new NioEventLoopGroup(); try { Bootstrap b = new Bootstrap(); b.group(group) ......代码省略
// Start the client. ChannelFuture f1 =
b.connect(HOST, PORT); ChannelFuture f2 =
b.connect(HOST2, PORT2);
// Wait until
the connection is closed.
f1.channel().closeFuture().sync(); f2.channel().closeFuture().sync(); ......代码省略
}


2.2. 答疑解惑


上述代码没有问题,原因是尽管Bootstrap自身不是线程安全的,但是执行Bootstrap的连接操作是串行执行的,而且connect(String inetHost, int inetPort)方法本身是线程安全的,它会创建一个新的NiosocketChannel,并从初始构造的EventLoopGroup中选择一个NioEventLoop线程执行真正的Channel连接操作,与执行Bootstrap的线程无关,所以通过一个Bootstrap连续发起多个连接操作是安全的,它的原理如下:

实践案例丨Netty案例集锦之多线程篇(续)

图2-1 Netty BootStrap工作原理


2.3. 问题总结


注意事项-资源释放问题: 在同一个Bootstrap中连续创建多个客户端连接,需要注意的是EventLoopGroup是共享的,也就是说这些连接共用一个NIO线程组EventLoopGroup,当某个链路发生异常或者关闭时,只需要关闭并释放Channel本身即可,不能同时销毁Channel所使用的NioEventLoop和所在的线程组EventLoopGroup,例如下面的代码片段就是错误的:

ChannelFuture f1 =
b.connect(HOST, PORT); ChannelFuture f2 =
b.connect(HOST2, PORT2); f1.channel().closeFuture().sync(); } finally { group.shutdownGracefully(); }

线程安全问题: 需要指出的是Bootstrap不是线程安全的,因此在多个线程中并发操作Bootstrap是一件非常危险的事情,Bootstrap是I/O操作工具类,它自身的逻辑处理非常简单,真正的I/O操作都是由EventLoop线程负责的,所以通常多线程操作同一个Bootstrap实例也是没有意义的,而且容易出错,错误代码如下:

Bootstrap b = new Bootstrap();
{
//多线程执行初始化、连接等操作
}
性能数据统计不准确案例


3.1. 问题描述


某生产环境在业务高峰期,偶现服务调用时延突刺问题,时延突然增大的服务没有固定规律,比例虽然很低,但是对客户的体验影响很大,需要尽快定位出问题原因并解决。


3.2. 问题分析


服务调用时延增大,但并不是异常,因此运行日志并不会打印ERROR日志,单靠传统的日志无法进行有效问题定位。利用分布式消息跟踪系统魔镜,进行分

布式环境的故障定界。


通过对服务调用时延进行排序和过滤,找出时延增大的服务调用链详细信息,发现业务服务端处理很快,但是消费者统计数据却显示服务端处理非常慢,调用链两端看到的数据不一致,怎么回事?


对调用链的埋点日志进行分析发现,服务端打印的时延是业务服务接口调用的时延,并没有包含:

  • 通信端读取数据报、消息解码和内部消息投递、队列排队的时间

  • 通信端编码业务消息、在通信线程队列排队时间、消息发送到Socket的时间


调用链的工作原理如下:


实践案例丨Netty案例集锦之多线程篇(续)

图3-1 调用链工作原理


将调用链中的消息调度过程详细展开,以服务端读取请求消息为例进行说明,如下图所示:


实践案例丨Netty案例集锦之多线程篇(续)

图3-2 性能统计日志埋点


优化调用链埋点日志,措施如下:

  • 包含客户端和服务端消息编码和解码的耗时

  • 包含请求和应答消息在业务线程池队列中的排队时间;

  • 包含请求和应答消息在通信线程发送队列(数组)中的排队时间


同时,为了方便问题定位,我们需要打印输出Netty的性能统计日志,主要包括:


  • 每条链路接收的总字节数、周期T接收的字节数、消息接收CAPs

  • 每条链路发送的总字节数、周期T发送的字节数、消息发送CAPs


优化之后,上线运行一天之后,我们通过分析比对Netty性能统计日志、调用链日志,发现双方的数据并不一致,Netty性能统计日志统计到的数据与前端门户看到的也不一致,因为怀疑是新增的性能统计功能存在BUG,继续问题定位。

首先对消息发送功能进行CodeReview,发现代码调用完writeAndFlush之后直接对发送的请求消息字节数进行计数,代码如下:


实践案例丨Netty案例集锦之多线程篇(续)


实际上,调用writeAndFlush并不意味着消息已经发送到网络上,它的功能分解如下:


实践案例丨Netty案例集锦之多线程篇(续)

图3-3 writeAndFlush 工作原理图


通过对writeAndFlush方法展开分析,我们发现性能统计代码存在如下几个问题:


  • 业务ChannelHandler的执行时间

  • ByteBuf在ChannelOutboundBuffer 数组中排队时间

  • NioEventLoop线程调度时间,它不仅仅只处理消息发送,还负责数据报读取、定时任务执行以及业务定制的其它I/O任务

  • JDK NIO类库将ByteBuffer写入到网络的时间,包括单条消息的多次写半包


由于性能统计遗漏了上述4个步骤的执行时间,因此统计出来的性能比实际值更高,这会干扰我们的问题定位。


3.3. 问题总结


其它常见性能统计误区汇总:


  1. 调用write 方法之后就开始统计发送速率,示例代码如下:


实践案例丨Netty案例集锦之多线程篇(续)

2. 消息编码时进行性能统计,示例代码如下:


实践案例丨Netty案例集锦之多线程篇(续)


编码之后,获取out可读的字节数,然后做累加。编码完成,ByteBuf并没有被加入到发送队列(数组)中,因此在此时做性能统计仍然是不准的。


正确的做法:

  1. 调用writeAndFlush方法之后获取ChannelFuture;

  2. 新增消息发送ChannelFutureListener,监听消息发送结果,如果消息写入网络Socket成功,则Netty会回调ChannelFutureListener的operationComplete方法;

  3. 在消息发送ChannelFutureListener的operationComplete方法中进行性能统计。


示例代码如下:


实践案例丨Netty案例集锦之多线程篇(续)


问题定位出来之后,按照正确的做法对Netty性能统计代码进行了修正,上线之后,结合调用链日志,很快定位出了业务高峰期偶现的部分服务时延毛刺较大问题,优化业务线程池参数配置之后问题得到解决。


3.4. 举一反三


除了消息发送性能统计之外,Netty数据报的读取、消息接收QPS性能统计也非常容易出错,我们第一版性能统计代码消息接收CAPs也不准确,大家知道为什么吗?这个留作问题,供大家自己思考。


Netty线程数膨胀案例


4.1. 问题描述


分布式服务框架在进行现网问题定位时,Dump 线程堆栈之后发现Netty的NIO线程竟然有3000多个,大量的NIO线程占用了系统的句柄资源、内存资源、CPU资源等,引发了一些其它问题,需要尽快查明原因并解决线程过多问题。


4.2. 问题分析


在研发环境中模拟现网组网和业务场景,使用jmc工具进行问题定位,

使用飞行记录器对系统运行状况做快照,模拟示例图如下所示:


实践案例丨Netty案例集锦之多线程篇(续)

图4-1 使用jmc工具进行问题定位


获取到黑匣子数据之后,可以对系统的各种重要指标做分析,包括系统数据、内存、GC数据、线程运行状态和数据等,如下图所示:


实践案例丨Netty案例集锦之多线程篇(续)

图4-2 获取系统资源占用详细数据


实践案例丨Netty案例集锦之多线程篇(续)

图4-3 Netty线程占用超过3000个

通过对线程堆栈分析,我们发现Netty的NioEventLoop线程超过了3000个!


对服务框架协议栈的Netty客户端和服务端源码进行CodeReview,发现了问题所在:


  • 客户端每连接1个服务端,就会创建1个新的NioEventLoopGroup,并设置它的线程数为1;

  • 现网有300个+节点,节点之间采用多链路(10个链路),由于业务采用了随机路由,最终每个消费者需要跟其它200多个节点建立长连接,加上自己服务端也需要占用一些NioEventLoop线程,最终客户端单进程线程数膨胀到了3000多个。


业务的伪代码如下:

for(Link linkE : links)
   {
   EventLoopGroup group = 
new NioEventLoopGroup(1); Bootstrap b =
new Bootstrap(); b.group(group) .channel(NioSocketChannel.class) .option
(ChannelOption.TCP_NODELAY, true)
// 此处省略.....b.connect
(linkE.localAddress,
linkE.remoteAddress);
}

如果客户端对每个链路连接都创建一个新的NioEventLoopGroup,则每个链路就会占用1个独立的NIO线程,最终沦为 1连接:1线程 这种同步阻塞模式线程模型。随着集群组网规模的不断扩大,这会带来严重的线程膨胀问题,最终会发生句柄耗尽无法创建新的线程,或者栈内存溢出。


从另一个角度看,1个NIO线程只处理一条链路也体现不出非阻塞I/O的优势。案例中的错误线程模型如下所示:


图4-4 错误的客户端连接线程使用方式


4.3. 案例总结


无论是服务端监听多个端口,还是客户端连接多个服务端,都需要注意必须要重用NIO线程,否则就会导致线程资源浪费,在大规模组网时还会存在句柄耗尽或者栈溢出等问题。


Netty官方Demo仅仅是个Sample,对用户而言,必须理解Netty的线程模型,否则很容易按照官方Demo的做法,在外层套个For循环连接多个服务端,然后,悲剧就这样发生了。


修正案例中的问题非常简单,原理如下:


图4-5 正确的客户端连接线程模型

作者 李林锋,2007年毕业于东北大学,2008年进入华为公司从事高性能通信软件的设计和开发工作,有7年NIO设计和开发经验,精通Netty、Mina等NIO框架和平台中间件,现任华为软件平台架构部架构师,《Netty权威指南》作者。目前从事华为下一代中间件和PaaS平台的架构设计工作。


  • 版权归属InfoQ,禁止商业用途抄袭转载


荐文






自荐


『 InfoQ原创作者招募第一期 』

如果你自信是技术人才,有领域专长,有从业经验,有职业态度,有观点灼见,来InfoQ原创专家作者团,写你感兴趣的文章,分享你独到的观点,给有共鸣的人看……

怎么加入?

联系微信[muyu4444]

添加请注明原创作者


点击“阅读原文”查看更多

以上是关于实践案例丨Netty案例集锦之多线程篇(续)的主要内容,如果未能解决你的问题,请参考以下文章

Netty案例集锦(并发编程篇)终于来了

我为什么要写一本Netty案例实践方面的书

SpringBoot开发案例之多任务并行+线程池处理

爬虫之多线程案例

案例分享Netty线程安全疑问

Netty4.x实战专题案例文章列表