写点什么

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:2618453
用户头像

发布了 25 篇内容, 共 69.9 次阅读, 收获喜欢 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
回复
没有更多了
发现更多内容

项目管理与项目集管理、项目组合管理的区别?

万事ONES

项目管理 项目 PMO ONES

加快技术应用规模化 建设世界先进水平区块链产业生态

CECBC

数字化转型背景下的测试转型

BY林子

敏捷测试 测试转型

异构内存及其在机器学习系统的应用与优化

白玉兰开源

人工智能 机器学习 解决方案 第四范式 傲腾

HTTPS协议

IT视界

JavaScript 中数组 sort() 方法的基本使用

编程三昧

JavaScript 大前端 数组 排序 js

做通才还是专才,你会怎么选?

架构精进之路

认知提升 6月日更

浅谈Java中的TCP超时

Hoswey_洪树伟

Java、

高性能 JavaScriptの七 -- 编程实践小技巧

空城机

JavaScript 大前端 6月日更

为什么说产品经理也要学点技术?

LigaAI

产品经理 研发管理 技术团队 产品设计与思考

🏆【声网 Agora】「PC端实现实时语音通讯4.x」

洛神灬殇

WebRTC RTC征文大赛 声网 6月日更

5分钟速读之Rust权威指南(十九)

wzx

rust 生命周期

阿里云边缘容器服务、申通 IoT 云边端架构入选 2021 云边协同发展阶段性领先成果

阿里巴巴云原生

云原生

分布式认知工业互联网如何赋能工业企业数字化转型?

CECBC

MySQL基础之六:连接查询

打工人!

myslq 6月日更

《原则》(八)

Changing Lin

6月日更

区块链+金融:当前区块链应用场景中最具活力的领域

CECBC

学妹问,学网站开发还是打 ACM?

程序员鱼皮

Java 程序员 算法 大前端 ACM

操作系统内核是什么?Linux内核又是什么?读完这篇文章,我终于知道了

奔着腾讯去

c++ 操作系统 内存管理 Linux内核 进程管理

Kubernetes手记(5)- 配置清单使用

雪雷

k8s 6月日更

Java--JVM运行流程

是老郭啊

Java JVM JVM原理

公司:离职就是一场危机管理

石云升

创业 职场经验 6月日更

云原生推动全云开发与实践

阿里巴巴云原生

云原生

Python——输入输出:加减乘除四则运算的程序

在即

6月日更

spring-beans 注册 Beans(四)BeanDefinition

梦倚栏杆

不管是三胎还是App!指望“拉新”太难了,还是要靠老用户!

APP开发

给你一直尝试和创新的机会!走进亚马逊云科技MRC团队

亚马逊云科技 (Amazon Web Services)

5W1H聊开源之What——开源协议有哪些?

禅道项目管理

开源

国内低代码产品是如何定位的?这3类,企业可自行对号入座

优秀

低代码

人人视频被迫下架:打击盗版视频网站任重道远

石头IT视角

软件研发团队如何做好项目进度管理?

万事ONES

项目管理 研发管理 需求 ONES

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