10 月,开发者不可错过的开源大数据大会-2021 WeDataSphere 社区大会深圳站 了解详情
写点什么

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

2015 年 2 月 06 日

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 内存溢出堆栈

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

图 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 事件处理流程:

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

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

图 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 的线程模型,则很可能在新的线程模型中出问题,可能是功能问题或者其它问题。

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 等性能分析工具对性能热点进行分析,示例如下(信息安全等原因,只给出分析过程示例截图):

图 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 条业务消息。

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

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

图 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 操作的线程模型:

图 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 操作的线程模型:

图 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 操作线程模型比较简答,它的原理图如下所示:

图 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 的线程细节,这确实是个非常好的设计理念,它的工作原理图如下:

图 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,确实是历史的一大进步。

7. 作者简介

李林锋,2007 年毕业于东北大学,2008 年进入华为公司从事高性能通信软件的设计和开发工作,有 7 年 NIO 设计和开发经验,精通 Netty、Mina 等 NIO 框架和平台中间件,现任华为软件平台架构部架构师,《Netty 权威指南》作者。

联系方式:新浪微博 Nettying 微信:Nettying 微信公众号:Netty 之家


感谢郭蕾对本文的策划和审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。

2015 年 2 月 06 日 22:0121857
用户头像

发布了 25 篇内容, 共 65.2 次阅读, 收获喜欢 185 次。

关注

评论

发布
暂无评论
发现更多内容

HDFS

xujiangniao

Vue路由守卫完整流程实战+解析

程序员海军

Vue 前端 VueRouter

MapReduce

xujiangniao

全靠这套大厂Java面试题目指南,让我成功斩获 25*16 薪资的offer

飞飞JAva

Java

GitHub Actions:真正的 DevOps CI

世界上最好的语言

架构 DevOps 持续集成 Github Actions NoOps

金三银四圆梦腾讯,我整理了这份“2021春招常见面试真题集合”

Crud的程序员

Java spring 编程 架构 JVM

HDFS的HA以及Yarn的HA高可用

五分钟学大数据

hdfs YARN 5月日更

YARN资源调度三种模型介绍

五分钟学大数据

YARN

yarn的多租户配置实现资源隔离

五分钟学大数据

YARN

一个江南皮鞋厂的小故事带我理解透了——什么是“代理模式”

Java架构师迁哥

TcaplusDB五一假期返工通告

TcaplusDB

数据库 nosql TcaplusDB NoSQL数据库

专家解惑 | 关于华为云盘古大模型,你想问的都在这里~

华为云开发者社区

计算机视觉 nlp 华为云 盘古大模型 预训练

架构师实战营,模块三:架构设计详细文档

ifc177

#架构实战营

zookeeper的架构

大数据技术指南

zookeeper 5月日更

架构训练营-作业三(消息队列详细架构设计文档)

eoeoeo

架构实战营

破茧成蝶!从投简历石沉大海到收割5个大厂offer,我只刷了这套面试题!

Java架构追梦

Java 阿里巴巴 架构 面试 offer

面试官:RocketMQ消费者是如何获取消息的?

互联网架构师小马

Android中绘制圆角的三种方式

teoking

android

大四实习生”都四面成功拿到字节跳动Offer了,你还有什么理由去摸鱼?

学Java关注我

Java 编程 架构 面试 计算机

GitHub开源的最全中文诗歌古典文集数据库

不脱发的程序猿

GitHub 开源 程序人生 中华古典文集数据库

Java面试:BIO,NIO,AIO 的区别,别再傻傻分不清楚

Java大蜗牛

Java 程序员 面试 编程语言 后端

如何高效率的度过一天?

程序员海军

效率 方法论

万丈高楼平地起,爆肝21000字Java基础知识总结,收藏起来总有用得着的时候

北游学Java

Java 集合 线程池 IO流

起飞!阿里独家的MySQL优化王者晋级之路,跟弯路说再见

Crud的程序员

MySQL 数据库 编程 程序员 架构

数据仓库分层架构及元数据管理

五分钟学大数据

数据仓库

Python打包后的EXE文件,如何获取同级目录

IT蜗壳-Tango

5月日更

Vue 组件通信的 8 种方式

程序员海军

Vue 前端 组件通信 引航计划

深入了解 JavaScript 对象

程序员海军

JavaScript 前端 对象

从简历被拒到收割8个大厂offer,我用了3个月成功破茧成蝶

比伯

Java 编程 架构 面试 计算机

TcaplusDB|五一结束,假期返工

数据人er

nosql TcaplusDB Tcaplus 国产数据库

五四青年节 | Tcaplus祝大家五四青年节快乐!

数据人er

数据库 nosql TcaplusDB Tcaplus

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