QCon北京|3天沉浸式学习,跳出信息茧房。 了解详情
写点什么

Netty 案例集锦之多线程篇

  • 2015-09-03
  • 本文字数:6308 字

    阅读完需:约 21 分钟

1. Netty 案例集锦系列文章介绍

1.1. Netty 的特点

Netty 入门比较简单,主要原因有如下几点:

  1. Netty 的 API 封装比较简单,将复杂的网络通信通过 BootStrap 等工具类做了二次封装,用户使用起来比较简单;
  2. Netty 源码自带的 Demo 比较多,通过 Demo 可以很快入门;
  3. Netty 社区资料、相关学习书籍也比较多,学习资料比较丰富。

但是很多入门之后的 Netty 学习者遇到了很多困惑,例如不知道在实际项目中如何使用 Netty、遇到 Netty 问题之后无从定位等,这些问题严重制约了对 Netty 的深入掌握和实际项目应用。

Netty 相关问题比较难定位的主要原因如下:

1) NIO 编程自身的复杂性,涉及到大量 NIO 类库、Netty 自身封装的类库等,当你需要打开黑盒定位问题时,必须对这些类库了如指掌;否则即便定位到问题所在,也不知所以然,更无法修复;

2) Netty 复杂的多线程模型,用户在实际使用 Netty 时,会涉及到 Netty 自己封装的线程组、线程池、NIO 线程,以及业务线程,通信链路的创建、I/O 消息的读写会涉及到复杂的线程切换,这会让初学者云山雾绕,调试起来非常痛苦,甚至都不知道从哪里调试;

3) Netty 版本的跨度大,从实际商用情况看,涉及到了 Netty 3.X、4.X 和 5.X 等多个版本,每个 Major 版本之间特性变化非常大,即便是 Minor 版本都存在一些差异,这些功能特性和类库差异会给使用者带来很多问题,版本升级之后稍有不慎就会掉入陷阱。

1.2. 案例来源

Netty 案例集锦的案例来源于作者在实际项目中遇到的问题总结、以及 Netty 社区网友的反馈,大多数案例都来源于实际项目,也有少部分是读者在学习 Netty 中遭遇的比较典型的问题。

1.3. 多线程篇

学习和掌握 Netty 多线程模型是个难点,在实际项目中如何使用好 Netty 多线程更加困难,很多网上问题和事故都来源于对 Netty 线程模型了解不透彻所致。鉴于此,Netty 案例集锦系列就首先从多线程方面开始。

2. Netty 3 版本升级遭遇内存泄漏案例

2.1. 问题描述

业务代码升级 Netty 3 到 Netty4 之后,运行一段时间,Java 进程就会宕机,查看系统运行日志发现系统发生了内存泄露(示例堆栈):

图 2-1 内存泄漏堆栈

对内存进行监控(切换使用堆内存池,方便对内存进行监控),发现堆内存一直飙升,如下所示(示例堆内存监控):

图 2-2 堆内存监控示例

2.2. 问题定位

使用 jmap -dump:format=b,file=netty.bin PID 将堆内存 dump 出来,通过 IBM 的 HeapAnalyzer 工具进行分析,发现 ByteBuf 发生了泄露。

因为使用了 Netty 4 的内存池,所以首先怀疑是不是申请的 ByteBuf 没有被释放导致?查看代码,发现消息发送完成之后,Netty 底层已经调用 ReferenceCountUtil.release(message) 对内存进行了释放。这是怎么回事呢?难道 Netty 4.X 的内存池有 Bug,调用 release 操作释放内存失败?

考虑到 Netty 内存池自身 Bug 的可能性不大,首先从业务的使用方式入手分析:

  1. 内存的分配是在业务代码中进行,由于使用到了业务线程池做 I/O 操作和业务操作的隔离,实际上内存是在业务线程中分配的;
  2. 内存的释放操作是在 outbound 中进行,按照 Netty 3 的线程模型,downstream(对应 Netty 4 的 outbound,Netty 4 取消了 upstream 和 downstream)的 handler 也是由业务调用者线程执行的,也就是说申请和释放在同一个业务线程中进行。初次排查并没有发现导致内存泄露的根因,继续分析 Netty 内存池的实现原理。

