做者 李林锋 发布于 2015年2月7日 | 注意:GTLC全球技术领导力峰会,500+CTO技聚从新定义技术领导力!18 讨论后端
根据对Netty社区部分用户的调查,结合Netty在其它开源项目中的使用状况,咱们能够看出目前Netty商用的主流版本集中在3.X和4.X上,其中以Netty 3.X系列版本使用最为普遍。promise
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没有作到很好的前向兼容。服务器
相关厂商内容微信
相关赞助商网络
QCon全球软件开发大会上海站,2016年10月20日-22日,上海宝华万豪酒店,精彩内容抢先看!多线程
因为版本不兼容,大多数老版本使用者的想法就是既然升级这么麻烦,我暂时又不须要使用到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权威指南》做者