netty案例集锦(并发编程篇)


Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 1 页 共 77 页 1 1. Netty 案例集锦系列文章介绍.....................................................................................................4 1.1. Netty 的特点........................................................................................................................4 1.2. 案例来源.............................................................................................................................4 1.3. 并发编程篇.........................................................................................................................4 2. Netty 线程模型.............................................................................................................................5 2.1. Java 线程模型的演进..........................................................................................................5 2.1.1. 单线程......................................................................................................................5 2.1.2. 多线程......................................................................................................................5 2.1.3. 线程池......................................................................................................................5 2.2. Reactor 模型........................................................................................................................ 6 2.2.1. 单线程模型..............................................................................................................6 2.2.2. 多线程模型..............................................................................................................7 2.2.3. 主从多线程模型......................................................................................................8 2.3. Netty 线程模型分类............................................................................................................9 2.3.1. 服务端线程模型......................................................................................................9 2.3.2. 客户端线程模型....................................................................................................12 2.4. Reactor 线程 NioEventLoop............................................................................................. 16 2.4.1. NioEventLoop 介绍................................................................................................16 2.5. NioEventLoop 设计原理...................................................................................................18 2.5.1. 串行化设计避免线程竞争....................................................................................18 2.5.2. 定时任务与时间轮算法........................................................................................19 2.5.3. 聚焦而不是膨胀....................................................................................................23 3. Netty 3 版本升级遭遇内存泄漏案例.......................................................................................24 3.1. 问题描述...........................................................................................................................24 3.2. 问题定位...........................................................................................................................25 3.3. 问题根因...........................................................................................................................26 3.4. 案例总结...........................................................................................................................27 4. Netty 3 版本升级性能下降案例...............................................................................................28 4.1. 问题描述...........................................................................................................................28 4.2. 问题定位...........................................................................................................................29 4.3. 问题总结...........................................................................................................................30 5. Netty 业务 Handler 接收不到消息案例....................................................................................31 5.1. 问题描述...........................................................................................................................31 5.2. 问题定位...........................................................................................................................31 5.3. 问题总结...........................................................................................................................34 6. Netty 4 ChannelHandler 线程安全疑问.................................................................................... 35 6.1. 问题咨询...........................................................................................................................36 6.2. 解答...................................................................................................................................36 6.3. 一些特例...........................................................................................................................36 7. Netty 构建推送服务问题...........................................................................................................37 7.1. 问题描述...........................................................................................................................38 7.2. 答疑解惑...........................................................................................................................38 7.3. 问题总结...........................................................................................................................39 8. Netty 客户端连接问题...............................................................................................................40 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 2 页 共 77 页 2 8.1. 问题描述...........................................................................................................................40 8.2. 答疑解惑...........................................................................................................................40 8.3. 问题总结...........................................................................................................................41 9. 性能数据统计不准确案例.........................................................................................................42 9.1. 问题描述...........................................................................................................................42 9.2. 问题分析...........................................................................................................................42 9.3. 问题总结...........................................................................................................................44 9.4. 举一反三...........................................................................................................................45 10. Netty 线程数膨胀案例.............................................................................................................46 10.1. 问题描述.........................................................................................................................46 10.2. 问题分析.........................................................................................................................46 10.3. 案例总结.........................................................................................................................49 11. Netty 3.X 版本升级线程上下文丢失案例..............................................................................49 11.1. 问题描述.........................................................................................................................49 11.2. 问题分析.........................................................................................................................50 11.3. 问题总结.........................................................................................................................52 12. 智能家居服务端遭遇内存泄漏...............................................................................................52 12.1. 问题描述.........................................................................................................................52 12.2. 问题定位.........................................................................................................................53 12.3. 问题总结.........................................................................................................................54 13. Netty 3.X 多线程并发导致的异常..........................................................................................54 13.1. 问题描述.........................................................................................................................54 13.2. 问题分析.........................................................................................................................55 13.3. 案例总结.........................................................................................................................55 14. Netty OOM 案例.......................................................................................................................56 14.1. 问题描述.........................................................................................................................56 14.2. 问题分析.........................................................................................................................56 14.3. 问题总结.........................................................................................................................60 15. Netty 线上问题排查................................................................................................................60 15.1. 背景说明.........................................................................................................................60 15.2. 问题描述.........................................................................................................................61 15.3. 问题分析.........................................................................................................................61 15.4. 问题总结.........................................................................................................................62 16. Netty 线程开发最佳实践.........................................................................................................62 16.1. 时间可控的简单业务直接在 I/O 线程上处理.............................................................62 16.2. 复杂和时间不可控业务建议投递到后端业务线程池统一处理................................62 16.3. 业务线程避免直接操作 ChannelHandler..................................................................... 62 17. 附录...........................................................................................................................................63 17.1. 并发编程在 Netty 中的应用..........................................................................................63 17.1.1. 对共享的可变数据进行正确的同步................................................................. 63 17.1.2. 正确的使用锁......................................................................................................64 17.1.3. volatile 的正确使用..............................................................................................66 17.1.4. CAS 指令和原子类..............................................................................................69 17.1.5. 线程安全类的应用..............................................................................................71 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 3 页 共 77 页 3 17.1.6. 读写锁的应用......................................................................................................74 17.1.7. 线程安全性的文档说明......................................................................................76 17.2. 作者简介.........................................................................................................................77 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 4 页 共 77 页 4 1. Netty 案例集锦系列文章介绍 1.1. Netty 的特点 Netty 入门比较简单,主要原因有如下几点: 1. Netty 的 API 封装比较简单,将复杂的网络通信通过 BootStrap 等工具类做 了二次封装,用户使用起来比较简单; 2. Netty 源码自带的 Demo 比较多,通过 Demo 可以很快入门; 3. Netty 社区资料、相关学习书籍也比较多,学习资料比较丰富。 但是很多入门之后的 Netty 学习者遇到了很多困惑,例如不知道在实际项 目中如何使用 Netty、遇到 Netty 问题之后无从定位等,这些问题严重制约了对 Netty 的深入掌握和实际项目应用。 Netty 相关问题比较难定位的主要原因如下: 1) NIO 编程自身的复杂性,涉及到大量 NIO 类库、Netty 自身封装的类库等, 当你需要打开黑盒定位问题时,必须对这些类库了如指掌;否则即便定位到 问题所在,也不知所以然,更无法修复; 2) Netty 复杂的多线程模型,用户在实际使用 Netty 时,会涉及到 Netty 自己 封装的线程组、线程池、NIO 线程,以及业务线程,通信链路的创建、I/O 消息的读写会涉及到复杂的线程切换,这会让初学者云山雾绕,调试起来非 常痛苦,甚至都不知道从哪里调试; 3) Netty 版本的跨度大,从实际商用情况看,涉及到了 Netty 3.X、4.X 和 5.X 等多个版本,每个 Major 版本之间特性变化非常大,即便是 Minor 版本都存 在一些差异,这些功能特性和类库差异会给使用者带来很多问题,版本升级 之后稍有不慎就会掉入陷阱。 1.2. 案例来源 Netty 案例集锦的案例来源于作者在实际项目中遇到的问题总结、以及 Netty 社区网友的反馈,大多数案例都来源于实际项目,也有少部分是读者在学 习 Netty 中遭遇的比较典型的问题。 1.3. 并发编程篇 学习和掌握 Netty 并发编程模型是个难点,在实际项目中如何使用好 Netty Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 5 页 共 77 页 5 多线程更加困难,很多网上问题和事故都来源于对 Netty 线程模型了解不透彻所 致。鉴于此,Netty 案例集锦系列就首先从并发编程开始。 2. Netty 线程模型 2.1. Java 线程模型的演进 2.1.1. 单线程 时间回到 10 几年前,那时主流的 CPU 都还是单核(除了商用高性能的小机), CPU 的核心频率是机器最重要的指标之一。 在 Java 领域当时比较流行的是单线程编程,对于 CPU 密集型的应用程序而 言,频繁的通过多线程进行协作和抢占时间片反而会降低性能。 2.1.2. 多线程 随着硬件性能的提升,CPU 的核数越来越越多,很多服务器标配已经达到 32 或 64 核。通过多线程并发编程,可以充分利用多核 CPU 的处理能力,提升系统 的处理效率和并发性能。 从 2005 年开始,随着多核处理器的逐步普及,java 的多线程并发编程也逐 渐流行起来,当时商用主流的 JDK 版本是 1.4,用户可以通过 new Thread() 的方式创建新的线程。 由于 JDK1.4 并没有提供类似线程池这样的线程管理容器,多线程之间的同 步、协作、创建和销毁等工作都需要用户自己实现。由于创建和销毁线程是个相 对比较重量级的操作,因此,这种原始的多线程编程效率和性能都不高。 2.1.3. 线程池 为了提升 Java 多线程编程的效率和性能,降低用户开发难度。JDK1.5 推出 了 java.util.concurrent 并发编程包。在并发编程类库中,提供了线程池、线 程安全容器、原子类等新的类库,极大的提升了 Java 多线程编程的效率,降低 了开发难度。 从 JDK1.5 开始,基于线程池的并发编程已经成为 Java 多核编程的主流。 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 6 页 共 77 页 6 2.2. Reactor 模型 无论是 C++还是 Java 编写的网络框架,大多数都是基于 Reactor 模式进行 设计和开发,Reactor 模式基于事件驱动,特别适合处理海量的 I/O 事件。 2.2.1. 单线程模型 Reactor 单线程模型,指的是所有的 IO 操作都在同一个 NIO 线程上面完成, NIO 线程的职责如下: 1)作为 NIO 服务端,接收客户端的 TCP 连接; 2)作为 NIO 客户端,向服务端发起 TCP 连接; 3)读取通信对端的请求或者应答消息; 4)向通信对端发送消息请求或者应答消息。 Reactor 单线程模型示意图如下所示: 图 2-1 Reactor 单线程模型 由于 Reactor 模式使用的是异步非阻塞 IO,所有的 IO 操作都不会导致阻塞, 理论上一个线程可以独立处理所有 IO 相关的操作。从架构层面看,一个 NIO 线 程确实可以完成其承担的职责。例如,通过 Acceptor 类接收客户端的 TCP 连接 请求消息,链路建立成功之后,通过 Dispatch 将对应的 ByteBuffer 派发到指定 的 Handler 上进行消息解码。用户线程可以通过消息编码通过 NIO 线程将消息发 送给客户端。 对于一些小容量应用场景,可以使用单线程模型。但是对于高负载、大并 发的应用场景却不合适,主要原因如下: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 7 页 共 77 页 7 1)一个 NIO 线程同时处理成百上千的链路,性能上无法支撑,即便 NIO 线 程的 CPU 负荷达到 100%,也无法满足海量消息的编码、解码、读取和发送; 2)当 NIO 线程负载过重之后,处理速度将变慢,这会导致大量客户端连接 超时,超时之后往往会进行重发,这更加重了 NIO 线程的负载,最终会导致大量 消息积压和处理超时,成为系统的性能瓶颈; 3)可靠性问题:一旦 NIO 线程意外跑飞,或者进入死循环,会导致整个系 统通信模块不可用,不能接收和处理外部消息,造成节点故障。 为了解决这些问题,演进出了 Reactor 多线程模型,下面我们一起学习下 Reactor 多线程模型。 2.2.2. 多线程模型 Rector 多线程模型与单线程模型最大的区别就是有一组 NIO 线程处理 IO 操作,它的原理图如下: 图 2-2 Reactor 多线程模型 Reactor 多线程模型的特点: 1)有专门一个 NIO 线程-Acceptor 线程用于监听服务端,接收客户端的 TCP 连 接请求; 2)网络 IO 操作-读、写等由一个 NIO 线程池负责,线程池可以采用标准的 JDK 线程池实现,它包含一个任务队列和 N 个可用的线程,由这些 NIO 线程负责消息 的读取、解码、编码和发送; 3)1 个 NIO 线程可以同时处理 N 条链路,但是 1 个链路只对应 1 个 NIO 线程, Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 8 页 共 77 页 8 防止发生并发操作问题。 在绝大多数场景下,Reactor 多线程模型都可以满足性能需求;但是,在极 个别特殊场景中,一个 NIO 线程负责监听和处理所有的客户端连接可能会存在性 能问题。例如并发百万客户端连接,或者服务端需要对客户端握手进行安全认证, 但是认证本身非常损耗性能。在这类场景下,单独一个 Acceptor 线程可能会存 在性能不足问题,为了解决性能问题,产生了第三种 Reactor 线程模型-主从 Reactor 多线程模型。 2.2.3. 主从多线程模型 主从 Reactor 线程模型的特点是:服务端用于接收客户端连接的不再是个 1 个单独的 NIO 线程,而是一个独立的 NIO 线程池。Acceptor 接收到客户端 TCP 连接请求处理完成后(可能包含接入认证等),将新创建的 SocketChannel 注册 到 IO 线程池(sub reactor 线程池)的某个 IO 线程上,由它负责 SocketChannel 的读写和编解码工作。Acceptor 线程池仅仅只用于客户端的登陆、握手和安全认 证,一旦链路建立成功,就将链路注册到后端 subReactor 线程池的 IO 线程上, 由 IO 线程负责后续的 IO 操作。 它的线程模型如下图所示: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 9 页 共 77 页 9 图 2-3 主从 Reactor 多线程模型 利用主从 NIO 线程模型,可以解决 1 个服务端监听线程无法有效处理所有 客户端连接的性能不足问题。 它的工作流程总结如下: 1. 从主线程池中随机选择一个 Reactor 线程作为 Acceptor 线程,用于绑定监听 端口,接收客户端连接; 2. Acceptor 线程接收客户端连接请求之后创建新的 SocketChannel,将其注册到 主线程池的其它 Reactor 线程上,由其负责接入认证、IP 黑白名单过滤、握 手等操作; 3. 步骤 2 完成之后,业务层的链路正式建立,将 SocketChannel 从主线程池的 Reactor 线程的多路复用器上摘除,重新注册到 Sub 线程池的线程上,用于 处理 I/O 的读写操作。 2.3. Netty 线程模型分类 事实上,Netty 的线程模型与 1.2 章节中介绍的三种 Reactor 线程模型相似, 下面章节我们通过 Netty 服务端和客户端的线程处理流程图来介绍 Netty 的线程 模型。 2.3.1. 服务端线程模型 一种比较流行的做法是服务端监听线程和 IO 线程分离,类似于 Reactor 的 多线程模型,它的工作原理图如下: User Thread EventLoopGroup(A) EventLoopGroup(IO) 1 服务端端口绑定 2 随机选取 1 个线程作 为 Acceptor,监听客户端 连接 3 将客户端 SocketChannel 注册到 IO 线程池中 4 随机选取 1 个线 程作为IO线程处理 网络读写事件 5 IO 线程专门处理网络读 写 返回,不阻塞业务线程 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 10 页 共 77 页 10 图 2-4 Netty 服务端线程工作流程 下面我们结合 Netty 的源码,对服务端创建线程工作流程进行介绍: 第一步,从用户线程发起创建服务端操作,代码如下: 通常情况下,服务端的创建是在用户进程启动的时候进行,因此一般由 Main 函数或者启动类负责创建,服务端的创建由业务线程负责完成。在创建服务端的 时 候 实 例 化 了 2 个 EventLoopGroup , 1 个 EventLoopGroup 实 际 就 是 一 个 EventLoop 线程组,负责管理 EventLoop 的申请和释放。 EventLoopGroup 管理的线程数可以通过构造函数设置,如果没有设置,默 认取-Dio.netty.eventLoopThreads,如果该系统参数也没有指定,则为可用的 CPU 内核数 × 2。 bossGroup 线程组实际就是 Acceptor 线程池,负责处理客户端的 TCP 连接请 求,如果系统只有一个服务端端口需要监听,则建议 bossGroup 线程组线程数设 置为 1。 workerGroup 是真正负责 I/O 读写操作的线程组,通过 ServerBootstrap 的 group 方法进行设置,用于后续的 Channel 绑定。 第二步,Acceptor 线程绑定监听端口,启动 NIO 服务端,相关代码如下: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 11 页 共 77 页 11 其中,group()返回的就是 bossGroup,它的 next 方法用于从线程组中获取可用线 程,代码如下: 服务端 Channel 创建完成之后,将其注册到多路复用器 Selector 上,用于接收客 户端的 TCP 连接,核心代码如下: 第三步,如果监听到客户端连接,则创建客户端 SocketChannel 连接,重新 注册到 workerGroup 的 IO 线程上。首先看 Acceptor 如何处理客户端的接入: 调用 unsafe 的 read()方法,对于 NioServerSocketChannel,它调用了 NioMessageUnsafe 的 read()方法,代码如下: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 12 页 共 77 页 12 最终它会调用 NioServerSocketChannel 的 doReadMessages 方法,代码如下: 其中 childEventLoopGroup 就是之前的 workerGroup, 从中选择一个 I/O 线程负责 网络消息的读写。 第四步,选择 IO 线程之后,将 SocketChannel 注册到多路复用器上,监听 READ 操作。 第五步,处理网络的 I/O 读写事件,核心代码如下: 2.3.2. 客户端线程模型 相比于服务端,客户端的线程模型简单一些,它的工作原理如下: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 13 页 共 77 页 13 图 2-5 Netty 客户端线程模型 第一步,由用户线程发起客户端连接,示例代码如下: 大家发现相比于服务端,客户端只需要创建一个 EventLoopGroup,因为它不需 要独立的线程去监听客户端连接,也没必要通过一个单独的客户端线程去连接服 务端。Netty 是异步事件驱动的 NIO 框架,它的连接和所有 IO 操作都是异步的, 因此不需要创建单独的连接线程。相关代码如下: User Thread EventLoopGroup(IO) 1 创建 SocketChannel, 发起连接 2 判断连接结果,连接成功,注 册 Channel 到 IO 线程;否则,监 听连接事件 3 监听和判断连接结果,支持客 户端超时,连接成功,修改监听 位为 READ4 处理网络读写 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 14 页 共 77 页 14 当前的 group()就是之前传入的 EventLoopGroup,从中获取可用的 IO 线程 EventLoop,然后作为参数设置到新创建的 NioSocketChannel 中。 第二步,发起连接操作,判断连接结果,代码如下: 判断连接结果,如果没有连接成功,则监听连接网络操作 位 SelectionKey.OP_CONNECT。如果连接成功,则调用 pipeline().fireChannelActive() 将监听位修改为 READ。 第三步,由 NioEventLoop 的多路复用器轮询连接操作结果,代码如下: 判断连接结果,如果或连接成功,重新设置监听位为 READ: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 15 页 共 77 页 15 第四步,由 NioEventLoop 线程负责 I/O 读写,同服务端。 总结:客户端创建,线程模型如下: 1. 由用户线程负责初始化客户端资源,发起连接操作; 2. 如果连接成功,将 SocketChannel 注册到 IO 线程组的 NioEventLoop 线程中, 监听读操作位; 3. 如果没有立即连接成功,将 SocketChannel 注册到 IO 线程组的 NioEventLoop 线程中,监听连接操作位; 4. 连接成功之后,修改监听位为 READ,但是不需要切换线程。 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 16 页 共 77 页 16 2.4. Reactor 线程 NioEventLoop 2.4.1. NioEventLoop 介绍 NioEventLoop 是 Netty 的 Reactor 线程,它的职责如下: 1. 作为服务端 Acceptor 线程,负责处理客户端的请求接入; 2. 作为客户端 Connecor 线程,负责注册监听连接操作位,用于判断异步连接 结果; 3. 作为 IO 线程,监听网络读操作位,负责从 SocketChannel 中读取报文; 4. 作为 IO 线程,负责向 SocketChannel 写入报文发送给对方,如果发生写半包, 会自动注册监听写事件,用于后续继续发送半包数据,直到数据全部发送完 成; 5. 作为定时任务线程,可以执行定时任务,例如链路空闲检测和发送心跳消息 等; 6. 作为线程执行器可以执行普通的任务线程(Runnable)。 在服务端和客户端线程模型章节我们已经详细介绍了 NioEventLoop 如何处 理网络 IO 事件,下面我们简单看下它是如何处理定时任务和执行普通的 Runnable 的。 首先 NioEventLoop 继承 SingleThreadEventExecutor,这就意味着它实际上是 一个线程个数为 1 的线程池,类继承关系如下所示: 图 2-6 NioEventLoop 继承关系 SingleThreadEventExecutor 聚合了 JDK 的 java.util.concurrent.Executor 和消息 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 17 页 共 77 页 17 队列 Queue,自定义提供线程池功能,相关代码如下: 对于用户而言,直接调用 NioEventLoop 的 execute(Runnable task)方法即可执 行自定义的 Task,代码实现如下: NioEventLoop 实现了 ScheduledExecutorService 接口,这意味着它也可以执 行定时任务,对应的继承关系如下图所示: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 18 页 共 77 页 18 图 2-7 NioEventLoop 实现 ScheduledExecutorService 通 过 调 用 SingleThreadEventExecutor 的 schedule 系 列 方 法 , 可 以 在 NioEventLoop 中执行 Netty 或者用户自定义的定时任务,接口定义如下: 图 2-8 NioEventLoop 的定时任务执行接口定义 2.5. NioEventLoop 设计原理 2.5.1. 串行化设计避免线程竞争 我们知道当系统在运行过程中,如果频繁的进行线程上下文切换,会带来额 外的性能损耗。多线程并发执行某个业务流程,业务开发者还需要时刻对线程安 全保持警惕,哪些数据可能会被并发修改,如何保护?这不仅降低了开发效率, 也会带来额外的性能损耗。 为了解决上述问题,Netty 采用了串行化设计理念,从消息的读取、编码以 及后续 Handler 的执行,始终都由 IO 线程 NioEventLoop 负责,这就意外着整个 流程不会进行线程上下文的切换,数据也不会面临被并发修改的风险,对于用户 而言,甚至不需要了解 Netty 的线程细节,这确实是个非常好的设计理念,它的 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 19 页 共 77 页 19 工作原理图如下: 图 2-9 NioEventLoop 串行执行 ChannelHandler 一个 NioEventLoop 聚合了一个多路复用器 Selector,因此可以处理成百上千 的 客 户 端 连 接 ,Netty 的处理策略是每当有一个新的客户端接入,则 从 NioEventLoop 线程组中顺序获取一个可用的 NioEventLoop,当到达数组上限之 后,重新返回到 0,通过这种方式,可以基本保证各个 NioEventLoop 的负载均 衡。一个客户端连接只注册到一个 NioEventLoop 上,这样就避免了多个 IO 线程 去并发操作它。 Netty 通过串行化设计理念降低了用户的开发难度,提升了处理性能。利用 线程组实现了多个串行化线程水平并行执行,线程之间并没有交集,这样既可以 充分利用多核提升并行处理能力,同时避免了线程上下文的切换和并发保护带来 的额外性能损耗。 2.5.2. 定时任务与时间轮算法 在 Netty 中,有很多功能依赖定时任务,比较典型的有两种: 1. 客户端连接超时控制; 2. 链路空闲检测。 一种比较常用的设计理念是在 NioEventLoop 中聚合 JDK 的定时任务线程池 ScheduledExecutorService,通过它来执行定时任务。这样做单纯从性能角度看不 是最优,原因有如下三点: 1. 在 IO 线程中聚合了一个独立的定时任务线程池,这样在处理过程中会存在 client NioEventLoop NioEventLoop NioEventLoop... client client client client 串行执行 Handler 链 串行执行 Handler 链 串行执行 Handler 链 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 20 页 共 77 页 20 线程上下文切换问题,这就打破了 Netty 的串行化设计理念; 2. 存在多线程并发操作问题,因为定时任务 Task 和 IO 线程 NioEventLoop 可 能同时访问并修改同一份数据; 3. JDK 的 ScheduledExecutorService 从性能角度看,存在性能优化空间。 最早面临上述问题的是操作系统和协议栈,例如 TCP 协议栈,其可靠传输 依赖超时重传机制,因此每个通过 TCP 传输的 packet 都需要一个 timer 来调度 timeout 事件。这类超时可能是海量的,如果为每个超时都创建一个定时器,从 性能和资源消耗角度看都是不合理的。 根据 George Varghese 和 Tony Lauck 1996 年的论文《Hashed and Hierarchical Timing Wheels: data structures to efficiently implement a timer facility》提出了一种 定时轮的方式来管理和维护大量的 timer 调度。Netty 的定时任务调度就是基于时 间轮算法调度,下面我们一起来看下 Netty 的实现。 定时轮是一种数据结构,其主体是一个循环列表,每个列表中包含一个称之 为 slot 的结构,它的原理图如下: 图 2-10 时间轮工作原理 定时轮的工作原理可以类比于时钟,如上图箭头(指针)按某一个方向按固 时间轮 0 1 2 3 4 56 7 8 9 10 Timer Queue1 Timer Queue2 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 21 页 共 77 页 21 定频率轮动,每一次跳动称为一个 tick。这样可以看出定时轮由个 3 个重要的属 性参数:ticksPerWheel(一轮的 tick 数),tickDuration(一个 tick 的持续时间) 以及 timeUnit(时间单位),例如当 ticksPerWheel=60,tickDuration=1,timeUnit= 秒,这就和时钟的秒针走动完全类似了。 下面我们具体分析下 Netty 的实现:时间轮的执行由 NioEventLoop 来复杂 检测,首先看任务队列中是否有超时的定时任务和普通任务,如果有则按照比例 循环执行这些任务,代码如下: 如果没有需要理解执行的任务,则调用 Selector 的 select 方法进行等待,等 待的时间为定时任务队列中第一个超时的定时任务时延,代码如下: 从定时任务 Task 队列中弹出 delay 最小的 Task,计算超时时间,代码如下: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 22 页 共 77 页 22 定时任务的执行:经过周期 tick 之后,扫描定时任务列表,将超时的定时任 务移除到普通任务队列中,等待执行,相关代码如下: 检测和拷贝任务完成之后,就执行超时的定时任务,代码如下: 为了保证定时任务的执行不会因为过度挤占 IO 事件的处理,Netty 提供了 IO 执行比例供用户设置,用户可以设置分配给 IO 的执行比例,防止因为海量定 时任务的执行导致 IO 处理超时或者积压。 因为获取系统的纳秒时间是件耗时的操作,所以 Netty 每执行 64 个定时任 务检测一次是否达到执行的上限时间,达到则退出。如果没有执行完,放到下次 Selector 轮询时再处理,给 IO 事件的处理提供机会,代码如下: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 23 页 共 77 页 23 2.5.3. 聚焦而不是膨胀 Netty 是个异步高性能的 NIO 框架,它并不是个业务运行容器,因此它不需 要也不应该提供业务容器和业务线程。合理的设计模式是 Netty 只负责提供和管 理 NIO 线程,其它的业务层线程模型由用户自己集成,Netty 不应该提供此类功 能,只要将分层划分清楚,就会更有利于用户集成和扩展。 令人遗憾的是在 Netty 3 系列版本中,Netty 提供了类似 Mina 异步 Filter 的 ExecutionHandler,它聚合了 JDK 的线程池 java.util.concurrent.Executor,用户异 步执行后续的 Handler。 ExecutionHandler 是为了解决部分用户 Handler 可能存在执行时间不确定而 导致 IO 线程被意外阻塞或者挂住,从需求合理性角度分析这类需求本身是合理 的,但是 Netty 提供该功能却并不合适。原因总结如下: 1. 它打破了 Netty 坚持的串行化设计理念,在消息的接收和处理过程中发生了 线程切换并引入新的线程池,打破了自身架构坚守的设计原则,实际是一种 架构妥协; 2. 潜在的线程并发安全问题,如果异步 Handler 也操作它前面的用户 Handler, 而用户 Handler 又没有进行线程安全保护,这就会导致隐蔽和致命的线程安 全问题; 3. 用户开发的复杂性,引入 ExecutionHandler,打破了原来的 ChannelPipeline 串行执行模式,用户需要理解 Netty 底层的实现细节,关心线程安全等问题, 这会导致得不偿失。 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 24 页 共 77 页 24 鉴于上述原因,Netty 的后续版本彻底删除了 ExecutionHandler,而且也没有 提供类似的相关功能类,把精力聚焦在 Netty 的 IO 线程 NioEventLoop 上,这无 疑是一种巨大的进步,Netty 重新开始聚焦在 IO 线程本身,而不是提供用户相关 的业务线程模型。 3. Netty 3 版本升级遭遇内存泄漏案例 3.1. 问题描述 业务代码升级 Netty 3 到 Netty4 之后,运行一段时间,Java 进程就会宕机, 查看系统运行日志发现系统发生了内存泄露(示例堆栈): 图 3-1 内存泄漏堆栈 对内存进行监控(切换使用堆内存池,方便对内存进行监控),发现堆内 存一直飙升,如下所示(示例堆内存监控): 图 3-2 堆内存监控示例 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 25 页 共 77 页 25 3.2. 问题定位 使用 jmap -dump:format=b,file=netty.bin PID 将堆内存 dump 出来,通 过 IBM 的 HeapAnalyzer 工具进行分析,发现 ByteBuf 发生了泄露。 因为使用了 Netty 4 的内存池,所以首先怀疑是不是申请的 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 内 存池的实现原理。 Netty 内存池实现原理分析:查看 Netty 的 内 存 池 分 配 器 PooledByteBufAllocator 的源码实现,发现内存池实际是基于线程上下文实现 的,相关代码如下: 也就是说内存的申请和释放必须在同一线程上下文中,不能跨线程。跨线程 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 26 页 共 77 页 26 之后实际操作的就不是同一块儿内存区域,这会导致很多严重的问题,内存泄露 便是其中之一。内存在 A 线程申请,切换到 B 线程释放,实际是无法正确回收的。 3.3. 问题根因 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 线程负责处理。 在本案例中,ByteBuf 在业务线程中申请,在后续的 ChannelHandler 中释 放,ChannelHandler 是由 Netty 的 I/O 线程(EventLoop)执行的,因此内存的申 请和释放不在同一个线程中,导致内存泄漏。 Netty 3 的 I/O 事件处理流程: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 27 页 共 77 页 27 图 3-3 Netty 3 的 I/O 线程模型 Netty 4 的 I/O 消息处理流程: 图 3-4 Netty 4 I/O 线程模型 3.4. 案例总结 Netty 4.X 版本新增的内存池确实非常高效,但是如果使用不当则会导致各 种严重的问题。诸如内存泄露这类问题,功能测试并没有异常,如果相关接口没 有进行压测或者稳定性测试而直接上线,则会导致严重的线上问题。 内存池 PooledByteBuf 的使用建议: 1. 申请之后一定要记得释放,Netty 自身 Socket 读取和发送的 ByteBuf 系统会 自动释放,用户不需要做二次释放;如果用户使用 Netty 的内存池在应用中 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 28 页 共 77 页 28 做 ByteBuf 的对象池使用,则需要自己主动释放; 2. 避免错误的释放:跨线程释放、重复释放等都是非法操作,要避免。特别是 跨线程申请和释放,往往具有隐蔽性,问题定位难度较大; 3. 防止隐式的申请和分配:之前曾经发生过一个案例,为了解决内存池跨线程 申请和释放问题,有用户对内存池做了二次包装,以实现多线程操作时,内 存始终由包装的管理线程申请和释放,这样可以屏蔽用户业务线程模型和访 问方式的差异。谁知运行一段时间之后再次发生了内存泄露,最后发现原来 调用 ByteBuf 的 write 操作时,如果内存容量不足,会自动进行容量扩展。 扩展操作由业务线程执行,这就绕过了内存池管理线程,发生了“引用逃逸”; 4. 避免跨线程申请和使用内存池,由于存在“引用逃逸”等隐式的内存创建, 实际上跨线程申请和使用内存池是非常危险的行为。尽管从技术角度看可以 实现一个跨线程协调的内存池机制,甚至重写 PooledByteBufAllocator,但 是这无疑会增加很多复杂性,通常也使用不到。如果确实存在跨线程的 ByteBuf 传递,而且无法保证 ByteBuf 在另一个线程中会重新分配大小等操 作,最简单保险的方式就是在线程切换点做一次 ByteBuf 的拷贝,但这会造 成性能下降。 比较好的一种方案就是如果存在跨线程的 ByteBuf 传递,对 ByteBuf 的写操 作要在分配线程完成,另一个线程只能做读操作。操作完成之后发送一个事件通 知分配线程,由分配线程执行内存释放操作。 4. Netty 3 版本升级性能下降案例 4.1. 问题描述 业务代码升级 Netty 3 到 Netty4 之后,并没有给产品带来预期的性能提升, 有些甚至还发生了非常严重的性能下降,这与 Netty 官方给出的数据并不一致。 Netty 官方性能测试对比数据:我们比较了两个分别建立在 Netty 3 和 4 基础上 echo 协议服务器。(Echo 非常简单,这样,任何垃圾的产生都是 Netty 的原因,而不是协议的原因)。我使它们服务于相同的分布式 echo 协议客户端, 来自这些客户端的 16384 个并发连接重复发送 256 字节的随机负载,几乎使千兆 以太网饱和。 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 29 页 共 77 页 29 根据测试结果,Netty 4:  GC 中断频率是原来的 1/5: 45.5 vs. 9.2 次/分钟  垃圾生成速度是原来的 1/5: 207.11 vs 41.81 MiB/秒 4.2. 问题定位 首先通过 JMC 等性能分析工具对性能热点进行分析,示例如下(信息安全 等原因,只给出分析过程示例截图): 图 4-1 性能热点线程堆栈 通过对热点方法的分析,发现在消息发送过程中,有两处热点: 1. 消息发送性能统计相关 Handler; 2. 编码 Handler。 对使用 Netty 3 版本的业务产品进行性能对比测试,发现上述两个 Handler 也是热点方法。既然都是热点,为啥切换到 Netty4 之后性能下降这么厉害呢? 通过方法的调用树分析发现了两个版本的差异:在 Netty 3 中,上述两个热 点方法都是由业务线程负责执行;而在 Netty 4 中,则是由 NioEventLoop(I/O)线 程执行。对于某个链路,业务是拥有多个线程的线程池,而 NioEventLoop 只有 一个,所以执行效率更低,返回给客户端的应答时延就大。时延增大之后,自然 导致系统并发量降低,性能下降。 找出问题根因之后,针对 Netty 4 的线程模型对业务进行专项优化,将耗时 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 30 页 共 77 页 30 的编码等操作迁移到业务线程中执行,为 I/O 线程减负,性能达到预期,远超过 了 Netty 3 老版本的性能。 Netty 3 的业务线程调度模型图如下所示:充分利用了业务多线程并行编码 和 Handler 处理的优势,周期 T 内可以处理 N 条业务消息: 图 4-2 Netty 3 Handler 执行线程模型 切换到 Netty 4 之后,业务耗时 Handler 被 I/O 线程串行执行,因此性能发生 比较大的下降: 图 4-3 Netty 4 Handler 执行线程模型 4.3. 问题总结 该问题的根因还是由于 Netty 4 的线程模型变更引起,线程模型变更之后, 不仅影响业务的功能,甚至对性能也会造成很大的影响。 对 Netty 的升级需要从功能、兼容性和性能等多个角度进行综合考虑,切不 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 31 页 共 77 页 31 可只盯着 API 变更这个芝麻,而丢掉了性能这个西瓜。API 的变更会导致编译错 误,但是性能下降却隐藏于无形之中,稍不留意就会中招。 对于讲究快速交付、敏捷开发和灰度发布的互联网应用,升级的时候更应该 要当心。 5. Netty 业务 Handler 接收不到消息案例 5.1. 问题描述 我的服务碰到一个问题,经常有请求上来到 MessageDecoder 就结束了,没 有继续往 LogicServerHandler 里面送,觉得很奇怪,是不是线程池满了?我想请 教: 1. netty 5 如何打印 executor 线程的占用情况,如空闲线程数? 2. executor 设置的大小一般如何进行计算的? 业务代码示例如下: 5.2. 问题定位 从服务端初始化代码来看,并没有什么问题,业务 LogicServerHandler 没有 接收到消息,有如下几种可能: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 32 页 共 77 页 32 1. 客户端并没有将消息发送到服务端,可以在服务端 LoggingHandler 中打印日 志查看; 2. 服务端部分消息解码发生异常,导致消息被丢弃 / 忽 略 , 没 有 走 到 LogicServerHandler 中; 3. 执行业务 Handler 的 DefaultEventExecutor 中的线程太繁忙,导致任务队列积 压,长时间得不到处理。 通过抓包结合日志分析,可能导致问题的原因 1 和 2 排除,需要继续对可能 原因 3 进行排查。 Netty 5 如何打印 executor 线程的占用情况,如空闲线程数?回答这些问题, 首先要了解 Netty 的线程组和线程池机制。 Netty 的 EventExecutorGroup 实际就是一组 EventExecutor,它的定义如下: 通常通过它的 next 方法从线程组中获取一个线程池,代码如下: 为了方便操作,从EventExecutor中也可以获取它所属的 EventExecutorGroup, 接口如下: Netty EventExecutor 的 典 型 实 现 有 两 个 : DefaultEventExecutor 和 SingleThreadEventLoop,在本案例中,因为使用的是 DefaultEventExecutorGroup, Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 33 页 共 77 页 33 所 以实 际 执 行业 务 Handler 的 线程 池 就 是 DefaultEventExecutor, 它继 承自 SingleThreadEventExecutor,从名称就可以看出它是个单线程的线程池。它的工 作原理如下: 1. DefaultEventExecutor 聚合 JDK 的 Executor 和 Thread, 首次执行 Task 的时候 启动线程,将线程池状态修改为运行态; 2. Thread run 方法循环从队列中获取 Task 执行,如果队列为空,则同步阻塞, 线程无限循环执行,直到接收到退出信号。 图 5-1 DefaultEventExecutor 工作原理 用 户 想 通 过 Netty 提 供 的 DefaultEventExecutorGroup 来 并 发 执 行 业 务 Handler,但实际上却是单线程 SingleThreadEventExecutor 在串行执行业务逻辑, 当服务端消息接收速度超过业务逻辑执行速度时,就会导致业务消息积压在 SingleThreadEventExecutor 的消息队列中得不到及时处理,现象就是业务 Handler 好像得不到执行,部分业务消息丢失。 讲解完 Netty 线程模型后,问题原因也定位出来了。其实我们发现,可以通 过 EventExecutor 获取 EventExecutorGroup 的信息,然后获取整个 EventExecutor 线程组信息,最后打印线程负载信息,代码如下: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 34 页 共 77 页 34 执行结果如下: 图 5-2 EventExecutor 线程池负载打印 5.3. 问题总结 事实上,Netty 为了防止多线程执行某个 Handler(Channel)引起线程安全 问题,实际只有一个线程会执行某个 Handler,代码如下: 需要指出的是,SingleThreadEventExecutor 的 pendingTasks 可能是个耗时的 操作,因此调用的时候需要注意: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 35 页 共 77 页 35 executor 设置的大小一般如何进行计算的?如果这个 executor 用于处理业务 Handler,实际就是业务的线程池,这个需要根据业务的执行时间、TPS 数等指 标进行调整,没有标准的答案。 实际就像 JDK 的线程池,不同的业务场景、硬件环境和性能标就会有不同 的配置,无法给出标准的答案。需要进行实际测试、评估和调优来灵活调整。 最后再总结回顾下问题,对于案例中的代码,实际上在使用单线程处理某个 Handler 的 LogicServerHandler,作者可能想并发多线程执行这个 Handler,提升 业务处理性能,但实际并没有达到设计效果。 如果业务性能存在问题,并不奇怪,因为业务实际是单线程串行处理的!当 然,如果业务存在多个 Channel,则每个/多个 Channel 会对应一个线程(池), 也可以实现多线程处理,这取决于客户端的接入数。 案例中代码的线程处理模型如下所示(单个链路模型): 图 5-3 单线程执行业务逻辑线程模型图 6. Netty 4 ChannelHandler 线程安全疑问 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 36 页 共 77 页 36 6.1. 问题咨询 我有一个非线程安全的类 ThreadUnsafeClass,这个类会在 channelRead 方 法中被调用。我下面这样的调用方法在多线程环境下安全吗?谢谢! 代码示例如下: 6.2. 解答 Netty 4 优化了 Netty 3 的线程模型,其中一个非常大的优化就是用户不需 要再担心 ChannelHandler 会被并发调用,总结如下:  ChannelHandler's 的方法不会被 Netty 并发调用;  用户不再需要对 ChannelHandler 的各个方法做同步保护;  ChannelHandler 实例不允许被多次添加到 ChannelPiple 中,否则线程安全 将得不到保证 根据上述分析,MyHandler 的 channelRead 方法不会被并发调用,因此不存 在线程安全问题。 6.3. 一些特例 ChannelHandler 的线程安全存在几个特例,总结如下:  如果 ChannelHandler 被注解为 @Sharable,全局只有一个 handler 实例, 它会被多个 Channel 的 Pipeline 共享,会被多线程并发调用,因此它不是 线程安全的;  如果存在跨 ChannelHandler 的实例级变量共享,需要特别注意,它可能不 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 37 页 共 77 页 37 是线程安全的 非线程安全的跨 ChannelHandler 变量原理如下: 图 6-1 串行调用,线程安全 Netty 支 持 在 添 加 ChannelHandler 的 时 候 , 指 定 执 行 该 Handler 的 EventExecutorGroup,这就意味着在整个 ChannelPipeline 执行过程中,可能会 发生线程切换。此时,如果同一个对象在多个 ChannelHandler 中被共享,可能 会被多线程并发操作,原理如下: 图 6-2 并行调用,多 Handler 共享成员变量,非线程安全 7. Netty 构建推送服务问题 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 38 页 共 77 页 38 7.1. 问题描述 最近在使用 Netty 构建推送服务的过程中,遇到了一个问题,想再次请教 您:如何正确的处理业务逻辑?问题主要来源于阅读您发表在 InfoQ 上的文章 《Netty 系列之 Netty 线程模型》,文中提到 “2.4Netty 线程开发最佳实践中 2.4.2 复杂和时间不可控业务建议投递到后端业务线程池统一处理。对于此类业 务,不建议直接在业务 ChannelHandler 中启动线程或者线程池处理,建议将不 同的业务统一封装成 Task,统一投递到后端的业务线程池中进行处理。” 我不太理解“统一投递到后端的业务线程池中进行处理”具体如何操作? 像下面这样做是否可行: 其实我想了解的是真实生产环境中如何将业务逻辑与 Netty 网络处理部分 很好的作隔离,有没有通用的做法? 7.2. 答疑解惑 Netty 的 ChannelHandler 链由 I/O 线程执行,如果在 I/O 线程做复杂的业 务逻辑操作,可能会导致 I/O 线程无法及时进行 read()或者 write()操作。所以, 比较通用的做法如下:  在 ChannelHanlder 的 Codec 中进行编解码,由 I/O 线程做 CodeC;  将数据报反序列化成业务 Object 对象之后,将业务消息封装到 Task 中,投 递到业务线程池中进行处理,I/O 线程返回。 不建议的做法: private ExecutorService executorService = Executors.newFixedThreadPool(4); @Override public void channelRead(final ChannelHandlerContext ctx, final Object msg) throws Exception { executorService.execute(new Runnable() { @Override public void run() { doSomething(); ctx.writeAndFlush(resp); Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 39 页 共 77 页 39 图 7-1 不推荐业务和 I/O 线程共用同一个线程 推荐做法: 图 7-2 建议业务线程和 I/O 线程隔离 7.3. 问题总结 事实上,并不是说业务 ChannelHandler 一定不能由 NioEventLoop 线程执 行,如果业务 ChannelHandler 处理逻辑比较简单,执行时间是受控的,业务 I/O 线程的负载也不重,在这种应用场景下,业务 ChannelHandler 可以和 I/O 操作 共享同一个线程。使用这种线程模型会带来两个优势: 1. 开发简单:开发业务 ChannelHandler 的不需要关注 Netty 的线程模型,只 负责 ChannelHandler 的业务逻辑开发和编排即可,对开发人员的技能要求 会低一些; 2. 性能更高:因为减少了一次线程上下文切换,所以性能会更高。 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 40 页 共 77 页 40 在实际项目开发中,一些开发人员往往喜欢照葫芦画瓢,并不会分析自己的 ChannelHandler 更适合在哪种线程模型下处理。如果在 ChannelHandler 中进行 数据库等同步 I/O 操作,很有可能会导致通信模块被阻塞。所以,选择什么样的 线程模型还需要根据项目的具体情况而定,一种比较好的做法是支持策略配置, 例如阿里的 Dubbo,支持通过配置化的方式让用户选择业务在 I/O 线程池还是业 务线程池中执行,比较灵活。 8. Netty 客户端连接问题 8.1. 问题描述 Netty 客户端想同时连接多个服务端,使用如下方式,是否可行,我简单测 试了下,暂时没有发现问题。代码如下: 8.2. 答疑解惑 上述代码没有问题,原因是尽管 Bootstrap 自身不是线程安全的,但是执行 Bootstrap 的连接操作是串行执行的,而且 connect(String inetHost, int inetPort)方 法本身是线程安全的,它会创建一个新的 NioSocketChannel,并从初始构造的 EventLoopGroup 中选择一个 NioEventLoop 线程执行真正的 Channel 连接操作, 与执行 Bootstrap 的线程无关,所以通过一个 Bootstrap 连续发起多个连接操作是 安全的,它的原理如下: EventLoopGroup group = new NioEventLoopGroup(); try { Bootstrap b = new Bootstrap(); b.group(group) ......代码省略 // Start the client. ChannelFuture f1 = b.connect(HOST, PORT); ChannelFuture f2 = b.connect(HOST2, PORT2); // Wait until the connection is closed. f1.channel().closeFuture().sync(); f2.channel().closeFuture().sync(); ......代码省略 } Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 41 页 共 77 页 41 图 8-1 Netty BootStrap 工作原理 8.3. 问题总结 注意事项-资源释放问题: 在同一个 Bootstrap 中连续创建多个客户端连接, 需要注意的是 EventLoopGroup 是共享的,也就是说这些连接共用一个 NIO 线程 组EventLoopGroup,当某个链路发生异常或者关闭时,只需要关闭并释放Channel 本身即可,不能同时销毁 Channel 所使用的 NioEventLoop 和所在的线程组 EventLoopGroup,例如下面的代码片段就是错误的: 线程安全问题: 需要指出的是 Bootstrap 不是线程安全的,因此在多个线程中 并发操作 Bootstrap 是一件非常危险的事情,Bootstrap 是 I/O 操作工具类,它自 身的逻辑处理非常简单,真正的 I/O 操作都是由 EventLoop 线程负责的,所以通 常多线程操作同一个 Bootstrap 实例也是没有意义的,而且容易出错,错误代码 如下: ChannelFuture f1 = b.connect(HOST, PORT); ChannelFuture f2 = b.connect(HOST2, PORT2); f1.channel().closeFuture().sync(); } finally { group.shutdownGracefully(); } Bootstrap b = new Bootstrap(); { //多线程执行初始化、连接等操作 } Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 42 页 共 77 页 42 9. 性能数据统计不准确案例 9.1. 问题描述 某生产环境在业务高峰期,偶现服务调用时延突刺问题,时延突然增大的 服务没有固定规律,比例虽然很低,但是对客户的体验影响很大,需要尽快定位 出问题原因并解决。 9.2. 问题分析 服务调用时延增大,但并不是异常,因此运行日志并不会打印 ERROR 日志, 单靠传统的日志无法进行有效问题定位。利用分布式消息跟踪系统魔镜,进行分 布式环境的故障定界。 通过对服务调用时延进行排序和过滤,找出时延增大的服务调用链详细信 息,发现业务服务端处理很快,但是消费者统计数据却显示服务端处理非常慢, 调用链两端看到的数据不一致,怎么回事? 对调用链的埋点日志进行分析发现,服务端打印的时延是业务服务接口调用 的时延,并没有包含:  通信端读取数据报、消息解码和内部消息投递、队列排队的时间  通信端编码业务消息、在通信线程队列排队时间、消息发送到 Socket 的时 间 调用链的工作原理如下: 图 9-1 调用链工作原理 将调用链中的消息调度过程详细展开,以服务端读取请求消息为例进行说 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 43 页 共 77 页 43 明,如下图所示: 图 9-2 性能统计日志埋点 优化调用链埋点日志,措施如下:  包含客户端和服务端消息编码和解码的耗时  包含请求和应答消息在业务线程池队列中的排队时间;  包含请求和应答消息在通信线程发送队列(数组)中的排队时间 同时,为了方便问题定位,我们需要打印输出 Netty 的性能统计日志,主要 包括:  每条链路接收的总字节数、周期 T 接收的字节数、消息接收 CAPs  每条链路发送的总字节数、周期 T 发送的字节数、消息发送 CAPs 优化之后,上线运行一天之后,我们通过分析比对 Netty 性能统计日志、调 用链日志,发现双方的数据并不一致,Netty 性能统计日志统计到的数据与前端 门户看到的也不一致,因为怀疑是新增的性能统计功能存在 BUG,继续问题定位。 首先对消息发送功能进行 CodeReview,发现代码调用完 writeAndFlush 之 后直接对发送的请求消息字节数进行计数,代码如下: 实际上,调用 writeAndFlush 并不意味着消息已经发送到网络上,它的功能分解 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 44 页 共 77 页 44 如下: 图 9-3 writeAndFlush 工作原理图 通过对 writeAndFlush 方法展开分析,我们发现性能统计代码存在如下几个 问题:  业务 ChannelHandler 的执行时间  ByteBuf 在 ChannelOutboundBuffer 数组中排队时间  NioEventLoop 线程调度时间,它不仅仅只处理消息发送,还负责数据报读 取、定时任务执行以及业务定制的其它 I/O 任务  JDK NIO 类库将 ByteBuffer 写入到网络的时间,包括单条消息的多次写半包 由于性能统计遗漏了上述 4 个步骤的执行时间,因此统计出来的性能比实际 值更高,这会干扰我们的问题定位。 9.3. 问题总结 其它常见性能统计误区汇总: 1. 调用 write 方法之后就开始统计发送速率,示例代码如下: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 45 页 共 77 页 45 2. 消息编码时进行性能统计,示例代码如下: 编码之后,获取 out 可读的字节数,然后做累加。编码完成,ByteBuf 并没有被 加入到发送队列(数组)中,因此在此时做性能统计仍然是不准的。 正确的做法: 1. 调用 writeAndFlush 方法之后获取 ChannelFuture; 2. 新增消息发送 ChannelFutureListener,监听消息发送结果,如果消息写入 网 络 Socket 成 功 , 则 Netty 会 回 调 ChannelFutureListener 的 operationComplete 方法; 3. 在消息发送 ChannelFutureListener 的 operationComplete 方法中进行性能 统计。 示例代码如下: 问题定位出来之后,按照正确的做法对 Netty 性能统计代码进行了修正,上 线之后,结合调用链日志,很快定位出了业务高峰期偶现的部分服务时延毛刺较 大问题,优化业务线程池参数配置之后问题得到解决。 9.4. 举一反三 除了消息发送性能统计之外,Netty 数据报的读取、消息接收 QPS 性能统计 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 46 页 共 77 页 46 也非常容易出错,我们第一版性能统计代码消息接收 CAPs 也不准确,大家知道 为什么吗?这个留作问题,供大家自己思考。 10. Netty 线程数膨胀案例 10.1. 问题描述 分布式服务框架在进行现网问题定位时,Dump 线程堆栈之后发现 Netty 的 NIO 线程竟然有 3000 多个,大量的 NIO 线程占用了系统的句柄资源、内存资源、 CPU 资源等,引发了一些其它问题,需要尽快查明原因并解决线程过多问题。 10.2. 问题分析 在研发环境中模拟现网组网和业务场景,使用 jmc 工具进行问题定位, 使用飞行记录器对系统运行状况做快照,模拟示例图如下所示: 图 10-1 使用 jmc 工具进行问题定位 获取到黑匣子数据之后,可以对系统的各种重要指标做分析,包括系统数 据、内存、GC 数据、线程运行状态和数据等,如下图所示: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 47 页 共 77 页 47 图 10-2 获取系统资源占用详细数据 通过对线程堆栈分析,我们发现 Netty 的 NioEventLoop 线程超过了 3000 个! 图 10-3 Netty 线程占用超过 3000 个 对服务框架协议栈的 Netty 客户端和服务端源码进行 CodeReview,发现了 问题所在:  客户端每连接 1 个服务端,就会创建 1 个新的 NioEventLoopGroup,并设置 它的线程数为 1;  现网有 300 个+节点,节点之间采用多链路(10 个链路),由于业务采用了 随机路由,最终每个消费者需要跟其它 200 多个节点建立长连接,加上自己 服务端也需要占用一些 NioEventLoop 线程,最终客户端单进程线程数膨胀 到了 3000 多个。 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 48 页 共 77 页 48 业务的伪代码如下: 如果客户端对每个链路连接都创建一个新的 NioEventLoopGroup,则每个链 路就会占用 1 个独立的 NIO 线程,最终沦为 1 连接:1 线程 这种同步阻塞模式 线程模型。随着集群组网规模的不断扩大,这会带来严重的线程膨胀问题,最终 会发生句柄耗尽无法创建新的线程,或者栈内存溢出。 从另一个角度看,1 个 NIO 线程只处理一条链路也体现不出非阻塞 I/O 的优 势。案例中的错误线程模型如下所示: 图 10-4 错误的客户端连接线程使用方式 for(Link linkE : links) { EventLoopGroup group = new NioEventLoopGroup(1); Bootstrap b = new Bootstrap(); b.group(group) .channel(NioSocketChannel.class) .option(ChannelOption.TCP_NODELAY, true) // 此处省略..... b.connect(linkE.localAddress, linkE.remoteAddress); } Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 49 页 共 77 页 49 10.3. 案例总结 无论是服务端监听多个端口,还是客户端连接多个服务端,都需要注意必 须要重用 NIO 线程,否则就会导致线程资源浪费,在大规模组网时还会存在句柄 耗尽或者栈溢出等问题。 Netty 官方 Demo 仅仅是个 Sample,对用户而言,必须理解 Netty 的线程模 型,否则很容易按照官方 Demo 的做法,在外层套个 For 循环连接多个服务端, 然后,悲剧就这样发生了。 修正案例中的问题非常简单,原理如下: 图 10-5 正确的客户端连接线程模型 11. Netty 3.X 版本升级线程上下文丢失案例 11.1.问题描述 为了提升业务的二次定制能力,降低对接口的侵入性,业务使用线程变量 进行消息上下文的传递。例如消息发送源地址信息、消息 Id、会话 Id 等。 业务同时使用到了一些第三方开源容器,也提供了线程级变量上下文的能 力。业务通过容器上下文获取第三方容器的系统变量信息。 升级到 Netty 4 之后,业务继承自 Netty 的 ChannelHandler 发生了空指针 异常,无论是业务自定义的线程上下文、还是第三方容器的线程上下文,都获取 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 50 页 共 77 页 50 不到传递的变量值。 11.2.问题分析 首先检查代码,看业务是否传递了相关变量,确认业务传递之后怀疑跟 Netty 版本升级相关,调试发现,业务 ChannelHandler 获取的线程上下文对象 和之前业务传递的上下文不是同一个。这就说明执行 ChannelHandler 的线程跟 处理业务的线程不是同一个线程! 查看 Netty 4 线程模型的相关 Doc 发现,Netty 修改了 outbound 的线程模 型,正好影响了业务消息发送时的线程上下文传递,最终导致线程变量丢失。 为了更清晰的说明问题,我们对 Netty 3.X 版本的线程模型和 4.X 版本进 行对比。 Netty 3 Outbound 操作的线程模型如下: 图 11-1 Netty 3.x 版本 outbound 操作线程模型 从上图可以看出,Outbound 操作的主要处理流程如下: 1. 业务线程发起 Channel Write 操作,发送消息; 2. Netty 将写操作封装成写事件,触发事件向下传播; 3. 写事件被调度到 ChannelPipeline 中,由业务线程按照 Handler Chain 串行 调用支持 Downstream 事件的 Channel Handler; 4. 执行到系统最后一个 ChannelHandler,将编码后的消息 Push 到发送队列中, 业务线程返回; Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 51 页 共 77 页 51 5. Netty 的 I/O 线程从发送消息队列中取出消息,调用 SocketChannel 的 write 方法进行消息发送。 Netty 4.X 的 I/O 操作线程模型如下: 图 11-2 Netty 4.x 线程模型 从上图可以看出,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 调度执行。 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 52 页 共 77 页 52 11.3.问题总结 通常业务的线程模型有如下几种: 1. 业 务 自 定 义 线 程 池 / 线程组处理业务,例如使用 JDK 1.5 提 供 的 ExecutorService; 2. 使用 J2EE Web 容器自带的线程模型,常见的如 JBoss 和 Tomcat 的 HTTP 接 入线程等; 3. 隐式的使用其它第三方框架的线程模型,例如使用 NIO 框架进行协议处理, 业务代码隐式使用的就是 NIO 框架的线程模型,除非业务明确的实现自定义 线程模型。 在实践中我们发现很多业务使用了第三方框架,但是只熟悉 API 和功能,对 线程模型并不清楚。某个类库由哪个线程调用,糊里糊涂。为了方便变量传递, 又随意的使用线程变量,实际对背后第三方类库的线程模型产生了强以来。当容 器或者第三方类库升级之后,如果线程模型发生了变更,则原有功能就会发生问 题。 鉴于此,在实际工作中,尽量不要强依赖第三方类库的线程模型,如果确实 无法避免,则必须对它的线程模型有深入和清晰的了解。当第三方类库升级之后, 需要检查线程模型是否发生变更,如果发生变化,相关的代码也需要考虑同步升 级。 12. 智能家居服务端遭遇内存泄漏 12.1.问题描述 智能家居 MQTT 消息服务中间件,保持 10 万用户在线长连接,2 万用户并 发做消息请求。程序运行一段时间之后,发现内存泄露,怀疑是 Netty 的 Bug。 其它相关信息如下: 1. MQTT 消息服务中间件服务器内存 16G,8 个核心 CPU; 2. Netty 中 boss 线程池大小为 1,worker 线程池大小为 6,其余线程分配给业 务使用。该分配方式后来调整为 worker 线程池大小为 11,问题依旧; Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 53 页 共 77 页 53 3. Netty 版本为 4.0.8.Final。 12.2.问题定位 首先需要 dump 内存堆栈,对疑似内存泄露的对象和引用关系进行分析,如 下所示: 图 12-1 内存堆栈 我们发现 Netty 的 ScheduledFutureTask 增加了 9076%,达到 110W 个左右 的实例,通过对业务代码的分析发现用户使用 IdleStateHandler 用于在链路空 闲时进行业务逻辑处理,但是空闲时间设置的比较大,为 15 分钟。 Netty 的 IdleStateHandler 会根据用户的使用场景,启动三类定时任务, 分 别 是 : ReaderIdleTimeoutTask 、 WriterIdleTimeoutTask 和 AllIdleTimeoutTask,它们都会被加入到 NioEventLoop 的 Task 队列中被调度和 执行。 由于超时时间过长,10W 个长链接链路会创建 10W 个 ScheduledFutureTask 对象,每个对象还保存有业务的成员变量,非常消耗内存。用户的持久代设置的 比较大,一些定时任务被老化到持久代中,没有被 JVM 垃圾回收掉,内存一直在 增长,用户误认为存在内存泄露。 事实上,我们进一步分析发现,用户的超时时间设置的非常不合理,15 分 钟的超时达不到设计目标,重新设计之后将超时时间设置为 45 秒,内存可以正 常回收,问题解决。 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 54 页 共 77 页 54 12.3.问题总结 如果是 100 个长连接,即便是长周期的定时任务,也不存在内存泄露问题, 在新生代通过 minor GC 就可以实现内存回收。正是因为十万级的长连接,导致 小问题被放大,引出了后续的各种问题。 事实上,如果用户确实有长周期运行的定时任务,该如何处理?对于海量长 连接的推送服务,代码处理稍有不慎,就满盘皆输,下面我们针对 Netty 的架构 特点,介绍下如何使用 Netty 实现百万级客户端的推送服务。 13. Netty 3.X 多线程并发导致的异常 13.1.问题描述 系统上线一段时间之后,经常出现 java.io.EOFException 异常,但是上线 之前的系统测试却并不重新该问题,异常堆栈如下所示: XXXAdaptor 是项目中一个 rpc 传输的 po 对象,实现了 Externalizable 接 口,能够保证序列化的版本兼容。版本兼容的实现方案是参考 protocol buffer 的方式,新增的属性直接放到一个 hashmap 中,序列化传输的时候遍历 map 中的 元素,这样就可以无限制的添加新的字段。 出问题的那一行代码:someField.readUTF(),每次 rpc 调用,会把同时所 有 rpc server 节点都请求一次,每个请求是由一个线程完成的,请求的参数是 线程共享的。 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 55 页 共 77 页 55 13.2.问题分析 刚开始怀疑是网络原因导致的,在网上查的资料说是因为流数据已经读完, 就会抛出 EOFException,是正常情况。在测试环境上没有出现,只在正式环境 中出现,这种情况肯定是不正常。难道是网络数据没有发送完,那 Netty 的 ObjectEncoder 和 ObjectDecorder 肯定会出现异常,抱着试一试的想法,在 XXXAdaptor.writeExternal 方法结束的地方调用 ObjectOutput.flush(),问题 依旧。 继续对问题进行定位,在反序列的代码中加上异常捕获, XXXAdaptor.readExternal 反序列化 hashmap 的代码块中捕获异常,并且把 hashmap 中的 entry 总数打印出来,抛出异常的时候,已经遍历的 entry index 也输出。 上线之后发现了问题:size=2,index=1。在序列化中,hashmap 只有一对 key-value,size 怎么回变成 2 呢? 我们重新走查了一边 XXXAdaptor.writeExternal 方法,发现有一句话很可 疑:hashmap.put(“someKey”,”someValue”); 一般的 rpc 调用情况下,这句话没有任何问题,在线上环境,这个服务有 n 个节点,每次调用,rpc 框架在客户端都会启动 n 个线程来调用服务节点,每个 线程都会对这个对象进行序列化,XXXAdaptor.writeExternal 方法会被多个线 程同时调用,hashmap.put(“someKey”,”someValue”)实际上是被多线程操作。 而在测试环境中,只有一个服务节点,所以不会出现问题。 将 Hashmap 类型改为 ConcurrentMap,put 操作使用 putIfAbsent 代替,问 题解决。 13.3.案例总结 尽管 Netty 向用户屏蔽了底层的技术细节,但是在开发 RPC 等平台框架时, 需要非常清楚的掌握 Netty 的线程模型。否则,可能因为不必要的加锁导致性能 下降,也可能由于没有并发保护而发生并发问题。 更为重要的是,Netty 的不同版本,线程模型可能会发生变更,我们在使用 的时候需要仔细阅读 Netty 的版本变更说明和 Java DOC。 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 56 页 共 77 页 56 14. Netty OOM 案例 14.1.问题描述 问题:最近公司某产品商用发布在即,连续性能测试 1 个小时左右,开始 发生时延变大、应答消息丢失等问题,最后抛出 OOM 异常,服务端宕机。 异常堆栈示例如下: 图 14-1 OOM 异常堆栈 14.2.问题分析 通过异常堆栈和 HeapAnalyzer 工具分析,发现是 Netty 的内存池直接内存 溢出,由于业务的消息接收和发送 ByteBuf 都使用了内存池直接内存,首先排查 消息接收 ByteBuf,业务处理流程如下: 1、业务的解码器继承自 LengthFieldBasedFrameDecoder,根据报文中的消 息长度做半包解码,解码成功之后将消息投递到后端业务线程池; 2、业务没有主动释放消息接收 ByteBuf, 由于 Netty 解码之后会主动释放 ByteBuf,所以不主动释放也没问题。 排查完消息接收之后,再查看消息发送。消息发送流程是对请求消息包装之后, 编码转发给其它第三方模块,消息发送采用了 1 个独立的发送线程,在发送线程 中通过 Netty 的 NioSocketChannel 直接 write ByteBuf,ByteBuf 在发送线程中 分配,发送完成之后没有调用 release 方法主动释放内存。 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 57 页 共 77 页 57 示例代码如下: execut.execute(new Runnable() { @Override public void run() { ByteBuf buf = PooledByteBufAllocator.DEFAULT.buffer(1024 * 1024); CodeC.endcode(sendMessage, buf); channel.write(buf); //后续业务逻辑处理,没有主动释放内存 } }); 在业务线程中通过内存池申请了一个直接内存,编码发送之后并没有主动 释放内存,是否有问题? 我们继续看 Netty 的源码: 通过代码分析,我们发现当 Netty 的 ChannelOutboundBuffer 将 ByteBuf 发送之后,会将 ByteBuf 从 Entry[] buffer 中删除,同时调用 safeRelease 方 法将 ByteBuf 释放。即便业务代码不主动释放发送的 ByteBuf,Netty 也会帮助 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 58 页 共 77 页 58 用户释放,不应该发生内存泄漏啊?! 查看业务的 Netty 版本,发现业务使用的是 Netty 4.0.X 版本,突然想到 了前段时间 Netty 4 内存池泄漏问题:在业务线程中通过内存池申请内存,又在 Netty 的 NIO 线程中释放内存,这会导致内存泄漏。该问题是 Netty 4 内存池机 制和线程模型优化导致的问题,原理如下: 图 14-2 线程模型变化 使用 Netty 4.X +版本的内存池,内存的申请和释放必须要在同一个线程中, 否则会导致内存引用错乱、内存溢出等问题。 问题定位出来之后,将内存池 ByteBuf 申请的代码迁移到 ChannelHandler 的 CodeC 中,由 Netty 的 NIO 线程统一申请和释放。优化之后,性能测试 72 个 小时,内存占用平稳、GC 正常,问题解决。 随后进行压力测试,客户端启动 N 个线程,使用同一个 SocketChannel 对 服务端进行压测,24 小时之后又发生了 OOM 异常,分析之后仍然是内存池的直 接内存泄漏,怎么回事? Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 59 页 共 77 页 59 图 14-3 业务测试模型 通过定位发现,在压力测试模式下,消息发送速度大于消息接收处理的速 度,也就是说 ByteBuf 的申请速度大于释放速度,这导致了内存池不断膨胀,最 终内存溢出。 如何解决这个问题? 业务建议通过调大服务端 work 线程数的方式提升服 务端并行处理性能,但实际行不通。因为对于单链路场景,1 个链路只被某一个 work 线程处理,增加 work 线程是没有效果的。 既然通过增大服务端线程数无法解决问题,那有没有更好的解决办法?方 法有三个: 1、放弃内存池,使用非内存池模式; 2、动态流量控制; 3、采用多链路的方式。 使用非内存池模式,内存最终被 JVM 回收,而不是缓存在线程中,因而只 要堆内存设置合适就可以解决内存溢出问题。 动态流控方案:可以使用 Netty 默认提供的流量整形功能,它可以解决两 个问题: 1、防止由于上下游网元性能不均衡导致下游网元被压垮,业务流程中断 2、防止由于通信模块接收消息过快,后端业务线程处理不及时导致的“撑 死”问题 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 60 页 共 77 页 60 原理如下: 图 14-4 令牌桶原理 多链路方案:通过调大服务端 work 线程个数,提升服务端的并行处理性能, 满足高峰期的浪涌冲击。 图 14-5 多链路 14.3.问题总结 尽管 Netty 使用起来比较简单,但是如何在高并发和负载情况下保证系统 平稳运行,却是并非一件易事。 除了完善的性能测试、压力测试之外,对 Netty 底层处理机制的理解和 Code Review 也是必不可少的。 15. Netty 线上问题排查 15.1.背景说明 1、 mongodb 消息的存储引擎。 2、 LeaderServer 负责接收客户端的请求去 mongodb load 数据 ID。 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 61 页 共 77 页 61 3、 LeaderClient 每个 Topic 维护一个 LeaderClient 向 LeaderServer 发请求 要数据一段区间 ID。 4、RingBuffer Disruptor 的缓冲区,LeaderServer 接收到请求丢到 RingBuffer, 每个 Topic 有一个 单独的 RingBuffer。 5、 RingBuffer 的 process 线程 负责从 RingBuffer 解析请求,从 mongodb 查 数据,写回 LeaderClient。 15.2.问题描述 当 一 个 topic 的 请 求 查 询 mongodb 不 断 超 时 时 , 其 他 的 topic 的 LeaderClient 的 请 求 也 超 时 , 而且当时查看网卡的发送接收队列 , 发 现 LeaderServer 的接收队列堆积了好多请求,没有被处理,当时一看还以为是网络, 交换机的原因,经过 ping 网段和重启都无效。后来只能分析代码发现是由于 Netty 的 Worker IO 线程把消息解码后同步 put 到 RingBuffer 导致的。 15.3.问题分析 我们的消息系统的每个 topic 是通过 leader 这样的角色来协调多个消费者 消费一个区间的数 据。防止重复消费,不同的 topic 会通过 netty 连接到同一个 LeaderServer,而且是每个 Topic 一个链接,而且 LeaderServer 的 Netty Worker IO 线程是 10 个,这意味着每个 Worker 线程 会负责处理多个 channle 的请求。 假如有个 Worker 线程堵住,则该 Worker 线程上的 channle 上的请求都得不到处 理,被堆积在系统的接收队列里,和当时的现象相符。 再说下为啥 Worker 线程被堵住,是在 RingBuffer 被填满后即堆积 1024 个 请求后,就被 block 住, RingBuffer 的 process 线程是根据请求去 mongodb 查 数据。我们的 mongdb 由于读写都是从 主,没有做读写分离,这样就是经常有表查 询 超 时 , 超 时 时 间 是 2s, 但 是 在 2s 内 RingBuffer 不 会 立 即 堆 积 , 因 为 LeaderClient 发请求是有控制的,大概 2s 内发 10 次,那就是在 RingBuffer 的 process 线程处理了 N 次后,会导致 RingBuffer 堆满,只要一堆满,Netty 的 Worker 线程就会被 block 住 2s,意味着 2s 内这个 Worker 线程负责的所有 channel 的请求都被 堆积。如果有多个 Topic 超时,就堵得更严重,因为是多个 Netty 的 IO Worker 线程被堵住。 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 62 页 共 77 页 62 15.4.问题总结 1、使用 Netty 时,IO 线程和业务线程分开,异步处理时铁律,只要同步,就有可能 被堵住。 2、使用 Disruptor 时,process 线程最好不要是涉及 IO 或者网络操作,如果堵住, 就导致写 往 RingBuffer 的线程 block 住,而且开源的实现会导致 cpu 100%(默 认的实现是在没有可用用的 slot 时,会 sleep 1 个 ns 的时候,很容易让 cpu 跑满),这个我们 Team 已经改过了。 16. Netty 线程开发最佳实践 16.1.时间可控的简单业务直接在 I/O 线程上处理 如果业务非常简单,执行时间非常短,不需要与外部网元交互、访问数据库 和磁盘,不需要等待其它资源,则建议直接在业务 ChannelHandler 中执行,不需 要再启业务的线程或者线程池。避免线程上下文切换,也不存在线程并发问题。 16.2.复杂和时间不可控业务建议投递到后端业务线程池统一处理 对于此类业务,不建议直接在业务 ChannelHandler 中启动线程或者线程池处 理,建议将不同的业务统一封装成 Task,统一投递到后端的业务线程池中进行 处理。 过多的业务 ChannelHandler 会带来开发效率和可维护性问题,不要把 Netty 当作业务容器,对于大多数复杂的业务产品,仍然需要集成或者开发自己的业务 容器,做好和 Netty 的架构分层。 16.3.业务线程避免直接操作 ChannelHandler 对于 ChannelHandler,I/O 线程和业务线程都可能会操作,因为业务通常是 多线程模型,这样就会存在多线程操作 ChannelHandler。为了尽量避免多线程并 发问题,建议按照 Netty 自身的做法,通过将操作封装成独立的 Task 由 NioEventLoop 统一执行,而不是业务线程直接操作,相关代码如下所示: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 63 页 共 77 页 63 如果你确认并发访问的数据或者并发操作是安全的,则无需多此一举,这个 需要根据具体的业务场景进行判断,灵活处理。 17. 附录 17.1.并发编程在 Netty 中的应用 在 Java 技术领域,网络通信和多线程并发编程是相对较高级和难掌握的领 域,作为高性能的 NIO 通信框架,线程模型对 Netty 的性能影响非常大,Netty 的高性能是建立在灵活和高效的并发编程基础之上。 通过学习 Netty 的多线程并发编程技巧,对于我们掌握并在实践中灵活应用 Java 多线程编程来提升系统性能带来很大的帮助。 17.1.1. 对共享的可变数据进行正确的同步 关键字 synchronized 可以保证在同一时刻,只有一个线程可以执行某一个 方法或者代码块。同步的作用不仅仅是互斥,它的另一个作用就是共享可变性, 当某个线程修改了可变数据并释放锁后,其它的线程可以获取被修改变量的最新 值。如果没有正确的同步,这种修改对其它线程是不可见的。 下面我们就通过对 Netty 的源码进行分析,看看 Netty 是如何对并发可变 数据进行正确同步的。 以 ServerBootstrap 为例进行分析,首先看它的 option 方法: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 64 页 共 77 页 64 这个方法的作用是设置 ServerBootstrap 的 ServerSocketChannel 的 Socket 属 性,它的属性集定义如下: 由 于 是 非 线 程 安 全 的 LinkedHashMap,所以如果多线程创建、访问和修改 LinkedHashMap 时,必须在外部进行必要的同步,LinkedHashMap 的 API DOC 对 于线程安全的说明如下: 由于 ServerBootstrap 是被使用者创建和使用的,我们无法保证它的方法和成员 变量不被并发访问,因此,作为成员变量的 options 必须进行正确的同步。由于 考虑到锁的范围需要尽可能的小,我们对传参的 option 和 value 的合法性判断 不需要加锁。因此,代码才对两个判断分支独立加锁,保证锁的范围尽可能的细 粒度。 Netty 加锁的地方非常多,大家在阅读代码的时候可以仔细体会下,为什么 有的地方要加锁,有的地方有不需要?如果不需要,为什么?当你对锁的真谛理 解以后,对于这些锁的使用时机和技巧理解起来就非常容易了。 17.1.2. 正确的使用锁 对于很多刚接触多线程编程的开发者,意识到了并发访问可变变量需要加 锁,但是对于锁的范围、加锁的时机和锁的协同缺乏认识,往往会导致一些问题, Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 65 页 共 77 页 65 下面我就结合 Netty 的代码来讲解下这方面的知识。 打开 ForkJoinTask,我们学习一些多线程同步和协作方面的技巧,先看下 当条件不满足时阻塞某个任务,直到条件满足后再继续执行,代码如下: 重点看下红框中的代码,首先通过循环检测的方式对状态变量 status 进行判断, 当它的状态大于等于 0 时,执行 wait(),阻塞当前的调度线程,直到 status 小 于 0,唤醒所有被阻塞的线程,继续执行。这个方法有三个多线程的编程技巧需 要说明: 1. wait 方法别用来使线程等待某个条件,它必须在同步块内部被调用,这个同 步块通常会锁定当前对象实例。下面是这个模式的标准使用方式: synchronized(this) { While(condition) Object.wait; ...... } 2. 始终使用 wait 循环来调用 wait 方法,永远不要在循环之外调用 wait 方法。 原因是尽管条件并不满足被唤醒条件,但是由于其它线程意外调用 notifyAll()方法会导致被阻塞线程意外唤醒,此时执行条件并不满足,它 将破坏被锁保护的约定关系,导致约束失效,引起意想不到的结果; 3. 唤醒线程,应该使用 notify 还是 notifyAll,当你不知道究竟该调用哪个方 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 66 页 共 77 页 66 法时,保守的做法是调用 notifyAll 唤醒所有等待的线程。从优化的角度看, 如果处于等待的所有线程都在等待同一个条件,而每次只有一个线程可以从 这个条件中被唤醒,那么就应该选择调用 notify。 当多个线程共享同一个变量的时候,每个读或者写数据的操作方法都必须加 锁进行同步,如果没有正确的同步,就无法保证一个线程所做的修改被其它线程 可见。未能同步共享变量会造成程序的活性失败和安全性失败,这样的失败通常 是难以调试和重现的,它们可能间歇性的出问题,也可能随着并发的线程个数而 失败,也可能在不同的虚拟机或者操作系统上存在不同的失败概率。因此,我们 务必要保证锁的正确使用。下面这个案例,就是个典型的错误应用: int size = 0; public synchronized void increase() { size++; } public int current() { Return size; } 17.1.3. volatile 的正确使用 在实际工作中,我发现即便是一些经验丰富的 JAVA 设计师,对于 volatile 和多线程编程的认识仍然存在误区。其实,volatile 的使用非常简单,只要理 解了 JAVA 的内存模型和多线程编程基础知识,正确使用 volatile 是不存在任何 问题的,下面我们结合 Netty 的源码,对 volatile 的正确使用进行说明。 打开 NioEventLoop 的代码,我们来看下控制 IO 操作和其它任务运行比例 的 ioRatio,它是 int 类型的变量,定义如下: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 67 页 共 77 页 67 我们发现,它被定义为 volatile,为什么呢?首先对 volatile 关键字进行说明, 然后再结合 Netty 的代码进行分析。 关键字 volatile 是 JAVA 提供的最轻量级的同步机制,JAVA 内存模型对 volatile 专门定义了一些特殊的访问规则,下面我们就看下它的规则: 当一个变量被 volatile 修饰后,它将具备两种特性: 1. 线程可见性:当一个线程修改了被 volatile 修饰的变量后,无论是否加锁, 其它线程都可以立即看到最新的修改,而普通变量却做不到这点; 2. 禁止指令重排序优化,普通的变量仅仅保证在该方法的执行过程中所有依赖 赋值结果的地方都能获取正确的结果,而不能保证变量赋值操作的顺序与程 序代码的执行顺序一致。举个简单的例子说明下指令重排序优化问题: 我们预期程序会在 3S 后停止,但是实际上它会一直执行下去,原因就是虚拟机 对代码进行了指令重排序和优化,优化后的指令如下: if (!stop) While(true) ...... 重排序后的代码是无法发现 stop 被主线程修改的,因此无法停止运行。如 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 68 页 共 77 页 68 果要解决这个问题,只要将 stop 前增加 volatile 修饰符即可,代码修改如下: 再次运行,我们发现 3S 后程序退出,达到了预期效果,使用 volatile 解决了如 下两个问题: 1. main 线程对 stop 的修改在 workThread 线程中可见,也就是说 workThread 线程立即看到了其它线程对于 stop 变量的修改; 2. 禁止指令重排序,防止因为重排序导致的并发访问逻辑混乱。 一些人错误的认为使用 volatile 可以代替传统锁,提升并发性能,这个认 识是错误的,volatile 仅仅解决了可见性的问题,但是它并不能保证互斥性, 也就是说多个线程并发修改某个变量时,依旧会产生多线程问题。因此,不能靠 volatile 来完全替代传的锁。 根据经验总结,volatile 最适合使用的地方是一个线程写、其它线程读的 场合,如果有多个线程并发写操作,仍然需要使用锁或者线程安全的容器或者原 子变量来代替。 讲了 volatile 的原理之后,我们继续对 Netty 的源码做分析,上面我们说 到了 ioRatio 被定义成 volatile,下面看看代码为啥这样定义: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 69 页 共 77 页 69 通过代码分析我们发现,在 NioEventLoop 线程中,ioRatio 并没有被修改,它 是只读操作。那既然没有修改,为啥要定义成 volatile 呢?我们继续看代码, 我们发现 NioEventLoop 提供了重新设置 IO 执行时间比例的公共方法,接口如下: 首先,NioEventLoop 线程没有调用该方法,说明调整 IO 执行时间比例是外部发 起的操作,通常是由业务的线程调用该方法,重新设置该参数。这样就形成了一 个线程写、一个线程读,根据前面针对 volatile 的应用总结,此时可以使用 volatile 来代替传统的 synchronized 关键字提升并发访问的性能。 Netty 中大量使用了 volatile 来修改成员变量,如果理解了 volatile 的应 用场景,读懂 Netty volatile 的相关代码还是比较容易的。 17.1.4. CAS 指令和原子类 互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此 这种同步被称为阻塞同步,它属于一种悲观的并发策略,我们称之为悲观锁。随 着硬件和操作系统指令集的发展和优化,产生了非阻塞同步,被称为乐观锁。简 单的说就是先进行操作,操作完成之后再判断下看看操作是否成功,是否有并发 问题,如果有进行失败补偿,如果没有就算操作成功,这样就从根本上避免了同 步锁的弊端。 目前,在 JAVA 中应用最广泛的非阻塞同步就是 CAS,在 IA64、X86 指令集 中通过 cmpxchg 指令完成 CAS 功能,在 sparc-TSO 中由 case 指令完成,在 ARM 和 PowerPC 架构下,需要使用一对 Idrex/strex 指令完成。 从 JDK1.5 以后,可以使用 CAS 操作,该操作由 sun.misc.Unsafe 类里面的 compareAndSwapInt()和 compareAndSwapLong()等方法包装提供。通常情况下 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 70 页 共 77 页 70 sun.misc.Unsafe 类对于开发者是不可见的,因此,JDK 提供了很多 CAS 包装类 简化开发者的使用,例如 AtomicInteger 等。 下面,结合 Netty 的源码,我们对于原子类的正确使用进行详细说明: 我们打开 ChannelOutboundBuffer 的代码,看看如何对发送的总字节数进行计数 和更新操作,先看定义: 首先定义了一个 volatile 的变量,它可以保证某个线程对于 totalPendingSize 的修改可以被其它线程立即访问到,但是,它无法保证多线程并发修改的安全性。 紧 接 着 又 定 义 了 一 个 AtomicIntegerFieldUpdater 类 型 的 变 量 WTOTAL_PENDING_SIZE_UPDATER,实现 totalPendingSize 的原子更新,也就是保 证 totalPendingSize 的多线程修改并发安全性,我们重点看 下 AtomicIntegerFieldUpdater 的 API 说明: 从 API 的说明我们可以看出来,它主要用于实现 volatile 修饰的 int 变量的原 子更新操作,对于使用者,必须通过类似 compareAndSet 或者 set 或者与这些操 作等价的原子操作来保证更新的原子性,否则会导致问题。 我们继续看代码,当执行 write 操作外发消息的时候,需要对外发的消息字节数 进行统计汇总,由于调用 write 操作的既可以是 IO 线程,也可以是业务的线程, 也可能由业务线程池多个工作线程同时执行发送任务,因此,统计操作是多线程 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 71 页 共 77 页 71 并发的,这也就是为什么要将计数器定义成 volatile 并使用原子更新类进行原 子操作,下面,我们看下计数的代码: 首先,我们发现计数操作并没有实现锁,而是使用了 CAS 自旋操作,通过 TOTAL_PENDING_SIZE_UPDATER.compareAndSet(this, oldValue, newWriteBufferSize)来判断本次原子操作是否成功,如果成功则退出循环,代 码继续执行;如果失败,说明在本次操作的过程中计数器已经被其它线程更新成 功,我们需要进入循环,首先,对 oldValue 进行更新,代码如下: oldValue = totalPendingSize; 然后重新对更新值进行计算: newWriteBufferSize = oldValue + size; 继续循环进行 CAS 操作,直到成功。它跟 AtomicInteger 的 compareAndSet 操作类似。 使用 JAVA 自带的 Atomic 原子类,可以避免同步锁带来的并发访问性能降 低的问题,减少犯错的机会,因此,Netty 中对于 int、long、boolean 等 大量使用其原子类,减少了锁的应用,降低了频繁使用同步锁带来的性能下降。 17.1.5. 线程安全类的应用 在 JDK1.5 的发行版本中,Java 平台新增了 java.util.concurrent,这个 包中提供了一系列的线程安全集合、容器和线程池,利用这些新的线程安全类可 以极大的降低 Java 多线程编程的难度,提升开发效率。 新的并发编程包中的工具可以分为如下四类: 1. 线程池 Executor Framework 以及定时任务相关的类库,包括 Timer 等; Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 72 页 共 77 页 72 2. 并发集合,包括 List、Queue、Map 和 Set 等; 3. 新的同步器,例如读写锁 ReadWriteLock 等; 4. 新的原子包装类,例如 AtomicInteger 等。 在实际编码过程中,我们建议通过使用线程池、Task(Runnable/Callable)、 原子类和线程安全容器来代替传统的同步锁、wait 和 notify,提升并发访问的 性能、降低多线程编程的难度。 下面,我们针对新的线程并发包在 Netty 中的应用进行分析和说明,以期 为大家的应用提供指导。 首先,我们看下线程安全容器在 Netty 中的应用,NioEventLoop 是 IO 线程, 负责网络读写操作,它同时也执行一些非 IO 的任务,例如事件通知、定时任务 执行等,因此,它需要一个任务队列来缓存这些 Task,它的任务队列定义如下: 它是一个 ConcurrentLinkedQueue,我们看下它的 API 说明: DOC 文档明确说明这个类是线程安全的,因此,对它进行读写操作不需要加锁, 下面我们继续看下队列中增加一个任务: 读取任务,也不需要加锁: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 73 页 共 77 页 73 JDK 的线程安全容器底层采用了 CAS、volatile 和 ReadWriteLock 实现,相 比于传统重量级的同步锁,采用了更轻量、细粒度的锁,因此,性能会更高。采 用这些线程安全容器,不仅仅能提升多线程并发访问的性能,还能降低开发难度。 下 面 我 们 看 看 线 程 池 在 Netty 中 的 应 用 , 打 开 SingleThreadEventExecutor 看下它是如何定义和使用线程池的: 首先定义了一个标准的线程池用于执行任务: 接着对它赋值并且进行初始化操作: 执行任务: 我们发现,实际上是执行任务就是先把任务加入到任务队列中,然后判断线程是 否已经启动循环执行,如果不是需要启动线程,启动线程代码如下: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 74 页 共 77 页 74 实际上就是执行当前线程的 run 方法,循环从任务队列中获取 Task 并执行,我 们看下它的子类 NioEventLoop 的 run 方法就能一目了然: 如红框中所示,循环从任务队列中获取任务并执行: Netty 对 JDK 的线程池进行了封装和改造,但是,本质上仍然是利用了线程池和 线程安全队列简化了多线程编程。 17.1.6. 读写锁的应用 JDK1.5 新的并发编程工具包中新增了读写锁,它是个轻量级、细粒度的锁, 合理的使用读写锁,相比于传统的同步锁,可以提升并发访问的性能和吞吐量, 在读多写少的场景下,使用同步锁比同步块性能高一大截。 尽管 JDK1.6 之后,随着 JVM 团队对 JIT 即使编译器的不断优化,同步块和 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 75 页 共 77 页 75 读写锁的性能差距缩小了很多;但是,读写锁的应用依然非常广泛,例如,JDK 的线程安全 List CopyOnWriteArrayList 就是基于读写锁实现的,代码如下: 下面,我们对 Netty 中的读写锁应用进行分析,让大家掌握读写锁的用法, 打开 HashedWheelTimer 代码,读写锁定义如下: 当新增一个定时任务的时候使用了读锁,用于同步 wheel 的变化,由于读锁是 共享锁,所以当有多个线程同时调用 newTimeout 的时候,并不会互斥,这样, 就提升了并发读的性能。 获取并删除所有过期的任务时,由于要从迭代器中删除任务,所以使用了写锁: Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 76 页 共 77 页 76 读写锁的使用总结: 1. 主要用于读多写少的场景,用来替代传统的同步锁,以提升并发访问性能; 2. 读写锁是可重入、可降级的,一个线程获取读写锁后,可以继续递归获取; 从写锁可以降级为读锁,以便快速释放锁资源; 3. ReentrantReadWriteLock 支持获取锁的公平策略,在某些特殊的应用场景 下,可以提升并发访问的性能,同时兼顾线程等待公平性; 4. 读写锁支持非阻塞的尝试获取锁,如果获取失败,直接返回 false,而不是同 步阻塞,这个功能在一些场景下非常有用。例如多个线程同步读写某个资源, 当发生异常或者需要释放资源的时候,由哪个线程释放是个挑战,因为某些 资源不能重复释放或者重复执行,这样,可以通过 tryLock 方法尝试获取锁, 如果拿不到,说明已经被其它线程占用,直接退出即可; 5. 获取锁之后一定要释放锁,否则会发生锁溢出异常。通常的做法是通过 finally 块释放锁。如果是 tryLock,获取锁成功才需要释放锁。 17.1.7. 线程安全性的文档说明 当一个类的方法或者成员变量被并发使用的时候,这个类的行为如何,是 该类与其客户端程序建立约定的重要组成部分。如果没有在这个类的文档中描述 其行为的并发情况,使用这个类的程序员不得不做出某种假设。如果这些假设是 错误的,这个程序就缺少必要的同步保护,会导致意想不到的并发问题,这些问 题通常都是隐蔽和调试困难的。如果同步过度,会导致意外的性能下降,无论是 发生何种情况,缺少线程安全性的说明文档,都会令开发人员非常沮丧,他们会 对这些类库的使用小心翼翼,提心吊胆。 在 Netty 中,对于一些关键的类库,给出了线程安全习惯的 API DOC,尽管 Netty 的线程安全性并不是非常完善,但是,相比于一些做的更糟糕的产品,它 还是迈出了总要一步。 Netty 案例集锦之并发编程 微信公众号:Netty 之家 第 77 页 共 77 页 77 由于 ChannelPipeline 的应用非常广泛,因此,在 API 中对它的线程安全性进行 了详细的说明,这样,开发者在调用 ChannelPipeline 的 API 时,就不要再额外 的考虑线程同步和并发问题。 17.2.作者简介 李林锋,2007 年毕业于东北大学,2008 年进入华为公司从事高性能通信软 件的设计和开发工作,有 7 年 NIO 设计和开发经验,精通 Netty、Mina 等 NIO 框架和分布式服务框架、ESB 等平台中间件,现任华为 PaaS 平台架构师,《Netty 权威指南》、《分布式服务框架原理与实践》作者。 目前从事华为下一代中间件和 PaaS 平台的架构设计工作。 联系方式:新浪微博 Nettying 微信:Nettying 微信公众号:Netty 之家
还剩76页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 10 金币 [ 分享pdf获得金币 ] 4 人已下载

下载pdf

pdf贡献者

315634034

贡献于2016-03-31

下载需要 10 金币 [金币充值 ]
亲,您也可以通过 分享原创pdf 来获得金币奖励!
下载pdf