Netty 内存池实现原理分析:查看 Netty 的内存池分配器 PooledByteBufAllocator 的源码实现,发现内存池实际是基于线程上下文实现的,相关代码如下:

也就是说内存的申请和释放必须在同一线程上下文中,不能跨线程。跨线程之后实际操作的就不是同一块儿内存区域,这会导致很多严重的问题,内存泄露便是其中之一。内存在 A 线程申请,切换到 B 线程释放,实际是无法正确回收的。

2.3. 问题根因

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 线程负责处理。

在本案例中,ByteBuf 在业务线程中申请,在后续的 ChannelHandler 中释放,ChannelHandler 是由 Netty 的 I/O 线程 (EventLoop) 执行的,因此内存的申请和释放不在同一个线程中,导致内存泄漏。

Netty 3 的 I/O 事件处理流程:

图 2-3 Netty 3 的 I/O 线程模型

Netty 4 的 I/O 消息处理流程:

图 2-4 Netty 4 I/O 线程模型

2.4. 案例总结

Netty 4.X 版本新增的内存池确实非常高效,但是如果使用不当则会导致各种严重的问题。诸如内存泄露这类问题,功能测试并没有异常,如果相关接口没有进行压测或者稳定性测试而直接上线,则会导致严重的线上问题。

内存池 PooledByteBuf 的使用建议:

  1. 申请之后一定要记得释放,Netty 自身 Socket 读取和发送的 ByteBuf 系统会自动释放,用户不需要做二次释放;如果用户使用 Netty 的内存池在应用中做 ByteBuf 的对象池使用,则需要自己主动释放;
  2. 避免错误的释放:跨线程释放、重复释放等都是非法操作,要避免。特别是跨线程申请和释放,往往具有隐蔽性,问题定位难度较大;
  3. 防止隐式的申请和分配:之前曾经发生过一个案例,为了解决内存池跨线程申请和释放问题,有用户对内存池做了二次包装,以实现多线程操作时,内存始终由包装的管理线程申请和释放,这样可以屏蔽用户业务线程模型和访问方式的差异。谁知运行一段时间之后再次发生了内存泄露,最后发现原来调用 ByteBuf 的 write 操作时,如果内存容量不足,会自动进行容量扩展。扩展操作由业务线程执行,这就绕过了内存池管理线程,发生了“引用逃逸”;
  4. 避免跨线程申请和使用内存池,由于存在“引用逃逸”等隐式的内存创建,实际上跨线程申请和使用内存池是非常危险的行为。尽管从技术角度看可以实现一个跨线程协调的内存池机制,甚至重写 PooledByteBufAllocator,但是这无疑会增加很多复杂性,通常也使用不到。如果确实存在跨线程的 ByteBuf 传递,而且无法保证 ByteBuf 在另一个线程中会重新分配大小等操作,最简单保险的方式就是在线程切换点做一次 ByteBuf 的拷贝,但这会造成性能下降。

比较好的一种方案就是如果存在跨线程的 ByteBuf 传递,对 ByteBuf 的写操作要在分配线程完成,另一个线程只能做读操作。操作完成之后发送一个事件通知分配线程,由分配线程执行内存释放操作。

3. Netty 3 版本升级性能下降案例

3.1. 问题描述

业务代码升级 Netty 3 到 Netty4 之后,并没有给产品带来预期的性能提升,有些甚至还发生了非常严重的性能下降,这与 Netty 官方给出的数据并不一致。

Netty 官方性能测试对比数据:我们比较了两个分别建立在 Netty 3 和 4 基础上 echo 协议服务器。(Echo 非常简单,这样,任何垃圾的产生都是 Netty 的原因,而不是协议的原因)。我使它们服务于相同的分布式 echo 协议客户端,来自这些客户端的 16384 个并发连接重复发送 256 字节的随机负载,几乎使千兆以太网饱和。

根据测试结果,Netty 4:

  • GC 中断频率是原来的 1/5: 45.5 vs. 9.2 次 / 分钟
  • 垃圾生成速度是原来的 1/5: 207.11 vs 41.81 MiB/ 秒

3.2. 问题定位

