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进程就会宕机,查看系统运行日志发现系统发生了内存泄露(示例堆栈):

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

图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版本业务逻辑没有问题,流程如下:

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

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

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

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

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

3.3. 问题总结

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

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

4. Netty升级之后性能严重下降

4.1. 问题描述

相信很多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:

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

正是看到了相关的Netty 4性能提升报告,很多用户选择了升级。事后一些用户反馈Netty 4并没有跟产品带来预期的性能提升,有些甚至还发生了非常严重的性能下降,下面我们就以某业务产品的失败升级经历为案例,详细分析下导致性能下降的原因。

4.2. 问题定位

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

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

图4-1 JMC性能监控分析

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

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

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

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

找出问题根因之后,针对Netty 4的线程模型对业务进行专项优化,性能达到预期,远超过了Netty 3老版本的性能。

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

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

图4-2 Netty 3业务调度性能模型

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

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

图4-3 Netty 4业务调度性能模型

4.3. 问题总结

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

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

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

5. Netty升级之后上下文丢失

5.1. 问题描述

为了提升业务的二次定制能力,降低对接口的侵入性,业务使用线程变量进行消息上下文的传递。例如消息发送源地址信息、消息Id、会话Id等。

业务同时使用到了一些第三方开源容器,也提供了线程级变量上下文的能力。业务通过容器上下文获取第三方容器的系统变量信息。

升级到Netty 4之后,业务继承自Netty的ChannelHandler发生了空指针异常,无论是业务自定义的线程上下文、还是第三方容器的线程上下文,都获取不到传递的变量值。

5.2. 问题定位

首先检查代码,看业务是否传递了相关变量,确认业务传递之后怀疑跟Netty 版本升级相关,调试发现,业务ChannelHandler获取的线程上下文对象和之前业务传递的上下文不是同一个。这就说明执行ChannelHandler的线程跟处理业务的线程不是同一个线程!

查看Netty 4线程模型的相关Doc发现,Netty修改了outbound的线程模型,正好影响了业务消息发送时的线程上下文传递,最终导致线程变量丢失。

5.3. 问题总结

通常业务的线程模型有如下几种:

  1. 业务自定义线程池/线程组处理业务,例如使用JDK 1.5提供的ExecutorService;
  2. 使用J2EE Web容器自带的线程模型,常见的如JBoss和Tomcat的HTTP接入线程等;
  3. 隐式的使用其它第三方框架的线程模型,例如使用NIO框架进行协议处理,业务代码隐式使用的就是NIO框架的线程模型,除非业务明确的实现自定义线程模型。

在实践中我们发现很多业务使用了第三方框架,但是只熟悉API和功能,对线程模型并不清楚。某个类库由哪个线程调用,糊里糊涂。为了方便变量传递,又随意的使用线程变量,实际对背后第三方类库的线程模型产生了强依赖。当容器或者第三方类库升级之后,如果线程模型发生了变更,则原有功能就会发生问题。

鉴于此,在实际工作中,尽量不要强依赖第三方类库的线程模型,如果确实无法避免,则必须对它的线程模型有深入和清晰的了解。当第三方类库升级之后,需要检查线程模型是否发生变更,如果发生变化,相关的代码也需要考虑同步升级。

6. Netty3.X VS Netty4.X 之线程模型

通过对三个具有典型性的升级失败案例进行分析和总结,我们发现有个共性:都是线程模型改变惹的祸!

下面小节我们就详细得对Netty3和Netty4版本的I/O线程模型进行对比,以方便大家掌握两者的差异,在升级和使用中尽量少踩雷。

6.1 Netty 3.X 版本线程模型

Netty 3.X的I/O操作线程模型比较复杂,它的处理模型包括两部分:

  1. Inbound:主要包括链路建立事件、链路激活事件、读事件、I/O异常事件、链路关闭事件等;
  2. Outbound:主要包括写事件、连接事件、监听绑定事件、刷新事件等。

我们首先分析下Inbound操作的线程模型:

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

图6-1 Netty 3 Inbound操作线程模型

从上图可以看出,Inbound操作的主要处理流程如下:

  1. I/O线程(Work线程)将消息从TCP缓冲区读取到SocketChannel的接收缓冲区中;
  2. 由I/O线程负责生成相应的事件,触发事件向上执行,调度到ChannelPipeline中;
  3. I/O线程调度执行ChannelPipeline中Handler链的对应方法,直到业务实现的Last Handler;
  4. Last Handler将消息封装成Runnable,放入到业务线程池中执行,I/O线程返回,继续读/写等I/O操作;
  5. 业务线程池从任务队列中弹出消息,并发执行业务逻辑。

通过对Netty 3的Inbound操作进行分析我们可以看出,Inbound的Handler都是由Netty的I/O Work线程负责执行。

下面我们继续分析Outbound操作的线程模型:

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

图6-2 Netty 3 Outbound操作线程模型

从上图可以看出,Outbound操作的主要处理流程如下:

业务线程发起Channel Write操作,发送消息;

  1. Netty将写操作封装成写事件,触发事件向下传播;
  2. 写事件被调度到ChannelPipeline中,由业务线程按照Handler Chain串行调用支持Downstream事件的Channel Handler;
  3. 执行到系统最后一个ChannelHandler,将编码后的消息Push到发送队列中,业务线程返回;
  4. Netty的I/O线程从发送消息队列中取出消息,调用SocketChannel的write方法进行消息发送。

