Netty版本升级血泪史之线程篇(上)

Posted Qunar技术沙龙

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Netty版本升级血泪史之线程篇(上)相关的知识,希望对你有一定的参考价值。

1. 背景


1.1. Netty 3.X系列版本现状

根据对Netty社区部分用户的调查,结合Netty在其它开源项目中的使用情况,我们可以看出目前Netty商用的主流版本集中在3.X和4.X上,其中以Netty 3.X系列版本使用最为广泛。


Netty社区非常活跃,3.X系列版本从2011年2月7日发布的netty-3.2.4 Final版本到2014年12月17日发布的netty-3.10.0 Final版本,版本跨度达3年多,期间共推出了61个Final版本。


1.2. 升级还是坚守老版本

相比于其它开源项目,Netty用户的版本升级之路更加艰辛,最根本的原因就是Netty 4对Netty 3没有做到很好的前向兼容。


由于版本不兼容,大多数老版本使用者的想法就是既然升级这么麻烦,我暂时又不需要使用到Netty 4的新特性,当前版本还挺稳定,就暂时先不升级,以后看看再说。


坚守老版本还有很多其它的理由,例如考虑到线上系统的稳定性、对新版本的熟悉程度等。无论如何升级Netty都是一件大事,特别是对Netty有直接强依赖的产品。


从上面的分析可以看出,坚守老版本似乎是个不错的选择;但是,“理想是美好的,现实却是残酷的”,坚守老版本并非总是那么容易,下面我们就看下被迫升级的案例。


1.3. “被迫”升级到Netty 4.X

除了为了使用新特性而主动进行的版本升级,大多数升级都是“被迫的”。下面我们对这些升级原因进行分析。

1,公司的开源软件管理策略:对于那些大厂,不同部门和产品线依赖的开源软件版本经常不同,为了对开源依赖进行统一管理,降低安全、维护和管理成本,往往会指定优选的软件版本。由于Netty 4.X 系列版本已经非常成熟,因为,很多公司都优选Netty 4.X版本。


2,维护成本:无论是依赖Netty 3.X,还是Netty4.X,往往需要在原框架之上做定制。例如,客户端的短连重连、心跳检测、流控等。分别对Netty 4.X和3.X版本实现两套定制框架,开发和维护成本都非常高。根据开源软件的使用策略,当存在版本冲突的时候,往往会选择升级到更高的版本。对于Netty,依然遵循这个规则。


3,新特性:Netty 4.X相比于Netty 3.X,提供了很多新的特性,例如优化的内存管理池、对MQTT协议的支持等。如果用户需要使用这些新特性,最简便的做法就是升级Netty到4.X系列版本。


4,更优异的性能:Netty 4.X版本相比于3.X老版本,优化了内存池,减少了GC的频率、降低了内存消耗;通过优化Rector线程池模型,用户的开发更加简单,线程调度也更加高效。


1.4. 升级不当付出的代价

表面上看,类库包路径的修改、API的重构等似乎是升级的重头戏,大家往往把注意力放到这些“明枪”上,但真正隐藏和致命的却是“暗箭”。如果对Netty底层的事件调度机制和线程模型不熟悉,往往就会“中枪”。


本文以几个比较典型的真实案例为例,通过问题描述、问题定位和问题总结,让这些隐藏的“暗箭”不再伤人。


由于Netty 4线程模型改变导致的升级事故还有很多,限于篇幅,本文不一一枚举,这些问题万变不离其宗,只要抓住线程模型这个关键点,所谓的疑难杂症都将迎刃而解。


2. Netty升级之后遭遇内存泄露

2.1. 问题描述

随着JVM虚拟机和JIT即时编译技术的发展,对象的分配和回收是个非常轻量级的工作。但是对于缓冲区Buffer,情况却稍有不同,特别是对于堆外直接内存的分配和回收,是一件耗时的操作。为了尽量重用缓冲区,Netty4.X提供了基于内存池的缓冲区重用机制。性能测试表明,采用内存池的ByteBuf相比于朝生夕灭的ByteBuf,性能高23倍左右(性能数据与使用场景强相关)。


业务应用的特点是高并发、短流程,大多数对象都是朝生夕灭的短生命周期对象。为了减少内存的拷贝,用户期望在序列化的时候直接将对象编码到PooledByteBuf里,这样就不需要为每个业务消息都重新申请和释放内存。