首先通过 JMC 等性能分析工具对性能热点进行分析,示例如下(信息安全等原因,只给出分析过程示例截图):

图 3-1 性能热点线程堆栈

通过对热点方法的分析,发现在消息发送过程中,有两处热点:

  1. 消息发送性能统计相关 Handler;
  2. 编码 Handler。

对使用 Netty 3 版本的业务产品进行性能对比测试,发现上述两个 Handler 也是热点方法。既然都是热点,为啥切换到 Netty4 之后性能下降这么厉害呢?

通过方法的调用树分析发现了两个版本的差异:在 Netty 3 中,上述两个热点方法都是由业务线程负责执行;而在 Netty 4 中,则是由 NioEventLoop(I/O) 线程执行。对于某个链路,业务是拥有多个线程的线程池,而 NioEventLoop 只有一个,所以执行效率更低,返回给客户端的应答时延就大。时延增大之后,自然导致系统并发量降低,性能下降。

找出问题根因之后,针对 Netty 4 的线程模型对业务进行专项优化,将耗时的编码等操作迁移到业务线程中执行,为 I/O 线程减负,性能达到预期,远超过了 Netty 3 老版本的性能。

Netty 3 的业务线程调度模型图如下所示:充分利用了业务多线程并行编码和 Handler 处理的优势,周期 T 内可以处理 N 条业务消息:

图 3-2 Netty 3 Handler 执行线程模型

切换到 Netty 4 之后,业务耗时 Handler 被 I/O 线程串行执行,因此性能发生比较大的下降:

图 3-3 Netty 4 Handler 执行线程模型

3.3. 问题总结

该问题的根因还是由于Netty 4 的线程模型变更引起,线程模型变更之后,不仅影响业务的功能,甚至对性能也会造成很大的影响。

对Netty 的升级需要从功能、兼容性和性能等多个角度进行综合考虑,切不可只盯着API 变更这个芝麻,而丢掉了性能这个西瓜。API 的变更会导致编译错误,但是性能下降却隐藏于无形之中,稍不留意就会中招。

对于讲究快速交付、敏捷开发和灰度发布的互联网应用,升级的时候更应该要当心。

4. Netty 业务 Handler 接收不到消息案例

4.1. 问题描述

我的服务碰到一个问题,经常有请求上来到 MessageDecoder 就结束了,没有继续往 LogicServerHandler 里面送,觉得很奇怪,是不是线程池满了?我想请教:

  1. netty 5 如何打印 executor 线程的占用情况,如空闲线程数?
  2. executor 设置的大小一般如何进行计算的?

业务代码示例如下:

4.2. 问题定位

从服务端初始化代码来看,并没有什么问题,业务 LogicServerHandler 没有接收到消息,有如下几种可能:

  1. 客户端并没有将消息发送到服务端,可以在服务端 LoggingHandler 中打印日志查看;
  2. 服务端部分消息解码发生异常,导致消息被丢弃 / 忽略,没有走到 LogicServerHandler 中;
  3. 执行业务 Handler 的 DefaultEventExecutor 中的线程太繁忙,导致任务队列积压,长时间得不到处理。

通过抓包结合日志分析,可能导致问题的原因 1 和 2 排除,需要继续对可能原因 3 进行排查。

Netty 5 如何打印 executor 线程的占用情况,如空闲线程数?回答这些问题,首先要了解 Netty 的线程组和线程池机制。

Netty 的 EventExecutorGroup 实际就是一组 EventExecutor,它的定义如下:

通常通过它的 next 方法从线程组中获取一个线程池,代码如下:

Netty EventExecutor 的典型实现有两个:DefaultEventExecutor 和 SingleThreadEventLoop,在本案例中,因为使用的是 DefaultEventExecutorGroup,所以实际执行业务 Handler 的线程池就是 DefaultEventExecutor,它继承自 SingleThreadEventExecutor,从名称就可以看出它是个单线程的线程池。它的工作原理如下:

  1. DefaultEventExecutor 聚合 JDK 的 Executor 和 Thread, 首次执行 Task 的时候启动线程,将线程池状态修改为运行态;
  2. Thread run 方法循环从队列中获取 Task 执行,如果队列为空,则同步阻塞,线程无限循环执行,直到接收到退出信号。