6.2 Netty 4.X 版本线程模型

相比于Netty 3.X系列版本,Netty 4.X的I/O操作线程模型比较简答,它的原理图如下所示:

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

图6-3 Netty 4 Inbound和Outbound操作线程模型

从上图可以看出,Outbound操作的主要处理流程如下:

  1. I/O线程NioEventLoop从SocketChannel中读取数据报,将ByteBuf投递到ChannelPipeline,触发ChannelRead事件;
  2. I/O线程NioEventLoop调用ChannelHandler链,直到将消息投递到业务线程,然后I/O线程返回,继续后续的读写操作;
  3. 业务线程调用ChannelHandlerContext.write(Object msg)方法进行消息发送;
  4. 如果是由业务线程发起的写操作,ChannelHandlerInvoker将发送消息封装成Task,放入到I/O线程NioEventLoop的任务队列中,由NioEventLoop在循环中统一调度和执行。放入任务队列之后,业务线程返回;
  5. I/O线程NioEventLoop调用ChannelHandler链,进行消息发送,处理Outbound事件,直到将消息放入发送队列,然后唤醒Selector,进而执行写操作。

通过流程分析,我们发现Netty 4修改了线程模型,无论是Inbound还是Outbound操作,统一由I/O线程NioEventLoop调度执行。

6.3. 线程模型对比

在进行新老版本线程模型PK之前,首先还是要熟悉下串行化设计的理念:

我们知道当系统在运行过程中,如果频繁的进行线程上下文切换,会带来额外的性能损耗。多线程并发执行某个业务流程,业务开发者还需要时刻对线程安全保持警惕,哪些数据可能会被并发修改,如何保护?这不仅降低了开发效率,也会带来额外的性能损耗。

为了解决上述问题,Netty 4采用了串行化设计理念,从消息的读取、编码以及后续Handler的执行,始终都由I/O线程NioEventLoop负责,这就意外着整个流程不会进行线程上下文的切换,数据也不会面临被并发修改的风险,对于用户而言,甚至不需要了解Netty的线程细节,这确实是个非常好的设计理念,它的工作原理图如下:

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

图6-4 Netty 4的串行化设计理念

一个NioEventLoop聚合了一个多路复用器Selector,因此可以处理成百上千的客户端连接,Netty的处理策略是每当有一个新的客户端接入,则从NioEventLoop线程组中顺序获取一个可用的NioEventLoop,当到达数组上限之后,重新返回到0,通过这种方式,可以基本保证各个NioEventLoop的负载均衡。一个客户端连接只注册到一个NioEventLoop上,这样就避免了多个I/O线程去并发操作它。

Netty通过串行化设计理念降低了用户的开发难度,提升了处理性能。利用线程组实现了多个串行化线程水平并行执行,线程之间并没有交集,这样既可以充分利用多核提升并行处理能力,同时避免了线程上下文的切换和并发保护带来的额外性能损耗。

了解完了Netty 4的串行化设计理念之后,我们继续看Netty 3线程模型存在的问题,总结起来,它的主要问题如下:

  1. Inbound和Outbound实质都是I/O相关的操作,它们的线程模型竟然不统一,这给用户带来了更多的学习和使用成本;
  2. Outbound操作由业务线程执行,通常业务会使用线程池并行处理业务消息,这就意味着在某一个时刻会有多个业务线程同时操作ChannelHandler,我们需要对ChannelHandler进行并发保护,通常需要加锁。如果同步块的范围不当,可能会导致严重的性能瓶颈,这对开发者的技能要求非常高,降低了开发效率;
  3. Outbound操作过程中,例如消息编码异常,会产生Exception,它会被转换成Inbound的Exception并通知到ChannelPipeline,这就意味着业务线程发起了Inbound操作!它打破了Inbound操作由I/O线程操作的模型,如果开发者按照Inbound操作只会由一个I/O线程执行的约束进行设计,则会发生线程并发访问安全问题。由于该场景只在特定异常时发生,因此错误非常隐蔽!一旦在生产环境中发生此类线程并发问题,定位难度和成本都非常大。

讲了这么多,似乎Netty 4 完胜 Netty 3的线程模型,其实并不尽然。在特定的场景下,Netty 3的性能可能更高,就如本文第4章节所讲,如果编码和其它Outbound操作非常耗时,由多个业务线程并发执行,性能肯定高于单个NioEventLoop线程。

但是,这种性能优势不是不可逆转的,如果我们修改业务代码,将耗时的Handler操作前置,Outbound操作不做复杂业务逻辑处理,性能同样不输于Netty 3,但是考虑内存池优化、不会反复创建Event、不需要对Handler加锁等Netty 4的优化,整体性能Netty 4版本肯定会更高。

总而言之,如果用户真正熟悉并掌握了Netty 4的线程模型和功能类库,相信不仅仅开发会更加简单,性能也会更优!

6.4. 思考

就Netty 而言,掌握线程模型的重要性不亚于熟悉它的API和功能。很多时候我遇到的功能、性能等问题,都是由于缺乏对它线程模型和原理的理解导致的,结果我们就以讹传讹,认为Netty 4版本不如3好用等。

不能说所有开源软件的版本升级一定都胜过老版本,就Netty而言,我认为Netty 4版本相比于老的Netty 3,确实是历史的一大进步。

上一篇:NIO之通道(Channel)的原理与获取以及数据传输与内存映射文件


下一篇:go中的数据结构通道-channel