根据对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版本。
相比于其它开源项目,Netty用户的版本升级之路更加艰辛,最根本的原因就是Netty 4对Netty 3没有做到很好的前向兼容。
由于版本不兼容,大多数老版本使用者的想法就是既然升级这么麻烦,我暂时又不需要使用到Netty 4的新特性,当前版本还挺稳定,就暂时先不升级,以后看看再说。
坚守老版本还有很多其它的理由,例如考虑到线上系统的稳定性、对新版本的熟悉程度等。无论如何升级Netty都是一件大事,特别是对Netty有直接强依赖的产品。
从上面的分析可以看出,坚守老版本似乎是个不错的选择;但是,“理想是美好的,现实却是残酷的”,坚守老版本并非总是那么容易,下面我们就看下被迫升级的案例。
除了为了使用新特性而主动进行的版本升级,大多数升级都是“被迫的”。下面我们对这些升级原因进行分析。
表面上看,类库包路径的修改、API的重构等似乎是升级的重头戏,大家往往把注意力放到这些“明枪”上,但真正隐藏和致命的却是“暗箭”。如果对Netty底层的事件调度机制和线程模型不熟悉,往往就会“中枪”。
本文以几个比较典型的真实案例为例,通过问题描述、问题定位和问题总结,让这些隐藏的“暗箭”不再伤人。
由于Netty 4线程模型改变导致的升级事故还有很多,限于篇幅,本文不一一枚举,这些问题万变不离其宗,只要抓住线程模型这个关键点,所谓的疑难杂症都将迎刃而解。
随着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内存溢出堆栈
对内存进行监控(切换使用堆内存池,方便对内存进行监控),发现堆内存一直飙升,如下所示(示例堆内存监控):
图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的可能性不大,首先从业务的使用方式入手分析:
初次排查并没有发现导致内存泄露的根因,一筹莫展之际开始查看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事件处理流程:
图2-3 Netty 3 I/O事件处理线程模型
Netty 4的I/O消息处理流程:
图2-4 Netty 4 I/O事件处理线程模型
Netty 4.X版本新增的内存池确实非常高效,但是如果使用不当则会导致各种严重的问题。诸如内存泄露这类问题,功能测试并没有异常,如果相关接口没有进行压测或者稳定性测试而直接上线,则会导致严重的线上问题。
内存池PooledByteBuf的使用建议:
某业务产品,Netty3.X升级到4.X之后,系统运行过程中,偶现服务端发送给客户端的应答数据被莫名“篡改”。
业务服务端的处理流程如下:
业务相关代码示例如下:
//构造订购应答消息 SubInfoResp infoResp = new SubInfoResp(); //根据业务逻辑,对应答消息赋值 infoResp.setResultCode(0); infoResp.setXXX(); 后续赋值操作省略...... //调用ChannelHandlerContext进行消息发送 ctx.writeAndFlush(infoResp); //消息发送完成之后,后续根据业务流程进行分支处理,修改infoResp对象 infoResp.setXXX(); 后续代码省略......
首先对应答消息被非法“篡改”的原因进行分析,经过定位发现当发生问题时,被“篡改”的内容是调用 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线程异步执行,业务线程返回。这时存在两种可能:
由于线程的执行先后顺序无法预测,因此该问题隐藏的相当深。如果对Netty 4和Netty3的线程模型不了解,就会掉入陷阱。
Netty 3版本业务逻辑没有问题,流程如下:
图3-1 升级之前的业务流程线程模型
升级到Netty 4版本之后,业务流程由于Netty线程模型的变更而发生改变,导致业务逻辑发生问题:
图3-2 升级之后的业务处理流程发生改变
很多读者在进行Netty 版本升级的时候,只关注到了包路径、类和API的变更,并没有注意到隐藏在背后的“暗箭”- 线程模型变更。
升级到Netty 4的用户需要根据新的线程模型对已有的系统进行评估,重点需要关注outbound的ChannelHandler,如果它的正确性依赖于Netty 3的线程模型,则很可能在新的线程模型中出问题,可能是功能问题或者其它问题。
相信很多Netty用户都看过如下相关报告:
在Twitter,Netty 4 GC开销降为五分之一:Netty 3使用Java对象表示I/O事件,这样简单,但会产生大量的 垃圾 ,尤其是在我们这样的规模下。Netty 4在新版本中对此做出了更改,取代生存周期短的事件对象,而以定义在生存周期长的通道对象上的方法处理I/O事件。它还有一个使用池的专用缓冲区分配器。
每当收到新信息或者用户发送信息到远程端,Netty 3均会创建一个新的堆缓冲区。这意味着,对应每一个新的缓冲区,都会有一个‘new byte[capacity]’。这些缓冲区会导致GC压力,并消耗内存带宽:为了安全起见,新的字节数组分配时会用零填充,这会消耗内存带宽。然而,用零填充的数组很可能会再次用实际的数据填充,这又会消耗同样的内存带宽。如果Java虚拟机(JVM)提供了创建新字节数组而又无需用零填充的方式,那么我们本来就可以将内存带宽消耗减少50%,但是目前没有那样一种方式。
在Netty 4中,代码定义了粒度更细的API,用来处理不同的事件类型,而不是创建事件对象。它还实现了一个新缓冲池,那是一个纯Java版本的 jemalloc (Facebook也在用)。现在,Netty不会再因为用零填充缓冲区而浪费内存带宽了。
我们比较了两个分别建立在Netty 3和4基础上 echo 协议服务器。(Echo非常简单,这样,任何垃圾的产生都是Netty的原因,而不是协议的原因)。我使它们服务于相同的分布式echo协议客户端,来自这些客户端的16384个并发连接重复发送256字节的随机负载,几乎使千兆以太网饱和。
根据测试结果,Netty 4:
正是看到了相关的Netty 4性能提升报告,很多用户选择了升级。事后一些用户反馈Netty 4并没有跟产品带来预期的性能提升,有些甚至还发生了非常严重的性能下降,下面我们就以某业务产品的失败升级经历为案例,详细分析下导致性能下降的原因。
首先通过JMC等性能分析工具对性能热点进行分析,示例如下(信息安全等原因,只给出分析过程示例截图):
图4-1 JMC性能监控分析
通过对热点方法的分析,发现在消息发送过程中,有两处热点:
对使用Netty 3版本的业务产品进行性能对比测试,发现上述两个Handler也是热点方法。既然都是热点,为啥切换到Netty4之后性能下降这么厉害呢?
通过方法的调用树分析发现了两个版本的差异:在Netty 3中,上述两个热点方法都是由业务线程负责执行;而在Netty 4中,则是由NioEventLoop(I/O)线程执行。对于某个链路,业务是拥有多个线程的线程池,而NioEventLoop只有一个,所以执行效率更低,返回给客户端的应答时延就大。时延增大之后,自然导致系统并发量降低,性能下降。
找出问题根因之后,针对Netty 4的线程模型对业务进行专项优化,性能达到预期,远超过了Netty 3老版本的性能。
Netty 3的业务线程调度模型图如下所示:充分利用了业务多线程并行编码和Handler处理的优势,周期T内可以处理N条业务消息。
图4-2 Netty 3业务调度性能模型
切换到Netty 4之后,业务耗时Handler被I/O线程串行执行,因此性能发生比较大的下降:
图4-3 Netty 4业务调度性能模型
该问题的根因还是由于Netty 4的线程模型变更引起,线程模型变更之后,不仅影响业务的功能,甚至对性能也会造成很大的影响。
对Netty的升级需要从功能、兼容性和性能等多个角度进行综合考虑,切不可只盯着API变更这个芝麻,而丢掉了性能这个西瓜。API的变更会导致编译错误,但是性能下降却隐藏于无形之中,稍不留意就会中招。
对于讲究快速交付、敏捷开发和灰度发布的互联网应用,升级的时候更应该要当心。
为了提升业务的二次定制能力,降低对接口的侵入性,业务使用线程变量进行消息上下文的传递。例如消息发送源地址信息、消息Id、会话Id等。
业务同时使用到了一些第三方开源容器,也提供了线程级变量上下文的能力。业务通过容器上下文获取第三方容器的系统变量信息。
升级到Netty 4之后,业务继承自Netty的ChannelHandler发生了空指针异常,无论是业务自定义的线程上下文、还是第三方容器的线程上下文,都获取不到传递的变量值。
首先检查代码,看业务是否传递了相关变量,确认业务传递之后怀疑跟Netty 版本升级相关,调试发现,业务ChannelHandler获取的线程上下文对象和之前业务传递的上下文不是同一个。这就说明执行ChannelHandler的线程跟处理业务的线程不是同一个线程!
查看Netty 4线程模型的相关Doc发现,Netty修改了outbound的线程模型,正好影响了业务消息发送时的线程上下文传递,最终导致线程变量丢失。
通常业务的线程模型有如下几种:
在实践中我们发现很多业务使用了第三方框架,但是只熟悉API和功能,对线程模型并不清楚。某个类库由哪个线程调用,糊里糊涂。为了方便变量传递,又随意的使用线程变量,实际对背后第三方类库的线程模型产生了强依赖。当容器或者第三方类库升级之后,如果线程模型发生了变更,则原有功能就会发生问题。
鉴于此,在实际工作中,尽量不要强依赖第三方类库的线程模型,如果确实无法避免,则必须对它的线程模型有深入和清晰的了解。当第三方类库升级之后,需要检查线程模型是否发生变更,如果发生变化,相关的代码也需要考虑同步升级。
通过对三个具有典型性的升级失败案例进行分析和总结,我们发现有个共性:都是线程模型改变惹的祸!
下面小节我们就详细得对Netty3和Netty4版本的I/O线程模型进行对比,以方便大家掌握两者的差异,在升级和使用中尽量少踩雷。
Netty 3.X的I/O操作线程模型比较复杂,它的处理模型包括两部分:
我们首先分析下Inbound操作的线程模型:
图6-1 Netty 3 Inbound操作线程模型
从上图可以看出,Inbound操作的主要处理流程如下:
通过对Netty 3的Inbound操作进行分析我们可以看出,Inbound的Handler都是由Netty的I/O Work线程负责执行。
下面我们继续分析Outbound操作的线程模型:
图6-2 Netty 3 Outbound操作线程模型
从上图可以看出,Outbound操作的主要处理流程如下:
业务线程发起Channel Write操作,发送消息;
相比于Netty 3.X系列版本,Netty 4.X的I/O操作线程模型比较简答,它的原理图如下所示:
图6-3 Netty 4 Inbound和Outbound操作线程模型
从上图可以看出,Outbound操作的主要处理流程如下:
通过流程分析,我们发现Netty 4修改了线程模型,无论是Inbound还是Outbound操作,统一由I/O线程NioEventLoop调度执行。
在进行新老版本线程模型PK之前,首先还是要熟悉下串行化设计的理念:
我们知道当系统在运行过程中,如果频繁的进行线程上下文切换,会带来额外的性能损耗。多线程并发执行某个业务流程,业务开发者还需要时刻对线程安全保持警惕,哪些数据可能会被并发修改,如何保护?这不仅降低了开发效率,也会带来额外的性能损耗。
为了解决上述问题,Netty 4采用了串行化设计理念,从消息的读取、编码以及后续Handler的执行,始终都由I/O线程NioEventLoop负责,这就意外着整个流程不会进行线程上下文的切换,数据也不会面临被并发修改的风险,对于用户而言,甚至不需要了解Netty的线程细节,这确实是个非常好的设计理念,它的工作原理图如下:
图6-4 Netty 4的串行化设计理念
一个NioEventLoop聚合了一个多路复用器Selector,因此可以处理成百上千的客户端连接,Netty的处理策略是每当有一个新的客户端接入,则从NioEventLoop线程组中顺序获取一个可用的NioEventLoop,当到达数组上限之后,重新返回到0,通过这种方式,可以基本保证各个NioEventLoop的负载均衡。一个客户端连接只注册到一个NioEventLoop上,这样就避免了多个I/O线程去并发操作它。
Netty通过串行化设计理念降低了用户的开发难度,提升了处理性能。利用线程组实现了多个串行化线程水平并行执行,线程之间并没有交集,这样既可以充分利用多核提升并行处理能力,同时避免了线程上下文的切换和并发保护带来的额外性能损耗。
了解完了Netty 4的串行化设计理念之后,我们继续看Netty 3线程模型存在的问题,总结起来,它的主要问题如下:
讲了这么多,似乎Netty 4 完胜 Netty 3的线程模型,其实并不尽然。在特定的场景下,Netty 3的性能可能更高,就如本文第4章节所讲,如果编码和其它Outbound操作非常耗时,由多个业务线程并发执行,性能肯定高于单个NioEventLoop线程。
但是,这种性能优势不是不可逆转的,如果我们修改业务代码,将耗时的Handler操作前置,Outbound操作不做复杂业务逻辑处理,性能同样不输于Netty 3,但是考虑内存池优化、不会反复创建Event、不需要对Handler加锁等Netty 4的优化,整体性能Netty 4版本肯定会更高。
总而言之,如果用户真正熟悉并掌握了Netty 4的线程模型和功能类库,相信不仅仅开发会更加简单,性能也会更优!
就Netty 而言,掌握线程模型的重要性不亚于熟悉它的API和功能。很多时候我遇到的功能、性能等问题,都是由于缺乏对它线程模型和原理的理解导致的,结果我们就以讹传讹,认为Netty 4版本不如3好用等。
不能说所有开源软件的版本升级一定都胜过老版本,就Netty而言,我认为Netty 4版本相比于老的Netty 3,确实是历史的一大进步。
李林锋,2007年毕业于东北大学,2008年进入华为公司从事高性能通信软件的设计和开发工作,有7年NIO设计和开发经验,精通Netty、Mina等NIO框架和平台中间件,现任华为软件平台架构部架构师,《Netty权威指南》作者。
联系方式:新浪微博 Nettying 微信:Nettying 微信公众号:Netty之家
感谢郭蕾对本文的策划和审校。
给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ)或者腾讯微博(@InfoQ)关注我们,并与我们的编辑和其他读者朋友交流。