图 4-1 DefaultEventExecutor 工作原理

用户想通过 Netty 提供的 DefaultEventExecutorGroup 来并发执行业务 Handler,但实际上却是单线程 SingleThreadEventExecutor 在串行执行业务逻辑,当服务端消息接收速度超过业务逻辑执行速度时,就会导致业务消息积压在 SingleThreadEventExecutor 的消息队列中得不到及时处理,现象就是业务 Handler 好像得不到执行,部分业务消息丢失。

讲解完 Netty 线程模型后,问题原因也定位出来了。其实我们发现,可以通过 EventExecutor 获取 EventExecutorGroup 的信息,然后获取整个 EventExecutor 线程组信息,最后打印线程负载信息,代码如下:

执行结果如下:

4.3. 问题总结

事实上,Netty 为了防止多线程执行某个 Handler(Channel)引起线程安全问题,实际只有一个线程会执行某个 Handler,代码如下:

需要指出的是,SingleThreadEventExecutor 的 pendingTasks 可能是个耗时的操作,因此调用的时候需要注意:

实际就像 JDK 的线程池,不同的业务场景、硬件环境和性能标就会有不同的配置,无法给出标准的答案。需要进行实际测试、评估和调优来灵活调整。

最后再总结回顾下问题,对于案例中的代码,实际上在使用单线程处理某个 Handler 的 LogicServerHandler,作者可能想并发多线程执行这个 Handler,提升业务处理性能,但实际并没有达到设计效果。

如果业务性能存在问题,并不奇怪,因为业务实际是单线程串行处理的!当然,如果业务存在多个 Channel,则每个 / 多个 Channel 会对应一个线程(池),也可以实现多线程处理,这取决于客户端的接入数。

案例中代码的线程处理模型如下所示(单个链路模型):

图 4-3 单线程执行业务逻辑线程模型图

5. Netty 4 ChannelHandler 线程安全疑问

5.1. 问题咨询

我有一个非线程安全的类 ThreadUnsafeClass,这个类会在 channelRead 方法中被调用。我下面这样的调用方法在多线程环境下安全吗?谢谢!

代码示例如下:

5.2. 解答

Netty 4 优化了 Netty 3 的线程模型,其中一个非常大的优化就是用户不需要再担心 ChannelHandler 会被并发调用,总结如下:

  • ChannelHandler’s 的方法不会被 Netty 并发调用;
  • 用户不再需要对 ChannelHandler 的各个方法做同步保护;
  • ChannelHandler 实例不允许被多次添加到 ChannelPiple 中,否则线程安全将得不到保证

根据上述分析,MyHandler 的 channelRead 方法不会被并发调用,因此不存在线程安全问题。

5.3. 一些特例

ChannelHandler 的线程安全存在几个特例,总结如下:

  • 如果 ChannelHandler 被注解为 @Sharable,全局只有一个 handler 实例,它会被多个 Channel 的 Pipeline 共享,会被多线程并发调用,因此它不是线程安全的;
  • 如果存在跨 ChannelHandler 的实例级变量共享,需要特别注意,它可能不是线程安全的

非线程安全的跨 ChannelHandler 变量原理如下:

图 5-1 串行调用,线程安全

Netty 支持在添加 ChannelHandler 的时候,指定执行该 Handler 的 EventExecutorGroup,这就意味着在整个 ChannelPipeline 执行过程中,可能会发生线程切换。此时,如果同一个对象在多个 ChannelHandler 中被共享,可能会被多线程并发操作,原理如下:

图 5-2 并行调用,多 Handler 共享成员变量,非线程安全

6. 作者简介

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

联系方式:新浪微博 Nettying 微信:Nettying 微信公众号:Netty 之家

对于 Netty 学习中遇到的问题,或者认为有价值的 Netty 或者 NIO 相关案例,可以通过上述几种方式联系我。


感谢郭蕾对本文的策划和审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入 InfoQ 读者交流群)。

2015-09-03 08:2618573
用户头像

发布了 25 篇内容, 共 70.2 次阅读, 收获喜欢 229 次。

关注

评论 5 条评论

