第一部分:基础架构与入门
00 学好 Netty,是你修炼 Java 内功的必经之路
我们知道网络层是架构设计中至关重要的环节,但 Java 的网络编程框架有很多(比如 Java NIO、Mina、Grizzy),为什么我这里只推荐 Netty 呢?
因为 Netty 是目前最流行的一款高性能 Java 网络编程框架,它被广泛使用在中间件、直播、社交、游戏等领域。目前,许多知名的开源软件也都将 Netty 用作网络通信的底层框架,如 Dubbo、RocketMQ、Elasticsearch、HBase 等。
为什么要学习Netty?
其实在互联网大厂(阿里、腾讯、美团等)的中高级 Java 开发面试中,经常会问到涉及 Netty 核心技术原理的问题,比如:
- Netty 的高性能表现在哪些方面?对你平时的项目开发有何启发?
- Netty 中有哪些重要组件,它们之间有什么联系?
- Netty 的内存池、对象池是如何设计的?
- 针对 Netty 你有哪些印象比较深刻的系统调优案例?
如果你可以学好 Netty,掌握底层原理,一定会成为你求职面试的加分项。
而且通过 Netty 的学习,还可以锻炼你的编程思维,对 Java 其他的知识体系起到融会贯通的作用。
Netty 的易用性和可靠性也极大程度上降低了开发者的心智负担。
学习目标与困难
这里我想分享一些我的学习经验,供你一同学习。学习方法不但适合 Netty,也适合其他技术。希望通过这些经验,可以一同进步。
- 首先,兴趣是最好的老师,工作之余我一定会分配出至少 10% 的时间去思考和学习新的知识,像 Netty 如此优秀的学习资源当然不能放过。
- 其次,如果你工作中缺乏项目实战,其实也不必过于担心,可以尝试实现一些 MVP 的原型系统,例如 RPC、IM 即时聊天,HTTP 服务器等。不要觉得这是在浪费时间,实践出真知,在学习 Netty 的同时你也会得到很多收获。
- 再次,在学习源码之前,首先要让自己成为一个熟练工,掌握基本理论。事实上,不论是学习什么框架,我会先尝试挑战自己。我在心中问自己:“我会如何设计它的架构?”然后再去学习相关的博客、源码等资源,思考作者的设计为什么与自己完全不一样?两者设计的差别在哪里?
- 最终,反复学习也很重要。有时在汲取新知识的时候会对之前的知识点理解产生新的想法,我会带着疑问去把相关的知识重新学习一遍,打破砂锅问到底,经常收获满满。
Netty的学习路径
在这里我也总结归纳出一份 Netty 核心知识点的思维导图,希望可以帮助你梳理本专栏的整体知识脉络。我会由浅入深地带你建立起完整的 Netty 知识体系,夯实你的 Netty 基础知识、Netty 进阶技能、实战开发经验。
- 夯实 Netty 基础知识:第一、二部分介绍 Netty 的全貌,了解 Netty 的发展现状和技术架构,并且逐一讲解了 Netty 的核心组件原理和使用,以及网络通信必不可少的编解码技能,为后面的源码解析和实践环节打下基础。
- Netty 进阶技能:第三部分讲解 Netty 的内存管理,并希望通过对比介绍 Nginx、Redis 两个著名的开源软件,帮你达到举一反三的能力。第四部分结合高频的面试问题,通过多解读剖析 Netty 的核心源码,帮助你快速准确地理解 Netty 高性能的技术原理,对其中的设计思想学以致用。
- 实战开发经验:课程最后带你从 0 到 1 打造一个高性能分布式 RPC 框架,并针对 RPC 框架的核心要点,帮助你掌握网络编程的技巧,加深对 Netty 的理解。
01 初识 Netty:为什么 Netty 这么流行?
为什么选择Netty?
Netty 是一款用于高效开发网络应用的 NIO 网络框架,它大大简化了网络应用的开发过程。我们所熟知的 TCP 和 UDP 的 Socket 服务器开发,就是一个有关 Netty 简化网络应用开发的典型案例。
既然Netty是网络应用框架,那我们永远绕不开以下几个核心关注点:
- I/O 模型、线程模型和事件处理机制;
- 易用性 API 接口;
- 对数据协议、序列化的支持。
我们之所以会最终选择 Netty,是因为 Netty 围绕这些核心要点可以做到尽善尽美,其健壮性、性能、可扩展性在同领域的框架中都首屈一指。下面我们从以下三个方面一起来看看,Netty 到底有多厉害。
高性能,低延迟
经常听到这么一句话:“网络编程只要你使用了 Netty 框架,你的程序性能基本就不会差。”这句话虽然有些绝对,但是也从侧面上反映了人们对 Netty 高性能的肯定。
I/O 请求可以分为两个阶段,分别为调用阶段和执行阶段。
- 第一个阶段为I/O 调用阶段,即用户进程向内核发起系统调用。
- 第二个阶段为I/O 执行阶段。此时,内核等待 I/O 请求处理完成返回。该阶段分为两个过程:首先等待数据就绪,并写入内核缓冲区;随后将内核缓冲区数据拷贝至用户态缓冲区。
为了方便大家理解,可以看一下这张图:
接下来我们来回顾一下 Linux 的 5 种主要 I/O 模式,并看下各种 I/O 模式的优劣势都在哪里?
完美弥补Java NIO的缺陷
1. 同步阻塞 I/O(BIO)
如上图所表现的那样,应用进程向内核发起 I/O 请求,发起调用的线程一直等待内核返回结果。一次完整的 I/O 请求称为BIO(Blocking IO,阻塞 I/O),所以 BIO 在实现异步操作时,只能使用多线程模型,一个请求对应一个线程。但是,线程的资源是有限且宝贵的,创建过多的线程会增加线程切换的开销。
2. 同步非阻塞 I/O(NIO)
在刚介绍完 BIO 的网络模型之后,NIO 自然就很好理解了。
如上图所示,应用进程向内核发起 I/O 请求后不再会同步等待结果,而是会立即返回,通过轮询的方式获取请求结果。NIO 相比 BIO 虽然大幅提升了性能,但是轮询过程中大量的系统调用导致上下文切换开销很大。所以,单独使用非阻塞 I/O 时效率并不高,而且随着并发量的提升,非阻塞 I/O 会存在严重的性能浪费。
3. I/O 多路复用
多路复用实现了一个线程处理多个 I/O 句柄的操作。多路指的是多个数据通道,复用指的是使用一个或多个固定线程来处理每一个 Socket。select、poll、epoll 都是 I/O 多路复用的具体实现,线程一次 select 调用可以获取内核态中多个数据通道的数据状态。多路复用解决了同步阻塞 I/O 和同步非阻塞 I/O 的问题,是一种非常高效的 I/O 模型。
4. 信号驱动 I/O
信号驱动 I/O 并不常用,它是一种半异步的 I/O 模型。在使用信号驱动 I/O 时,当数据准备就绪后,内核通过发送一个 SIGIO 信号通知应用进程,应用进程就可以开始读取数据了。
5. 异步 I/O
异步 I/O 最重要的一点是从内核缓冲区拷贝数据到用户态缓冲区的过程也是由系统异步完成,应用进程只需要在指定的数组中引用数据即可。异步 I/O 与信号驱动 I/O 这种半异步模式的主要区别:信号驱动 I/O 由内核通知何时可以开始一个 I/O 操作,而异步 I/O 由内核通知 I/O 操作何时已经完成。
了解了上述五种 I/O,我们再来看 Netty 如何实现自己的 I/O 模型。Netty 的 I/O 模型是基于非阻塞 I/O 实现的,底层依赖的是 JDK NIO 框架的多路复用器 Selector。一个多路复用器 Selector 可以同时轮询多个 Channel,采用 epoll 模式后,只需要一个线程负责 Selector 的轮询,就可以接入成千上万的客户端。
在 I/O 多路复用的场景下,当有数据处于就绪状态后,需要一个事件分发器(Event Dispather),它负责将读写事件分发给对应的读写事件处理器(Event Handler)。事件分发器有两种设计模式:Reactor 和 Proactor,Reactor 采用同步 I/O, Proactor 采用异步 I/O。
Reactor 实现相对简单,适合处理耗时短的场景,对于耗时长的 I/O 操作容易造成阻塞。Proactor 性能更高,但是实现逻辑非常复杂,目前主流的事件驱动模型还是依赖 select 或 epoll 来实现。
上图所描述的便是 Netty 所采用的主从 Reactor 多线程模型,所有的 I/O 事件都注册到一个 I/O 多路复用器上,当有 I/O 事件准备就绪后,I/O 多路复用器会将该 I/O 事件通过事件分发器分发到对应的事件处理器中。该线程模型避免了同步问题以及多线程切换带来的资源开销,真正做到高性能、低延迟。
完美弥补Java NIO的缺陷
在 JDK 1.4 投入使用之前,只有 BIO 一种模式。开发过程相对简单。新来一个连接就会创建一个新的线程处理。随着请求并发度的提升,BIO 很快遇到了性能瓶颈。JDK 1.4 以后开始引入了 NIO 技术,支持 select 和 poll;JDK 1.5 支持了 epoll;JDK 1.7 发布了 NIO2,支持 AIO 模型。Java 在网络领域取得了长足的进步。
既然 JDK NIO 性能已经非常优秀,为什么还要选择 Netty?这是因为 Netty 做了 JDK 该做的事,但是做得更加完备。我们一起看下 Netty 相比 JDK NIO 有哪些突出的优势。
- 易用性。 我们使用 JDK NIO 编程需要了解很多复杂的概念,比如 Channels、Selectors、Sockets、Buffers 等,编码复杂程度令人发指。相反,Netty 在 NIO 基础上进行了更高层次的封装,屏蔽了 NIO 的复杂性;Netty 封装了更加人性化的 API,统一的 API(阻塞/非阻塞) 大大降低了开发者的上手难度;与此同时,Netty 提供了很多开箱即用的工具,例如常用的行解码器、长度域解码器等,而这些在 JDK NIO 中都需要你自己实现。
- 稳定性。 Netty 更加可靠稳定,修复和完善了 JDK NIO 较多已知问题,例如臭名昭著的 select 空转导致 CPU 消耗 100%,TCP 断线重连,keep-alive 检测等问题。
- 可扩展性。 Netty 的可扩展性在很多地方都有体现,这里我主要列举其中的两点:一个是可定制化的线程模型,用户可以通过启动的配置参数选择 Reactor 线程模型;另一个是可扩展的事件驱动模型,将框架层和业务层的关注点分离。大部分情况下,开发者只需要关注 ChannelHandler 的业务逻辑实现。
更低的资源消耗
作为网络通信框架,需要处理海量的网络数据,那么必然面临有大量的网络对象需要创建和销毁的问题,对于 JVM GC 并不友好。为了降低 JVM 垃圾回收的压力,Netty 主要采用了两种优化手段:
- 对象池复用计数。Netty通过复用对象,避免频繁创建和销毁带来的开销。
- 零拷贝技术。 除了操作系统级别的零拷贝技术外,Netty 提供了更多面向用户态的零拷贝技术,例如 Netty 在 I/O 读写时直接使用 DirectBuffer,从而避免了数据在堆内存和堆外内存之间的拷贝。
因为 Netty 不仅做到了高性能、低延迟以及更低的资源消耗,还完美弥补了 Java NIO 的缺陷,所以在网络编程时越来越受到开发者们的青睐。
网络框架的选型
很多开发者都使用过 Tomcat,Tomcat 作为一款非常优秀的 Web 服务器看上去已经帮我们解决了类似问题,那么它与 Netty 到底有什么不同?
Netty和Tomcat最大的区别在于对通信协议的支持,可以说Tomcat是一个HTTP Server,它主要解决HTTP协议层的传输,而Netty不仅支持HTTP协议,还支持SSH、TLS/SSL等多种应用层的协议,而且能够自定义应用层协议。
Tomcat需要遵循遵循Servlet规范,在Servlet 3.0之前采用的是同步阻塞模型,Tomcat 6.x 版本之后已经支持 NIO,性能得到较大提升。然而 Netty 与 Tomcat 侧重点不同,所以不需要受到 Servlet 规范的约束,可以最大化发挥 NIO 特性。
如果你仅仅需要一个HTTP服务器,那么我推荐你使用Tomcat。术业有专攻,Tomcat 在这方面的成熟度和稳定性更好。但如果你需要做面向 TCP 的网络应用开发,那么 Netty 才是你最佳的选择。
此外,比较出名的网络框架还有 Mina 和 Grizzly。Mina 是 Apache Directory 服务器底层的 NIO 框架,由于 Mina 和 Netty 都是 Trustin Lee 主导的作品,所以两者在设计理念上基本一致。Netty 出现的时间更晚,可以认为是 Mina 的升级版,解决了 Mina 一些设计上的问题。比如 Netty 提供了可扩展的编解码接口、优化了 ByteBuffer 的分配方式,让用户使用起来更为便捷、安全。Grizzly 出身 Sun 公司,从设计理念上看没有 Netty 优雅,几乎是对 Java NIO 比较初级的封装,目前业界使用的范围也很小。
综上所述,Netty 是我们一个较好的选择。
Netty的发展现状
你可以去官方社区学习相关资料,下面这些网站可以帮助你学习。
- 官方社区。
- GitHub。截止至 2020 年 7 月,2.4w+ star,一共被 4w+ 的项目所使用。
尽可能不要在生产环境使用任何非稳定版本的组件。
目前主流推荐 Netty 4.x 的稳定版本,Netty 3.x 到 4.x 版本发生了较大变化,属于不兼容升级,下面我们初步了解下 4.x 版本有哪些值得你关注的变化和新特性。
- 项目结构:模块化程度更高,包名从 org.jboss.netty 更新为 io.netty,不再属于 Jboss。
- 常用 API:大多 API 都已经支持流式风格,更多新的 API 参考以下网址:https://netty.io/news/2013/06/18/4-0-0-CR5.html。
- Buffer 相关优化:Buffer 相关功能调整了现在 5 点。
- ChannelBuffer 变更为 ByteBuf,Buffer 相关的工具类可以独立使用。由于人性化的 Buffer API 设计,它已经成为 Java ByteBuffer 的完美替代品。
- Buffer 统一为动态变化,可以更安全地更改 Buffer 的容量。
- 增加新的数据类型 CompositeByteBuf,可以用于减少数据拷贝。
- GC 更加友好,增加池化缓存,4.1 版本开始 jemalloc 成为默认内存分配方式。
内存泄漏检测功能。
- 通用工具类:io.netty.util.concurrent 包中提供了较多异步编程的数据结构。
- 更加严谨的线程模型控制,降低用户编写 ChannelHandler 的心智,不必过于担心线程安全问题。
谁在使用Netty?
Netty 凭借其强大的社区影响力,越来越多的公司逐渐采用Netty 作为他们的底层通信框架,下图中我列举了一些正在使用 Netty 的公司,一起感受下它的热度吧。
Netty 经过很多出名产品在线上的大规模验证,其健壮性和稳定性都被业界认可,其中典型的产品有一下几个。
- 服务治理:Apache Dubbo、gRPC。
- 大数据:Hbase、Spark、Flink、Storm。
- 搜索引擎:Elasticsearch。
- 消息队列:RocketMQ、ActiveMQ。
还有更多优秀的产品我就不一一列举了,感兴趣的小伙伴可以参考下面网址:https://netty.io/wiki/related-projects.html。
总结
介绍了 Netty 的优势与特色,同时提到了 I/O 多路复用、Reactor 设计模式、零拷贝等必备的知识点,帮助你对 Netty 有了基本的认识。相信你一定意犹未尽,在后续的章节中我们将逐步走进 Netty 的世界。
02 纵览全局:把握 Netty 整体架构脉络
学习任何一门技术都需要有全局观,在开始上手的时候,不宜陷入琐碎的技术细节,避免走进死胡同。这节课我们以 Netty 整体架构设计为切入点,来带你明确学习目标,建立起 Netty 的学习主线,这条主线将贯穿我们整个的学习过程。
本节课以 Netty 4.1.42 为基准版本,我将分别从 Netty 整体结构、逻辑架构、源码结构三个方面对其进行介绍。
Netty整体结构
Netty 官网给出了有关 Netty 的整体功能模块结构:
1. Core核心层
Core 核心层提供了底层网络通信的通用抽象和实现,包括可扩展的事件模型、通用的通信 API、支持零拷贝的 ByteBuf 等。
2. Protocol Support协议支持层
协议支持层基本上覆盖了主流协议的编解码实现,如HTTP、SSL、Protobuf、压缩、大文件传输、WebSocket、文本、二进制等主流协议,此外 Netty 还支持自定义应用层协议。Netty 丰富的协议支持降低了用户的开发成本,基于 Netty 我们可以快速开发 HTTP、WebSocket 等服务。
3. Transport Service传输服务层
传输服务层提供了网络传输能力的定义和实现方法。它支持Sockey、HTTP隧道、虚拟机管道等传输方式。Netty对TCP、UDP等数据传输做了抽象和封装,用户可以更聚焦在业务逻辑实现上,而不必关系底层数据传输的细节。
Netty的模块设计具备较高的通用性和可扩展性,它不仅是一个优秀的网络框架,还可以作为网络编程的工具箱。Netty 的设计理念非常优雅,值得我们学习借鉴。
Netty逻辑架构
下图是 Netty 的逻辑处理架构。Netty 的逻辑处理架构为典型网络分层架构设计,共分为网络通信层、事件调度层、服务编排层,每一层各司其职。图中包含了 Netty 每一层所用到的核心组件。我将为你介绍 Netty 的每个逻辑分层中的各个核心组件以及组件之间是如何协调运作的。
网络通信层
网络通信层的职责是执行网络 I/O 的操作。它支持多种网络协议和 I/O 模型的连接操作。当网络数据读取到内核缓冲区后,会触发各种网络事件,这些网络事件会分发给事件调度层进行处理。
网络通信层的核心组件包含Bootstrap、ServerBootstrap、Channel三个组件。
- Bootstrap & ServerBootStrap
Bootstrap 是“引导”的意思,它主要负责整个 Netty 程序的启动、初始化、服务器连接等过程,它相当于一条主线,串联了 Netty 的其他核心组件。
如下图所示,Netty 中的引导器共分为两种类型:一个为用于客户端引导的 Bootstrap,另一个为用于服务端引导的 ServerBootStrap,它们都继承自抽象类 AbstractBootstrap。
Bootstrap和 ServerBootStrap 十分相似,两者非常重要的区别在于 Bootstrap 可用于连接远端服务器,只绑定一个 EventLoopGroup。而 ServerBootStrap 则用于服务端启动绑定本地端口,会绑定两个EventLoopGroup,这两个 EventLoopGroup 通常称为 Boss 和 Worker。
ServerBootStrap 中的 Boss 和 Worker 是什么角色呢?它们之间又是什么关系?这里的 Boss 和 Worker 可以理解为“老板”和“员工”的关系。每个服务器中都会有一个 Boss,也会有一群做事情的 Worker。Boss 会不停地接收新的连接,然后将连接分配给一个个 Worker 处理连接。
有了 Bootstrap 组件,我们可以更加方便地配置和启动 Netty 应用程序,它是整个 Netty 的入口,串接了 Netty 所有核心组件的初始化工作。
- Channel
Channel的字面意思是“管道”,它是网络通信的载体。Channel提供了基本的 API 用于网络 I/O 操作,如 register、bind、connect、read、write、flush 等。Netty自己实现的Channel是以JDK NIO Channel 为基础的,相比较于 JDK NIO,Netty 的 Channel 提供了更高层次的抽象,同时屏蔽了底层 Socket 的复杂性,赋予了 Channel 更加强大的功能,你在使用 Netty 时基本不需要再与 Java Socket 类直接打交道。
下图是 Channel 家族的图谱。AbstractChannel 是整个家族的基类,派生出 AbstractNioChannel、AbstractOioChannel、AbstractEpollChannel 等子类,每一种都代表了不同的 I/O 模型和协议类型。常用的 Channel 实现类有:
- NioServerSocketChannel 异步 TCP 服务端。
- NioSocketChannel 异步 TCP 客户端。
- OioServerSocketChannel 同步 TCP 服务端。
- OioSocketChannel 同步 TCP 客户端。
- NioDatagramChannel 异步 UDP 连接。
- OioDatagramChannel 同步 UDP 连接。
当然 Channel 会有多种状态,如连接建立、连接注册、数据读写、连接销毁等。随着状态的变化,Channel 处于不同的生命周期,每一种状态都会绑定相应的事件回调,下面的表格我列举了 Channel 最常见的状态所对应的事件回调。
事件 | 说明 |
---|---|
channelRegistered | Channel 创建后被注册到 EventLoop 上 |
channelUnregistered Channel | 创建后未注册或者从 EventLoop 取消注册 |
channelActive Channel | 处于就绪状态,可以被读写 |
channelInactive Channel | 处于非就绪状态 |
channelRead Channel | 可以从远端读取到数据 |
channelReadComplete | Channel 读取数据完成 |
BootStrap 和 ServerBootStrap 分别负责客户端和服务端的启动,它们是非常强大的辅助工具类;Channel 是网络通信的载体,提供了与底层 Socket 交互的能力。那么 Channel 生命周期内的事件都是如何被处理的呢?那就是 Netty 事件调度层的工作职责了。
事件调度层
事件调度层的职责是通过Reactor线程模型对各类事件进行聚合处理,通过Selector主循环线程集成多种事件( I/O 事件、信号事件、定时事件等),实际的业务处理逻辑是交由服务编排层中相关的Handler完成。
事件调度层的核心组件包括EventLoopGroup、EventLoop。
- EventLoopGroup & EventLoop
EventLoopGroup本质是一个线程池,主要负责接收I/O请求,并分配线程执行处理请求。在下图中,讲述了 EventLoopGroups、EventLoop 与 Channel 的关系。
从上图中,我们可以总结出 EventLoopGroup、EventLoop、Channel 的几点关系。
- 一个EventLoopGroup往往包含一个或者多个EventLoop。EventLoop用于处理Channel生命周期内的所有I/O事件,如accept、connect、read、write等I/O事件。
- EventLoop同一时间会与一个线程绑定,每个EventLoop负责处理多个Channel。
- 每新建一个Channel,EventLoopGroup会选择一个EventLoop与其绑定,该Channel在生命周期内都可以对EventLoop进行多次绑定和解绑。
下图是 EventLoopGroup 的家族图谱。可以看出 Netty 提供了 EventLoopGroup 的多种实现,而且 EventLoop 则是 EventLoopGroup 的子接口,所以也可以把 EventLoop 理解为 EventLoopGroup,但是它只包含一个 EventLoop 。
EventLoopGroup的实现类是NioEventLoopGroup,NioEventLoopGroup也是Netty中最被推荐使用的线程模型,NioEventLoopGroup 继承于 MultithreadEventLoopGroup,是基于 NIO 模型开发的,可以把 NioEventLoopGroup 理解为一个线程池,每个线程负责处理多个Channel,而同一个Channel只会对应一个线程。
EventLoopGroup 是 Netty 的核心处理引擎,那么 EventLoopGroup 和之前课程所提到的 Reactor 线程模型到底是什么关系呢?其实 EventLoopGroup 是 Netty Reactor 线程模型的具体实现方式,Netty 通过创建不同的 EventLoopGroup 参数配置,就可以支持 Reactor 的三种线程模型
:
- 单线程模型:EventLoopGroup之包含一个EventLoop,Boss和Worker使用同一个EventLoopGroup。
- 多线程模型:EventLoopGroup 包含多个 EventLoop,Boss 和 Worker 使用同一个EventLoopGroup;
- 主从多线程模型:EventLoopGroup包含多个EventLoop,Boss是主Reactor,Worker是从Reactor,它们分别使用不同的EventLoopGroup,主Reactor负责新的网络连接Channel创建,然后把Channel注册到从Reactor。
在介绍完事件调度层之后,可以说 Netty 的发动机已经转起来了,事件调度层负责监听网络连接和读写操作,然后触发各种类型的网络事件,需要一种机制管理这些错综复杂的事件,并有序地执行。
服务编排层
服务编排层的职责是负责组装各类服务,它是Netty的核心处理链,用以实现网络事件的动态编排和有序传播。
服务编排层的核心组件包括ChannelPipeline、ChannelHandler、ChannelHandlerContext。
- ChannelPipeline
ChannelPipeline是Netty的核心编排组件,负责组装各种ChannelHandler,实际数据的编解码以及加工处理操作都是由ChannelHandler完成的。ChannelPipeline可以理解为ChannelHandler的实例列表——内部通过双向链表将不同的ChannelHandler链接在一起。当I/O读写事件触发时,ChannelPipeline 会依次调用 ChannelHandler 列表对 Channel 的数据进行拦截和处理。
ChannelPipeline是线程安全的,因为每一个新的Channel都会对应绑定一个新的ChannelPipeline。一个ChannelPipeline关联一个EventLoop,一个EventLoop仅会绑定一个线程。
ChannelPipeline、ChannelHandler 都是高度可定制的组件。开发者可以通过这两个核心组件掌握对 Channel 数据操作的控制权。下面我们看一下 ChannelPipeline 的结构图:
从上图可以看出,ChannelPipeline 中包含入站 ChannelInboundHandler 和出站 ChannelOutboundHandler 两种处理器,我们结合客户端和服务端的数据收发流程来理解 Netty 的这两个概念。
客户端和服务端都有各自的ChannelPipeline,以客户端为例,数据从客户端发向服务端,该过程称为出站,反之则称为入站。数据入站会由一系列 InBoundHandler 处理,然后再以相反方向的 OutBoundHandler 处理后完成出站。
我们经常使用的编码 Encoder 是出站操作,解码 Decoder 是入站操作。服务端接收到客户端数据后,需要先经过 Decoder 入站处理后,再通过 Encoder 出站通知客户端。所以客户端和服务端一次完整的请求应答过程可以分为三个步骤:客户端出站(请求数据)、服务端入站(解析数据并执行业务逻辑)、服务端出站(响应结果)。
- ChannelHandler & ChannelHandlerContext
在介绍 ChannelPipeline 的过程中,想必你已经对 ChannelHandler 有了基本的概念,数据的编解码工作以及其他转换工作实际都是通过 ChannelHandler 处理的。站在开发者的角度,最需要关注的就是 ChannelHandler,我们很少会直接操作 Channel,都是通过 ChannelHandler 间接完成。
下图描述了Channel与ChannelPipeline的关系,从图中可以看出,每创建一个 Channel 都会绑定一个新的 ChannelPipeline,ChannelPipeline 中每加入一个 ChannelHandler 都会绑定一个 ChannelHandlerContext。由此可见,ChannelPipeline、ChannelHandlerContext、ChannelHandler 三个组件的关系是密切相关的,那么你一定会有疑问,每个 ChannelHandler 绑定ChannelHandlerContext 的作用是什么呢?
ChannelHandlerContext用于保存ChannelHandler上下文,通过ChannelHandlerContext我们可以知道ChannelPipeline和ChannelHandler的关联关系。
ChannelHandlerContext可以实现ChannelHandler之间的交互,ChannelHandlerContext包含了ChannelHandler生命周期的所有事件,如connect、bind、read、flush、write、close等。此外,你可以试想一下场景,如果每个ChannelHandler都有一些通用的逻辑需要实现,没有 ChannelHandlerContext 这层模型抽象,你是不是需要写很多相同的代码呢?
以上便是 Netty 的逻辑处理架构,可以看出 Netty 的架构分层设计得非常合理,屏蔽了底层 NIO 以及框架层的实现细节,对于业务开发者来说,只需要关注业务逻辑的编排和实现即可。
组件关系梳理
结合客户端和服务端的交互流程,我画了一张图,为你完整地梳理一遍 Netty 内部逻辑的流转。
- 服务端启动初始化时有Boss EventLoopGroup和Worker EventLoopGroup两个组件,其中Boss负责监听网络连接事件。当有新的网络连接事件到达时,则将Channel注册到Worker EventLoopGroup。
- Worker EventLoopGroup会被分配一个EventLoop负责处理该Channel的读写事件。每个EventLoop都是单线程的,通过Selector进行事件循环。
- 当客户端发起I/O读写事件时,服务端EventLoop会进行数据的读取,然后通过Pipeline触发各种监听器进行数据的加工处理。
- 客户端数据会被传递到ChannelPipeline的第一个ChannelInboundHandler中,数据处理完成后,将加工完成的数据传递给下一个ChannelInboundHandler。
- 当数据写回客户端时,会将处理结果在ChannelPipeline的ChannelOutboundHandler中传播,最后到达客户端。
以上便是 Netty 各个组件的整体交互流程,你只需要对每个组件的工作职责有所了解,心中可以串成一条流水线即可,具体每个组件的实现原理后续课程我们会深入介绍。
Netty源码结构
Netty 源码分为多个模块,模块之间职责划分非常清楚。如同上文整体功能模块一样,Netty 源码模块的划分也是基本契合的。
我们不仅可以使用 Netty all-in-one 的 Jar 包,也可以单独使用其中某些工具包。下面我根据 Netty 的分层结构以及实际的业务场景具体介绍 Netty 中常用的工具包。
Core核心层模块
netty-common模块是 Netty 的核心基础包,提供了丰富的工具类,其他模块都需要依赖它。在 common 模块中,常用的包括通用工具类和自定义并发包。
- 通用工具类:比如定时器工具TimerTask、时间轮HashedWheelTimer等。
- 自定义并发包:比如异步模型Future & Promise,相比JDK增强的FastThreadLocal等。
在netty-buffer模块中Netty自己实现了的一个更加完备的ByteBuf工具类,用于网络通信中的数据载体。由于人性化的 Buffer API 设计,它已经成为 Java ByteBuffer 的完美替代品。ByteBuf 的动态性设计不仅解决了 ByteBuffer 长度固定造成的内存浪费问题,而且更安全地更改了 Buffer 的容量。此外 Netty 针对 ByteBuf 做了很多优化,例如缓存池化、减少数据拷贝的 CompositeByteBuf 等。
netty-resover模块主要提供了一些有关基础设施的解析工具,包括IP Address、Hostname、DNS等。
Protocol Support 协议支持层模块
netty-codec模块主要负责编解码工作,通过编解码实现原始字节数据与业务实体对象之间的相互转化。如下图所示,Netty 支持了大多数业界主流协议的编解码器,如 HTTP、HTTP2、Redis、XML 等,为开发者节省了大量的精力。此外该模块提供了抽象的编解码类 ByteToMessageDecoder 和 MessageToByteEncoder,通过继承这两个类我们可以轻松实现自定义的编解码逻辑。
netty-handler模块主要负责数据处理工作。Netty中关于数据处理的部分,本质上是一串有序handler的集合。Netty-hanlder模块提供了开箱即用的ChannelHandler实现类,例如日志、IP 过滤、流量整形等,如果你需要这些功能,仅需在 pipeline 中加入相应的 ChannelHandler 即可。
Transport Service 传输服务层模块
netty-transport模块可以说是Netty提供数据处理和传输的核心模块。该模块提供了很多非常重要的接口,如Bootstrap、Channel、ChannelHandler、EventLoop、EventLoopGroup、ChannelPipeline等。其中 Bootstrap 负责客户端或服务端的启动工作,包括创建、初始化 Channel 等;EventLoop 负责向注册的 Channel 发起 I/O 读写操作;ChannelPipeline 负责 ChannelHandler 的有序编排,这些组件在介绍 Netty 逻辑架构的时候都有所涉及。
以上只介绍了 Netty 常用的功能模块,还有很多模块就不一一列举了,有兴趣的同学可以在 GitHub(https://github.com/netty/netty)查询 Netty 的源码。
总结
分别从整体结构、逻辑架构以及源码结构对 Netty 的整体架构进行了初步介绍,可见 Netty 的分层架构设计非常合理,实现了各层之间的逻辑解耦,对于开发者来说,只需要扩展业务逻辑即可。
03 引导器作用:客户端和服务端启动都要做些什么?
我们在使用 Netty 编写网络应用程序的时候,一定会从引导器 Bootstrap开始入手。
Bootstrap作为整个Netty客户端和服务端的程序入口,可以把Netty的核心组件像搭积木一样组装在一起。本节课我会从Netty的引导器Bootstrap出发,带你学习如何使用Netty进行最基本的程序开发。
从一个简单的HTTP服务器开始
HTTP 服务器是我们平时最常用的工具之一。完整地实现一个高性能、功能完备、健壮性强的 HTTP 服务器非常复杂,本文仅为了方便理解 Netty 网络应用开发的基本过程,所以只实现最基本的请求-响应的流程:
- 搭建HTTP服务器,配置相关参数并启动。
- 从浏览器或者终端发起HTTP请求。
- 成功得到服务端的响应结果。
Netty 的模块化设计非常优雅,客户端或者服务端的启动方式基本是固定的。作为开发者来说,只要照葫芦画瓢即可轻松上手。大多数场景下,你只需要实现与业务逻辑相关的一系列 ChannelHandler,再加上 Netty 已经预置了 HTTP 相关的编解码器就可以快速完成服务端框架的搭建。所以,我们只需要两个类就可以完成一个最简单的 HTTP 服务器,它们分别为服务器启动类和业务逻辑处理类,结合完整的代码实现我将对它们分别进行讲解。
服务端启动类
所有 Netty 服务端的启动类都可以采用如下代码结构进行开发。简单梳理一下流程:首先创建引导器;然后配置线程模型,通过引导器绑定业务逻辑处理器,并配置一些网络参数;最后绑定端口,就可以完成服务器的启动了。
public class HttpServer {public void start(int port) throws Exception {EventLoopGroup bossGroup = new NioEventLoopGroup;EventLoopGroup workerGroup = new NioEvenetLoopGroup;try {ServerBootstrap b = new ServerBootstrap();b.group(boosGroup, workerGroup).Channel(NioServerSocketChannel.class).localAddress(new InetSocketAddress(port)).childHandler(new ChannelInitializer<SocketChannel>() {@Overridepublic void initChannel(SocketChannel ch) {ch.pipeline().addLast("codec", new HttpServerCodec()) // HTTP 编解码.addLast("compressor", new HttpContentCompressor()) // HttpContent 压缩.addLast("aggregator", new HttpObjectAggregator(65536)) // HTTP 消息聚合.addLast("handler", new HttpServerHandler()); }})} finally {}}public static void main(String[] args) throws Exception {new HttpServer().start(8088);}
}
服务端业务逻辑处理类
如下代码所示,HttpServerHandler 是业务自定义的逻辑处理类。它是入站 ChannelInboundHandler 类型的处理器,负责接收解码后的 HTTP 请求数据,并将请求处理结果写回客户端。
public class HttpServerHandler extends SimpleChannelInboundHandler<FullHttpRequest> {@Overrideprotected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest msg) {String content = String.format("Receive http request, uri: %s, method: %s, content: %s%n", msg.uri(), msg.method(), msg.content().toString(CharsetUtil.UTF_8));FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1,HttpResponseStatus.OK,Unpooled.wrappedBuffer(content.getBytes()));ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);}
}
通过上面两个类,我们可以完成 HTTP 服务器最基本的请求-响应流程,测试步骤如下:
- 启动 HttpServer 的 main 函数。
- 终端或浏览器发起 HTTP 请求。
测试结果输出如下:
$ curl http://localhost:8088/abc
$ Receive http request, uri: /abc, method: GET, content:
当然,你也可以使用 Netty 自行实现 HTTP Client,客户端和服务端的启动类代码十分相似,我在附录部分提供了一份 HTTPClient 的实现代码仅供大家参考。
通过上述一个简单的 HTTP 服务示例,我们基本熟悉了 Netty 的编程模式。下面我将结合这个例子对 Netty 的引导器展开详细的介绍。
引导器实践指南
Netty服务端的启动过程大致分为三个步骤:
- 配置线程池;
- Channel初始化;
- 端口绑定。
配置线程池
Netty 是采用 Reactor 模型进行开发的,可以非常容易切换三种 Reactor 模式:单线程模式、多线程模式、主从多线程模式。
单线程模式
Reactor 单线程模型所有 I/O 操作都由一个线程完成,所以只需要启动一个 EventLoopGroup 即可。
EventLoopGroup group = new NioEventLoopGroup(1);
ServerBootstrap b = new ServerBootstrap();
b.group(group);
多线程模式
Reactor 单线程模型有非常严重的性能瓶颈,因此 Reactor 多线程模型出现了。在 Netty 中使用 Reactor 多线程模型与单线程模型非常相似,区别是 NioEventLoopGroup 可以不需要任何参数,它默认会启动 2 倍 CPU 核数的线程。当然,你也可以自己手动设置固定的线程数。
EventLoopGroup group = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(group);
主从多线程模式
在大多数场景下,我们采用的都是主从多线程Reactor模型。Boss是主Reactor,Worker是从Reactor。它们分别使用不同的NioEventLoopGroup,主Reactor负责处理Accept,然后把Channel注册到从Reactor上,从Reactor主要负责Channel生命周期内的所有I/O事件。
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup):
从上述三种 Reactor 线程模型的配置方法可以看出:Netty 线程模型的可定制化程度很高。它只需要简单配置不同的参数,便可启用不同的 Reactor 线程模型,而且无需变更其他的代码,很大程度上降低了用户开发和调试的成本。
Channel初始化
设置Channel类型
NIO 模型是 Netty 中最成熟且被广泛使用的模型。因此,推荐 Netty 服务端采用 NioServerSocketChannel 作为 Channel 的类型,客户端采用 NioSocketChannel。设置方式如下:
b.channel(NioServerSocketChannel.class);
当然,Netty 提供了多种类型的 Channel 实现类,你可以按需切换,例如 OioServerSocketChannel、EpollServerSocketChannel 等。
注册ChannelHandler
在 Netty 中可以通过 ChannelPipeline 去注册多个 ChannelHandler,每个 ChannelHandler 各司其职,这样就可以实现最大化的代码复用,充分体现了 Netty 设计的优雅之处。那么如何通过引导器添加多个 ChannelHandler 呢?其实很简单,我们看下 HTTP 服务器代码示例:
b.childHandler(new ChannelInitializer<SocketChannel>() {@Overridepublic void initChannel(SocketChannel ch) {ch.pipeline().addLast("codec", new HttpServerCodec()).addLast("compressor", new HttpContentCompressor()).addLast("aggregator", new HttpObjectAggregator(65536)) .addLast("handler", new HttpServerHandler());}
})
ServerBootstrap的childHandler()方法需要注册一个ChannelHandler。
ChannelInitializer是实现了ChannelHandler接口的匿名类,通过实例化ChannelInitializer作为ServerBootstrap的参数。
Channel初始化都会绑定一个Pipeline,它主要用于服务编排。Pipeline管理了多个 ChannelHandler。I/O事件依次在ChannelHandler中传播,ChannelHandler负责业务逻辑处理。上述HTTP服务器示例中使用链式的方式加载了多个ChannelHandler,包含HTTP编解码处理器、HTTPContent压缩处理器、HTTP消息聚合处理器、自定义业务逻辑处理器。
在以前的章节中,我们介绍了 ChannelPipeline 中入站 ChannelInboundHandler和出站 ChannelOutboundHandler的概念,在这里结合HTTP请求-响应的场景,分析下数据在ChannelPipeline中的流向。当服务端收到HTTP请求后,会依次经过HTTP编解码处理器、HTTPContent压缩处理器、HTTP消息聚合处理器、自定义业务逻辑处理器分别处理后,再将最终结果通过 HTTPContent 压缩处理器、HTTP 编解码处理器写回客户端。
设置Channel参数
Netty 提供了十分便捷的方法,用于设置 Channel 参数。关于 Channel 的参数数量非常多,如果每个参数都需要自己设置,那会非常繁琐。幸运的是 Netty 提供了默认参数设置,实际场景下默认参数已经满足我们的需求,我们仅需要修改自己关系的参数即可。
b.option(ChannelOption.SO_KEEPALIVE, true);
ServerBootstrap设置Channel属性有option和childOption两个方法,option主要负责设置Boss线程组,而childOption对应的是Worker线程组。
这里我列举了经常使用的参数含义,你可以结合业务场景,按需设置。
参数 | 含义 |
---|---|
SO_KEEPALIVE | 设置为 true 代表启用了 TCP SO_KEEPALIVE 属性,TCP 会主动探测连接状态,即连接保活 |
SO_BACKLOG | 已完成三次握手的请求队列最大长度,同一时刻服务端可能会处理多个连接,在高并发海量连接的场景下,该参数应适当调大 |
TCP_NODELAY Netty | 默认是 true,表示立即发送数据。如果设置为 false 表示启用 Nagle 算法,该算法会将 TCP 网络数据包累积到一定量才会发送,虽然可以减少报文发送的数量,但是会造成一定的数据延迟。Netty 为了最小化数据传输的延迟,默认禁用了 Nagle 算法 |
SO_SNDBUF | TCP 数据发送缓冲区大小 |
SO_RCVBUF | TCP数据接收缓冲区大小,TCP数据接收缓冲区大小 |
SO_LINGER | 设置延迟关闭的时间,等待缓冲区中的数据发送完成 |
CONNECT_TIMEOUT_MILLIS | 建立连接的超时时间 |
端口绑定
在完成上述 Netty 的配置之后,bind() 方法会真正触发启动,sync() 方法则会阻塞,直至整个启动过程完成,具体使用方式如下:
ChannelFuture f = b.bind().sync();
bind() 方法涉及的细节比较多,我们将在《源码篇:从 Linux 出发深入剖析服务端启动流程》课程中做详细地解析,在这里就先不做展开了。
关于如何使用引导器开发一个 Netty 网络应用我们就介绍完了,服务端的启动过程一定离不开配置线程池、Channel初始化、端口绑定三个步骤,在Channel初始化的过程中最重要的就是绑定用户实现的自定义业务逻辑。是不是特别简单?可以参考本节课的示例,自己尝试开发一个简单的程序练练手。
总结
围绕 Netty 的引导器,学习了如何开发最基本的网络应用程序。引导器串接了 Netty 的所有核心组件,通过引导器作为学习 Netty 的切入点有助于我们快速上手。Netty 的引导器作为一个非常方便的工具,避免我们再去手动完成繁琐的 Channel 的创建和配置等过程,其中有很多知识点可以深挖,在后续源码章节中我们再一起探索它的实现原理。
附录
HTTP 客户端类
public class HttpClient {public void connect(String host, int port) throws Exception {EveentLoopGroup group = new NioEventLoopGroup();try {Bootsteap b = new Bootstrap();b.group(group);b.channel(NioSocketChannel.clas);b.option(ChannelOption.SO_KEEPALIVE, true);b.hanlder(new ChannelInitializer<SocketChannel>() {@Overridepublic void initChannel(SocketChannel ch) {ch.pipeline().addLast(new HttpResponseDecoder());ch.pipeline().addLast(new HttpRequestEncoder());ch.pipeline().addLast(new HttpClientHandler());}});ChannelFuture f = b.connect(host, port).sync();URI uri = new URI("http://127.0.0.1:8088");String content = "Hello world";DefaultFullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET,uri.toASCIIString(), Unpooled.wrappedBuffer(content.getBytes(StandardCharsets.UTF_8)));request.headers().set(HttpHeaderNames.HOST, host);request.headers().set(HttpHeaderNames.CONNECION, HttpHeaderValues.KEEP_ALIVE);request.headers().set(HttpHeaderNames.CONNECTION_LENGTH, request.content().content().readableBytes());f.channel().write(request);f.channel().flush();f.channel().closeFuture().sync();} finally {group.shutdownGracefully();}}public static void main(String[] agrs) throws Exception {HttpClient client = new HttpClient();client.connect("127.0.0.1", 8088);}
}
客户端业务处理类
public class HttpClinetHandler extends ChannelInboundHandlerAdapter {@Overridepublic void channelReaad(ChannelHandlerContext ctx, Object msg) {if (msg instanceof HttpContext) {HttpContent content = (HttpContent) msg;ByteBuf buf = content.content();System.out.println(buf.toString(io.netty.util.CharsetUtil.UTF_8));buf.release();]}
}
04 事件调度层:为什么 EventLoop 是 Netty 的精髓?
Netty 高性能的奥秘在于其 Reactor 线程模型。 EventLoop 是 Netty Reactor 线程模型的核心处理引擎,那么它是如何高效地实现事件循环和任务处理机制的呢?
再谈 Reactor 线程模型
网络框架的设计离不开I/O线程模型,线程模型的优劣直接决定了系统的吞吐量、可扩展性、安全性等。目前主流的网络框架几乎都采用了 I/O 多路复用的方案。Reactor 模式作为其中的事件分发器,负责将读写事件分发给对应的读写事件处理者。大名鼎鼎的 Java 并发包作者 Doug Lea,在 Scalable I/O in Java 一文中阐述了服务端开发中 I/O 模型的演进过程。Netty中三种Reactor线程模型也来源于这篇文章。下面我们对这三种 Reactor 线程模型做一个详细的分析。
单线程模型
在 Reactor 单线程模型中,所有 I/O 操作(包括连接建立、数据读写、事件分发等),都是由一个线程完成的。单线程模型逻辑简单,缺陷也十分明显:
- 一个线程支持处理的连接数非常有限,CPU 很容易打满,性能方面有明显瓶颈;
- 当多个事件被同时触发时,只要有一个事件没有处理完,其他后面的事件就无法执行,这就会造成消息积压及请求超时;
- 线程在处理 I/O 事件时,Select 无法同时处理连接建立、事件分发等操作;
- 如果 I/O 线程一直处于满负荷状态,很可能造成服务端节点不可用。
多线程模型
由于单线程模型有性能方面的瓶颈,多线程模型作为解决方案就应运而生了。Reactor多线程模型将业务逻辑交给多个线程进行处理。除此之外,多线程模型其他的操作与单线程模型是类似的,例如读取数据依然保留了串行化的设计。当客户端有数据发送至服务端时,Select会监听到可读事件,数据读取完毕后提交到业务线程池中并发处理。
主从多线程模型
主从多线程模型由多个 Reactor 线程组成,每个 Reactor 线程都有独立的 Selector 对象。
MainReactor仅负责处理客户端连接的Accept事件,连接建立成功后将新创建的连接对象注册至SubReactor。再由SubReactor分配线程池中的I/O线程与其连接绑定,它将负责连接生命周期内所有的I/O事件。
Netty推荐使用主从多线程模型,这样就可以轻松达到成千上万规模的客户端连接。在海量客户端并发请求的场景下,主从多线程模式甚至可以适当增加SubReactor线程的数量,从而利用多核能力提升系统的吞吐量。
介绍了上述三种Reactor线程模型,再结合它们各自的架构图,我们能大致总结出Reactor线程模型运行机制的四个步骤:分别为连接注册、事件轮询、事件分发、任务处理,如下图所示:
- 连接注册:Channel建立后,注册至Reactor线程中的Selector选择器。
- 事件轮询:轮询Selector选择器中已注册的所有Channel的I/O事件。
- 事件分发:为准备就绪的I/O事件分配相应的处理线程。
- 任务处理:Reactor线程还负责任务队列中的非
以上介绍了 Reactor 线程模型的演进过程和基本原理,Netty 也同样遵循 Reactor 线程模型的运行机制,下面我们来了解一下 Netty 是如何实现 Reactor 线程模型的。
Netty EventLoop 实现原理
EventLoop是什么
EventLoop 这个概念其实并不是 Netty 独有的,它是一种事件等待和处理的程序模型,可以解决多线程资源消耗高的问题。例如Node.js就采用了EventLoop的运行机制,不进占用资源低,而且能够支撑了大规模的流量访问。
下图展示了 EventLoop 通用的运行模式。每当事件发生时,应用程序都会将产生的事件放入事件队列当中,然后 EventLoop 会轮询从队列中取出事件执行或者将事件分发给相应的事件监听者执行。事件执行的方式通常分为立即执行、延后执行、定期执行几种。
Netty 如何实现 EventLoop
在Netty中EventLoop可以理解为Reactor线程模型的事件处理引擎,每个EventLoop线程都维护一个Selector选择器和任务队列taskQueue。它主要负责处理I/O事件、普通任务和定时任务。
Netty中推荐使用NioEventLoop作为实现类,那么Netty是如何实现NioEventLoop的呢?首先我们来看NioEventLoop最核心的run()方法源码,我们先了解 NioEventLoop 的实现结构。
protected void run() {for (;;) {try {try {switch (selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())) {case SelectStrategy.CONTINUE:continue;case SelectStrategy.BUSY_WAIT:case SelectStrategy.SELECT:select(wakenUp.getAndSet(false)); // 轮询 I/O 事件if (wakenUp.get()) {selector.wakeup();}default:}} catch (IOException e) {rebuildSelector0();handleLoopException(e);continue;}cancelledKeys = 0;needsToSelectAgain = false;final int ioRatio = this.ioRatio;if (ioRatio == 100) {try {processSelectedKeys(); // 处理 I/O 事件} finally {runAllTasks(); // 处理所有任务}} else {final long ioStartTime = System.nanoTime();try {processSelectedKeys(); // 处理 I/O 事件} finally {final long ioTime = System.nanoTime() - ioStartTime;runAllTasks(ioTime * (100 - ioRatio) / ioRatio); // 处理完 I/O 事件,再处理异步任务队列}}} catch (Throwable t) {handleLoopException(t);}try {if (isShuttingDown()) {closeAll();if (confirmShutdown()) {return;}}} catch (Throwable t) {handleLoopException(t);}}
}
上述源码的结构比较清晰,NioEventLoop每次循环的处理流程都包含事件轮询select、事件处理processSelectedKeys、任务处理runAllTasks几个步骤,是典型的Reactor线程模型的运行机制。而且 Netty 提供了一个参数 ioRatio,可以调整 I/O 事件处理和任务处理的时间比例。下面我们将着重从事件处理和任务处理两个核心部分出发,详细介绍EventLoop的实现原理。
事件处理机制
结合Netty的整体架构,我们一起看下EventLoop的事件流转图,以便更好地理解Netty EventLoop的设计原理。NioEventLoop的事件处理机制采用的是无锁串行化的设计思路。
- BossEventLoopGroup和WorkerEventLoopGroup包含一个或者多个NioEventLoop。BossEventLoopGroup负责监听客户端的Accept事件,当事件触发时,将事件注册至WorkerEventLoopGroup中的一个NioEventLoop上。每新建一个Channel,只选择一个NioEventLoop与其绑定。所以说Channel生命周期的所有事件处理都是线程独立的,不同的NioEventLoop线程之间不会发生任何交集。
- NioEventLoop完成数据读取后,会调用绑定的ChannelPipeline进行事件传播,ChannelPipeline也是线程安全的,数据会被传递到ChannelPipeline的第一个ChannelHandler中。数据处理完成后,将加工完成的数据再传递给下一个ChannelHandler,整个过程是串行化执行,不会发生线程上下文切换的问题。
NioEventLoop无锁串行化的设计不仅使系统吞吐量达到最大化,而且降低了用户开发业务逻辑的难度,不需要花太多精力关心线程安全问题。虽然单线程执行避免了线程切换,但是它的缺陷就是不能执行时间过长的 I/O 操作,一旦某个 I/O 事件发生阻塞,那么后续的所有 I/O 事件都无法执行,甚至造成事件积压。在使用 Netty 进行程序开发时,我们一定要对 ChannelHandler 的实现逻辑有充分的风险意识。
NioEventLoop线程的可靠性至关重要,一旦 NioEventLoop 发生阻塞或者陷入空轮询,就会导致整个系统不可用。在 JDK 中, Epoll 的实现是存在漏洞的,即使 Selector 轮询的事件列表为空,NIO 线程一样可以被唤醒,导致 CPU 100% 占用。这就是臭名昭著的 JDK epoll 空轮询的 Bug。Netty 作为一个高性能、高可靠的网络框架,需要保证 I/O 线程的安全性。那么它是如何解决 JDK epoll 空轮询的 Bug 呢?实际上 Netty 并没有从根源上解决该问题,而是巧妙地规避了这个问题。
我们抛开其他细枝末节,直接定位到事件轮询 select() 方法中的最后一部分代码,一起看下 Netty 是如何解决 epoll 空轮询的 Bug。
long time = System.nanoTime();
if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {selectCnt = 1;
} else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 && selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {selector = selectRebuildSelector(selectCnt);selectCnt = 1;break;
}
Netty提供了一种检测机制判断线程是否可能陷入空训轮询,具体的实现如下:
- 每次执行 Select 操作之前记录当前时间 currentTimeNanos。
- time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos,如果事件轮询的持续时间大于等于 timeoutMillis,那么说明是正常的,否则表明阻塞时间并未达到预期,可能触发了空轮询的 Bug。
- Netty引入了计数变量selectCnt。在正常情况下,selectCnt会重置,否则会对 selectCnt 自增计数。当 selectCnt 达到 SELECTOR_AUTO_REBUILD_THRESHOLD(默认512) 阈值时,会触发重建 Selector 对象。
Netty 采用这种方法巧妙地规避了 JDK Bug。异常的 Selector 中所有的 SelectionKey 会重新注册到新建的 Selector 上,重建完成之后异常的 Selector 就可以废弃了。
任务处理机制
NioEventLoop 不仅负责处理 I/O 事件,还要兼顾执行任务队列中的任务。任务队列遵循 FIFO 规则,可以保证任务执行的公平性。NioEventLoop 处理的任务类型基本可以分为三类。
- 普通任务:通过NioEventLoop的execute()方法向任务队列taskQueue中添加任务。例如 Netty 在写数据时会封装 WriteAndFlushTask 提交给 taskQueue。taskQueue 的实现类是多生产者单消费者队列 MpscChunkedArrayQueue,在多线程并发添加任务时,可以保证线程安全。
- 定时任务:通过调用 NioEventLoop 的 schedule() 方法向定时任务队列 scheduledTaskQueue 添加一个定时任务,用于周期性执行该任务。例如,心跳消息发送等。定时任务队列 scheduledTaskQueue 采用优先队列 PriorityQueue 实现。
- 尾部队列:tailTasks 相比于普通任务队列优先级较低,在每次执行完 taskQueue 中任务后会去获取尾部队列中任务执行。尾部任务并不常用,主要用于做一些收尾工作,例如统计事件循环的执行时间、监控信息上报等。
下面结合任务处理 runAllTasks 的源码结构,分析下 NioEventLoop 处理任务的逻辑,源码实现如下:
protected boolean runAllTasks(long timeoutNanos) {// 1. 合并定时任务到普通任务队列fetchFromScheduledTaskQueue();// 2. 从普通任务队列中取出任务Runnable task = pollTask();if (task == null) {afterRunningAllTasks();return false;}// 3. 计算任务处理的超时时间final long deadline = ScheduledFutureTask.nanoTime() + timeoutNanos;long runTasks = 0;long lastExecutionTime;for (;;) {// 4. 安全执行任务safeExecute(task);runTasks ++;// 5. 每执行 64 个任务检查一下是否超时if ((runTasks & 0x3F) == 0) {lastExecutionTime = ScheduledFutureTask.nanoTime();if (lastExecutionTime >= deadline) {break;}}task = pollTask();if (task == null) {lastExecutionTime = ScheduledFutureTask.nanoTime();break;}}// 6. 收尾工作afterRunningAllTasks();this.lastExecutionTime = lastExecutionTime;return true;
}
可以分为 6 个步骤。
- fetchFromScheduledTaskQueue 函数:将定时任务从 2. scheduledTaskQueue 中取出,聚合放入普通任务队列 taskQueue 中,只有定时任务的截止时间小于当前时间才可以被合并。
- 从普通任务队列 taskQueue 中取出任务。
- 计算任务执行的最大超时时间。
- safeExecute 函数:安全执行任务,实际直接调用的 Runnable 的 run() 方法。
- 每执行 64 个任务进行超时时间的检查,如果执行时间大于最大超时时间,则立即停止执行任务,避免影响下一轮的 I/O 事件的处理。
- 最后获取尾部队列中的任务执行。
EventLoop 最佳实践
在日常开发中用好 EventLoop 至关重要,这里结合实际工作中的经验给出一些 EventLoop 的最佳实践方案。
- 网络连接建立过程中三次握手、安全认证的过程会消耗不少时间。这里建议采用 Boss 和 Worker 两个 EventLoopGroup,有助于分担 Reactor 线程的压力。
- 由于 Reactor 线程模式适合处理耗时短的任务场景,对于耗时较长的 ChannelHandler 可以考虑维护一个业务线程池,将编解码后的数据封装成 Task 进行异步处理,避免 ChannelHandler 阻塞而造成 EventLoop 不可用。
- 如果业务逻辑执行时间较短,建议直接在 ChannelHandler 中执行。例如编解码操作,这样可以避免过度设计而造成架构的复杂性。
- 不宜设计过多的ChannelHandler。对于系统性能和可维护性都会存在问题,在设计业务架构的时候,需要明确业务分层和 Netty 分层之间的界限。不要一味地将业务逻辑都添加到 ChannelHandler 中。
总结
我们对 Netty EventLoop 的功能用处做一个简单的归纳总结。
- MainReactor 线程:处理客户端请求接入。
- SubReactor 线程:数据读取、I/O 事件的分发与执行。
- 任务处理线程:用于执行普通任务或者定时任务,如空闲连接检测、心跳上报等。
EventLoop 的设计思想被运用于较多的高性能框架中,如 Redis、Nginx、Node.js 等,它的设计原理是否对你有所启发呢?在后续源码篇的章节中我们将进一步介绍 EventLoop 的源码实现,吃透 EventLoop 这个死循环,可以说你就是一个 Netty 专家了。
05 服务编排层:Pipeline 如何协调各类 Handler ?
EventLoop 可以说是 Netty 的调度中心,负责监听多种事件类型:I/O 事件、信号事件、定时事件等,然而实际的业务处理逻辑则是由 ChannelPipeline 中所定义的 ChannelHandler 完成的,ChannelPipeline 和 ChannelHandler 也是我们在平时应用开发的过程中打交道最多的组件。Netty 服务编排层的核心组件 ChannelPipeline 和 ChannelHandler 为用户提供了 I/O 事件的全部控制权。今天这节课我们便一起深入学习 Netty 是如何利用这两个组件,将数据玩转起来。
在学习这节课之前,我先抛出几个问题。
- ChannelPipeline 与 ChannelHandler 的关系是什么?它们之间是如何协同工作的?
- ChannelHandler 的类型有哪些?有什么区别?
- Netty 中 I/O 事件是如何传播的?
ChannelPipeline概述
Pipeline 的字面意思是管道、流水线。它在 Netty 中起到的作用,和一个工厂的流水线类似。原始的网络字节流经过 Pipeline ,被一步步加工包装,最后得到加工后的成品。经过前面课程核心组件的初步学习,我们已经对 ChannelPipeline 有了初步的印象:它是 Netty 的核心处理链,用以实现网络事件的动态编排和有序传播。
今天我们将从以下几个方面一起探讨 ChannelPipeline 的实现原理:
- ChannelPipeline 内部结构;
- ChannelHandler 接口设计;
- ChannelPipeline 事件传播机制;
- ChannelPipeline 异常传播机制。
ChannelPipeline 内部结构
ChannelPipeline 作为 Netty 的核心编排组件,负责调度各种类型的 ChannelHandler,实际数据的加工处理操作则是由 ChannelHandler 完成的。
ChannelPipeline可以看作是ChannelHandler的容器载体,它是由一组C喊呢实例组成的,内部通过双向链表将不同的ChannelHandler链路在一起,如下图所示。当有I/O读写事件触发时,ChannelPipeline会依次调用ChannelHandler列表对Channel的数据进行拦截和处理。
由上图可知,每个Channel会绑定一个ChannelPipeline,每一个ChannelPipeline都包含多个ChannelHandlerContext,所有ChannelHandlerContext之间组成了双向链表。又因为每个ChannelHandler都对应一个ChannelHandlerContext,所以实际上ChannelPipeline维护的是它与ChannelHandlerContext的关系。那么你可能会有疑问,为什么这里会多一层 ChannelHandlerContext 的封装呢?
其实这是一种比较常见的编程思想。ChannelHandlerContext用于保存ChannelHandler上下文;ChannelHandlerContext则包含了ChannelHandler生命周期的所有事件,如context、bind、read、flush、write、close等。可以试想以下,如果没有ChannelHandlerContext的这层封装,那么我们在做ChannelHandler之间传递的时候,前置后置的通用逻辑就要在每个ChannelHandler里都实现一份。这样虽然能解决问题,但是代码结构的耦合,会非常不优雅。
根据网络数据的流向,ChannelPipeline分为入站ChannelInboundHandler和出站ChannelOutboundHandler 两种处理器。在客户端与服务端通信的过程中,数据从客户端发向服务端的过程叫出站,反之称为入站。数据先由一系列 InboundHandler 处理后入站,然后再由相反方向的 OutboundHandler 处理完成后出站,如下图所示。我们经常使用的解码器 Decoder 就是入站操作,编码器 Encoder 就是出站操作。服务端接收到客户端数据需要先经过 Decoder 入站处理后,再通过 Encoder 出站通知客户端。
接下来我们详细分析下 ChannelPipeline 双向链表的构造,ChannelPipeline的双向链表分别维护了HeadContext和TailContext的头尾节点。我们自定义的 ChannelHandler 会插入到 Head 和 Tail 之间,这两个节点在 Netty 中已经默认实现了,它们在 ChannelPipeline 中起到了至关重要的作用。首先我们看下HeadContext和TailContext的继承关系,如下图所示。
HeadContext 既是 Inbound 处理器,也是 Outbound 处理器。它分别实现了 ChannelInboundHandler 和 ChannelOutboundHandler。网络数据写入操作的入口就是由 HeadContext 节点完成的。HeadContext作为Pipeline的头结点负责读取数据并开始传递InBound事件,当数据处理完成后,数据会反方向经过 Outbound 处理器,最终传递到HeadConext,所以HeadContext又是处理Outbound事件的最后一站。此外 HeadContext 在传递事件之前,还会执行一些前置操作。
TailContext 只实现了 ChannelInboundHandler 接口。它会在 ChannelInboundHandler 调用链路的最后一步执行,主要用于终止 Inbound 事件传播,例如释放 Message 数据资源等。TailContext 节点作为 OutBound 事件传播的第一站,仅仅是将 OutBound 事件传递给上一个节点。
从整个 ChannelPipeline 调用链路来看,如果由 Channel 直接触发事件传播,那么调用链路将贯穿整个 ChannelPipeline。然而也可以在其中某一个 ChannelHandlerContext 触发同样的方法,这样只会从当前的 ChannelHandler 开始执行事件传播,该过程不会从头贯穿到尾,在一定场景下,可以提高程序性能。
ChannelHandler 接口设计
在学习ChannelPipeline事件传播机制之前,我们需要了解I/O事件的生命周期。整个 ChannelHandler 是围绕 I/O 事件的生命周期所设计的,例如建立连接、读数据、写数据、连接销毁等。ChannelHandler 有两个重要的子接口:ChannelInboundHandler和ChannelOutboundHandler,分为拦截入站和出站的各种 I/O 事件。
1. ChannelInboundHandler 的事件回调方法与触发时机。
事件回调方法 | 触发时机 |
---|---|
channelRegistered | Channel 被注册到 EventLoop |
channelUnregistered | Channel 从 EventLoop 中取消注册 |
channelActive | Channel 处于就绪状态,可以被读写 |
channelInactive | Channel 处于非就绪状态Channel 可以从远端读取到数据 |
channelRead | Channel 可以从远端读取到数据 |
channelReadComplete | Channel 读取数据完成 |
userEventTriggered | 用户事件触发时 |
channelWritabilityChanged | Channel 的写状态发生变化 |
2. ChannelOutboundHandler 的事件回调方法与触发时机。
ChannelOutboundHandler 的事件回调方法非常清晰,直接通过 ChannelOutboundHandler 的接口列表可以看到每种操作所对应的回调方法,如下图所示。这里每个回调方法都是在相应操作执行之前触发,在此就不多做赘述了。此外 ChannelOutboundHandler 中绝大部分接口都包含ChannelPromise 参数,以便于在操作完成时能够及时获得通知。
事件传播机制
在上文中我们介绍了 ChannelPipeline 可分为入站 ChannelInboundHandler 和出站 ChannelOutboundHandler 两种处理器,与此对应传输的事件类型可以分为Inbound 事件和Outbound 事件。
我们通过一个代码示例,一起体验下 ChannelPipeline 的事件传播机制。
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {@Overridepublic void initChannel(SocketChannel ch) {ch.pipeline().addLast(new SampleInBoundHandler("SampleInBoundHandlerA", false)).addLast(new SampleInBoundHandler("SampleInBoundHandlerB", false)).addLast(new SampleInBoundHandler("SampleInBoundHandlerC", true));ch.pipeline().addLast(new SampleOutBoundHandler("SampleOutBoundHandlerA")).addLast(new SampleOutBoundHandler("SampleOutBoundHandlerB")).addLast(new SampleOutBoundHandler("SampleOutBoundHandlerC"));}
});public class SampleInBoundHandler extends ChannelInboundHandlerAdapter {private final String name;private final boolean flush;public SampleInBoundHandler(String name, boolean flush) {this.name = name;this.flush = flush;}@Overridepublic void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {System.out.println("InBoundHandler: " + name);if (flush) {ctx.channel().writeAndFlush(msg);} else {super.channelRead(ctx, msg);}}
}public class SampleOutBoundHandler extends ChannelOutboundHandlerAdapter {private final String name;public SampleOutBoundHandler(String name) {this.name = name;}@Overridepublic void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {System.out.println("OutBoundHandler: " + name);super.write(ctx, msg, promise);}
}
通过Pipeline的addLast方法分别添加了三个 InboundHandler 和 OutboundHandler,添加顺序都是 A -> B -> C,下图可以表示初始化后 ChannelPipeline 的内部结构。
当客户端向服务端发送请求时,会触发 SampleInBoundHandler 调用链的 channelRead 事件。经过 SampleInBoundHandler 调用链处理完成后,在 SampleInBoundHandlerC 中会调用 writeAndFlush 方法向客户端写回数据,此时会触发 SampleOutBoundHandler 调用链的 write 事件。最后我们看下代码示例的控制台输出:
由此可见,Inbound 事件和 Outbound 事件的传播方向是不一样的。Inbound 事件的传播方向为 Head -> Tail,而 Outbound 事件传播方向是 Tail -> Head,两者恰恰相反。在 Netty 应用编程中一定要理清楚事件传播的顺序。推荐你在系统设计时模拟客户端和服务端的场景画出 ChannelPipeline 的内部结构图,以避免搞混调用关系。
异常传播机制
ChannelPipeline事件传播的实现采用了经典的责任链模式,调用链路环环相扣。那么如果有一个节点处理逻辑异常会出现什么现象呢?我们通过修改 SampleInBoundHandler 的实现来模拟业务逻辑异常:
public class SimpleInBoundHandler extends ChannelInboundHandlerAdapter {private final String name;private final boolean flush;public SampleInBoundHandler(String name, boolean flush) {this.name = name;this.flush = flush;}@Overridepublic void channelRead(ChannelHandlerContext ctx, Object msg) {System.out.println("InBoundHandler: " + name);if (flush) {ctx.channel().writeAndFlush(msg);} else {throw new RuntimeException("InBoundHandler: " + name);}}@Overridepublic void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {System.out.println("InBoundHandlerException: " + name);ctx.fireExceptionCaught(cause);}
}
在 channelRead 事件处理中,第一个 A 节点就会抛出 RuntimeException。同时我们重写了 ChannelInboundHandlerAdapter 中的 exceptionCaught 方法,只是在开头加上了控制台输出,方便观察异常传播的行为。下面看一下代码运行的控制台输出结果:
由输出结果可以看出 ctx.fireExceptionCaugh 会将异常按顺序从 Head 节点传播到 Tail 节点。如果用户没有对异常进行拦截处理,最后将由 Tail 节点统一处理,在 TailContext 源码中可以找到具体实现:
protected void onUnhandledInboundException(Throwable cause) {try {logger.warn("An exceptionCaught() event was fired, and it reached at the tail of the pipeline. " +"It usually means the last handler in the pipeline did not handle the exception.",cause);} finally {ReferenceCountUtil.release(cause);}
}
虽然 Netty 中 TailContext 提供了兜底的异常处理逻辑,但是在很多场景下,并不能满足我们的需求。假如你需要拦截指定的异常类型,并做出相应的异常处理,应该如何实现呢?我们接着往下看。
异常处理的最佳实践
在 Netty 应用开发的过程中,良好的异常处理机制会让排查问题的过程事半功倍。所以推荐用户对异常进行统一拦截,然后根据实际业务场景实现更加完善的异常处理机制。通过异常传播机制的学习,我们应该可以想到最好的方法是在 ChannelPipeline 自定义处理器的末端添加统一的异常处理器,此时 ChannelPipeline 的内部结构如下图所示。
用户自定义的异常处理器代码示例如下:
public class ExceptionHandler extends ChannelDuplexHandler {@Overridepublic void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {if (cause instanceof RuntimeException) {System.out.println("Handle Business Exception Success.");}}
}
加入统一的异常处理器后,可以看到异常已经被优雅地拦截并处理掉了。这也是 Netty 推荐的最佳异常处理实践。
总结
我们深入分析了 Pipeline 的设计原理与事件传播机制。那么课程最初我提出的几个问题你是否已经都找到答案了?我来做个简单的总结:
ChannelPipeline 是双向链表结构,包含 ChannelInboundHandler 和 ChannelOutboundHandler 两种处理器。
ChannelHandlerContext 是对 ChannelHandler 的封装,每个 ChannelHandler 都对应一个 ChannelHandlerContext,实际上 ChannelPipeline 维护的是与 ChannelHandlerContext 的关系。
Inbound 事件和 Outbound 事件的传播方向相反,Inbound 事件的传播方向为 Head -> Tail,而 Outbound 事件传播方向是 Tail -> Head。
异常事件的处理顺序与 ChannelHandler 的添加顺序相同,会依次向后传播,与 Inbound 事件和 Outbound 事件无关。
ChannelPipeline 精妙的设计思想值得我们学以致用,建议有兴趣的同学可以深入学习下这个组件的核心源码。在未来源码篇的课程中我们将会继续深入了解 ChannelPipeline 这个组件。