业务的相关代码示例如下:

//在业务线程中初始化内存池分配器,分配非堆内存

ByteBufAllocator allocator = new PooledByteBufAllocator(true);

ByteBuf buffer = allocator.ioBuffer(1024);

//构造订购请求消息并赋值,业务逻辑省略

SubInfoReq infoReq = new SubInfoReq ();

infoReq.setXXX(......);

//将对象编码到ByteBuf中

codec.encode(buffer, info);

//调用ChannelHandlerContext进行消息发送

ctx.writeAndFlush(buffer);


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

图2-1 OOM内存溢出堆栈


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

Netty版本升级血泪史之线程篇(上)

图2-2 堆内存监控


2.2. 问题定位

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


因为使用了内存池,所以首先怀疑是不是申请的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的内存池分配器PooledByteBufAllocator的Doc和源码实现,发现内存池实际是基于线程上下文实现的,相关代码如下:

final ThreadLocal<PoolThreadCache> threadCache = new ThreadLocal<PoolThreadCache>() {

private final AtomicInteger index = new AtomicInteger();

@Override

protected PoolThreadCache initialValue() {

final int idx = index.getAndIncrement();

final PoolArena<byte[]> heapArena;

final PoolArena<ByteBuffer> directArena;

if (heapArenas != null) {

heapArena = heapArenas[Math.abs(idx % heapArenas.length)];

} else {

heapArena = null;

}

if (directArenas != null) {

directArena = directArenas[Math.abs(idx % directArenas.length)];

} else {

directArena = null;

}

return new PoolThreadCache(heapArena, directArena);

}


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


通过对Netty内存池的源码分析,问题基本锁定。保险起见进行简单验证,通过对单条业务消息进行Debug,发现执行释放的果然不是业务线程,而是Netty的NioEventLoop线程:当某个消息被完全发送成功之后,会通过ReferenceCountUtil.release(message)方法释放已经发送成功的ByteBuf。


问题定位出来之后,继续溯源,发现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线程负责处理。


下面我们分别通过对比Netty 3和Netty 4的消息接收和发送流程,来理解两个版本线程模型的差异:


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

Netty版本升级血泪史之线程篇(上)

图2-3 Netty 3 I/O事件处理线程模型


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

Netty版本升级血泪史之线程篇(上)

图2-4 Netty 4 I/O事件处理线程模型


2.3. 问题总结

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


内存池PooledByteBuf的使用建议:

1,申请之后一定要记得释放,Netty自身Socket读取和发送的ByteBuf系统会自动释放,用户不需要做二次释放;如果用户使用Netty的内存池在应用中做ByteBuf的对象池使用,则需要自己主动释放;


2,避免错误的释放:跨线程释放、重复释放等都是非法操作,要避免。特别是跨线程申请和释放,往往具有隐蔽性,问题定位难度较大;


3,防止隐式的申请和分配:之前曾经发生过一个案例,为了解决内存池跨线程申请和释放问题,有用户对内存池做了二次包装,以实现多线程操作时,内存始终由包装的管理线程申请和释放,这样可以屏蔽用户业务线程模型和访问方式的差异。谁知运行一段时间之后再次发生了内存泄露,最后发现原来调用ByteBuf的write操作时,如果内存容量不足,会自动进行容量扩展。扩展操作由业务线程执行,这就绕过了内存池管理线程,发生了“引用逃逸”。该Bug只有在ByteBuf容量动态扩展的时候才发生,因此,上线很长一段时间没有发生,直到某一天......因此,大家在使用Netty 4.X的内存池时要格外当心,特别是做二次封装时,一定要对内存池的实现细节有深刻的理解。


3. Netty升级之后遭遇数据被篡改


3.1. 问题描述

某业务产品,Netty3.X升级到4.X之后,系统运行过程中,偶现服务端发送给客户端的应答数据被莫名“篡改”。

业务服务端的处理流程如下:

1,将解码后的业务消息封装成Task,投递到后端的业务线程池中执行;


2,业务线程处理业务逻辑,完成之后构造应答消息发送给客户端;


3,业务应答消息的编码通过继承Netty的CodeC框架实现,即Encoder ChannelHandler;


4,调用Netty的消息发送接口之后,流程继续,根据业务场景,可能会继续操作原发送的业务对象。


业务相关代码示例如下:

//构造订购应答消息

SubInfoResp infoResp = new SubInfoResp();

//根据业务逻辑,对应答消息赋值

infoResp.setResultCode(0);

infoResp.setXXX();

后续赋值操作省略......

//调用ChannelHandlerContext进行消息发送

ctx.writeAndFlush(infoResp);

//消息发送完成之后,后续根据业务流程进行分支处理,修改infoResp对象

infoResp.setXXX();

后续代码省略......


3.2. 问题定位

首先对应答消息被非法“篡改”的原因进行分析,经过定位发现当发生问题时,被“篡改”的内容是调用writeAndFlush接口之后,由后续业务分支代码修改应答消息导致的。由于修改操作发生在writeAndFlush操作之后,按照Netty 3.X的线程模型不应该出现该问题。


在Netty3中,downstream是在业务线程里执行的,也就是说对SubInfoResp的编码操作是在业务线程中执行的,当编码后的ByteBuf对象被投递到消息发送队列之后,业务线程才会返回并继续执行后续的业务逻辑,此时修改应答消息是不会改变已完成编码的ByteBuf对象的,所以肯定不会出现应答消息被篡改的问题。


初步分析应该是由于线程模型发生变更导致的问题,随后查验了Netty 4的线程模型,果然发生了变化:当调用outbound向外发送消息的时候,Netty会将发送事件封装成Task,投递到NioEventLoop的任务队列中异步执行,相关代码如下:

@Override

public void invokeWrite(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {

if (msg == null) {

throw new NullPointerException("msg");

}

validatePromise(ctx, promise, true);

if (executor.inEventLoop()) {

invokeWriteNow(ctx, msg, promise);

} else {

AbstractChannel channel = (AbstractChannel) ctx.channel();

int size = channel.estimatorHandle().size(msg);

if (size > 0) {

ChannelOutboundBuffer buffer = channel.unsafe().outboundBuffer();

// Check for null as it may be set to null if the channel is closed already

if (buffer != null) {

buffer.incrementPendingOutboundBytes(size);

}

}

safeExecuteOutbound(WriteTask.newInstance(ctx, msg, size, promise), promise, msg);

}

}


通过上述代码可以看出,Netty首先对当前的操作的线程进行判断,如果操作本身就是由NioEventLoop线程执行,则调用写操作;否则,执行线程安全的写操作,即将写事件封装成Task,放入到任务队列中由Netty的I/O线程执行,业务调用返回,流程继续执行。


通过源码分析,问题根源已经很清楚:系统升级到Netty 4之后,线程模型发生变化,响应消息的编码由NioEventLoop线程异步执行,业务线程返回。这时存在两种可能:

1,如果编码操作先于修改应答消息的业务逻辑执行,则运行结果正确;


2,如果编码操作在修改应答消息的业务逻辑之后执行,则运行结果错误。


由于线程的执行先后顺序无法预测,因此该问题隐藏的相当深。如果对Netty 4和Netty3的线程模型不了解,就会掉入陷阱。


Netty 3版本业务逻辑没有问题,流程如下:

图3-1 升级之前的业务流程线程模型


升级到Netty 4版本之后,业务流程由于Netty线程模型的变更而发生改变,导致业务逻辑发生问题:

图3-2 升级之后的业务处理流程发生改变


3.3. 问题总结

很多读者在进行Netty 版本升级的时候,只关注到了包路径、类和API的变更,并没有注意到隐藏在背后的“暗箭”- 线程模型变更。


升级到Netty 4的用户需要根据新的线程模型对已有的系统进行评估,重点需要关注outbound的ChannelHandler,如果它的正确性依赖于Netty 3的线程模型,则很可能在新的线程模型中出问题,可能是功能问题或者其它问题。

(转自InfoQ)

以上是关于Netty版本升级血泪史之线程篇(上)的主要内容,如果未能解决你的问题,请参考以下文章

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

2021 .NET Conf China 主题分享之-轻松玩转.NET大规模版本升级

2021 .NET Conf China 主题分享之-轻松玩转.NET大规模版本升级

iOS开发之进阶篇(15)—— CocoaPods

TiDB集群运维之版本升级

Linux系统之升级内核版本方法