发布
用户头像
不知道这是4.X的版本,有些问题,在新版本中不存在了,比如说跨线程申请和释放的问题,PooledByteBuf中保存了PoolThreadCache和PoolChunk,不会释放错
2021-03-16 03:25
回复
我在4.1.49版本上做了测试,一个线程申请,另一个释放,可以正常回收。而且下载了较早的4.0.56的源码看了看,PooledByteBuf中也有PoolThreadCache和PoolChunk。图也挂了,看不懂哪里有问题啊
2021-03-16 03:33
回复
用户头像
图片损坏可以修复一下吗
2020-07-21 15:54
回复
用户头像
图片损坏
2019-07-15 16:52
回复
用户头像
这篇文章的插图都损坏了。影响阅读
2019-03-24 21:38
回复
没有更多了
发现更多内容

可视化技术:数据可视化17个常用图表

2D3D前端可视化开发

大数据 数据分析 数据可视化 数据可视化工具 可视化大屏

业务全面重塑,“人”要如何重塑?

用友BIP

人才管理

为啥不建议用BeanUtils.copyProperties拷贝数据 | 京东云技术团队

京东科技开发者

spring BeanUtils copyProperties

All in One, 快速搭建端到端可观测体系

华为云开发者联盟

云计算 后端 华为云 华为云开发者联盟 华为云可观测监控大屏

六步走向无忧,华为云数据库高可用的秘密武器

华为云开发者联盟

数据库 后端 华为云 华为云开发者联盟

从被动到主动,智能招聘为企业人效提升给出最优解

用友BIP

招聘

共话 AI for Science,2023和鲸社区年度科研闭门会圆满结束

ModelWhale

人工智能 数据科学 科研 AI4S

“Ladies In Tech 闪闪发光的她”分论坛圆满举办

开放原子开源基金会

开源

文心一言专业版年卡来啦!

飞桨PaddlePaddle

人工智能 文心一言

铜锁/Tongsuo项目管理委员会成立,重磅发布8.4.0版本

开放原子开源基金会

开源

星河创新,产业引领:大模型引领的企业智能化升级创新实践

飞桨PaddlePaddle

人工智能 深度学习 开发者 WAVE SUMMIT

开源工业物联网大数据分论坛圆满举办

开放原子开源基金会

开源

如何使用不同的纹理贴图制作逼真的 3D 图形?

3D建模设计

3D渲染 材质纹理贴图 3D材质编辑

优测云服务平台|总结Android开发常见风险及解决方案

优测云服务平台

风险 Android开发 Android解决方案

软件测试/测试开发丨测试用例的概念、组成、优先级、设计工具

测试人

软件测试 测试开发

什么是3D模型LOD:细节级别

3D建模设计

3D渲染 材质纹理贴图 3D材质编辑

【低代码】低代码平台协同&敏捷场景下的并行开发解决方案探索 | 京东云技术团队

京东科技开发者

敏捷 低代码 并行开发

openEuler Code Camp圆满举办

开放原子开源基金会

开源

七分技术、三分管理,做好供应链管理的需求预测

用友BIP

供应链

AI时代数据存储管理新挑战分论坛圆满举办

开放原子开源基金会

开源

我们不可能永远都在救火 ——Scrum中技术债务“偿还”指南

敏捷开发

项目管理 Scrum 敏捷开发 自动化测试 技术债务

稳定的数据云平台如何炼成?奇点云解读“RAS”典型问题

奇点云

奇点云 数据云平台 DataSimba

企业门户平台:八项必备功能助力业务升级

天津汇柏科技有限公司

网站 企业

软件测试/测试开发丨Bug概念,定义,判定标准,严重程度,优先级

测试人

软件测试 测试开发

PON网络应用场景

小齐写代码

微服务广播模式实践:维护内存数据的缓存一致性

华为云开发者联盟

微服务 云原生 后端 华为云 华为云开发者联盟

3D 纹理贴图基础知识

3D建模设计

3D渲染 材质纹理贴图 3D材质编辑

什么是多边形网格以及如何编辑它?

3D建模设计

3D渲染 材质纹理贴图 3D材质编辑

大模型热的冷思考

用友BIP

企业服务大模型

Netty案例集锦之多线程篇_语言 & 开发_李林锋_InfoQ精选文章