Netty 基本原理介绍
此前,我们学习了 Java NIO API 的使用,也学习了几种常见的 IO 模型 以及传统阻塞 I/O 服务模型和 Reactor 线程模型 。你体会到直接去使用 Java NIO API 去进行网络编程会非常麻烦,除了要对 Java NIO API 掌握的非常熟练之外,还需要掌握多线程等其他技术。不过这些问题,Netty 都可以帮我们解决。
Netty 是一个 NIO 客户端服务器框架,可以快速轻松地开发协议服务器和客户端等网络应用程序。 它极大地简化了 TCP 和 UDP 套接字服务器等网络编程的复杂度。
『快速而又简单』并不意味着最终的应用程序会受到可维护性或性能问题的影响。 Netty 经过精心设计,具有丰富的协议,如 FTP,SMTP,HTTP 以及各种二进制和基于文本的传统协议。 因此,Netty 成功地找到了一种在不妥协的情况下实现易于开发,性能,稳定性和灵活性的方法。
服务端 IO 编程
传统的 BIO 编程
网络编程的基本模型是 Client/Server 模型,也就是两个进程之间进行相互通信,其中服务端提供位置信息(绑定的 IP 地址和监听端口),客户端通过连接操作向服务端监听的地址发起连接请求,通过三次握手建立连接,如果连接建立成功,双方就可以通过网络套接字(Socket)进行通信。
在基于传统同步阻塞模型开发中,ServerSocket 负责绑定 IP 地址,启动监听端口;Socket 负责发起连接操作。连接成功之后,双方通过输入和输出流进行同步阻塞式通信。
首先,我们通过如图所示的通信模型图来熟悉下 BIO 的服务端通信模型:采用 BIO 通信模型的服务端,通常由一个独立的 Acceptor 线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,线程销毁。这就是典型的一请求一应答通信模型”
该模型最大的问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈 1:1 的正比关系,由于线程是 Java 虚拟机非常宝贵的系统资源,当线程数膨胀之后,系统的性能将急剧下降,随着并发访问量的继续增大,系统会发生线程堆栈溢出、创建新线程失败等问题,并最终导致进程宕机或者僵死,不能对外提供服务。
伪异步 I/O 编程
为了解决同步阻塞 I/O 面临的一个链路需要一个线程处理的问题,后来有人对它的线程模型进行了优化,后端通过一个线程池来处理多个客户端的请求接入,形成客户端个数 M:线程池最大线程数 N 的比例关系,其中 M 可以远远大于 N,通过线程池可以灵活的调配线程资源,设置线程的最大值,防止由于海量并发接入导致线程耗尽。
采用线程池和任务队列可以实现一种叫做伪异步的 I/O 通信框架,它的模型图如图 1-2 所示。
当有新的客户端接入的时候,将客户端的 Socket 封装成一个 Task(该任务实现 java.lang.Runnable 接口)投递到后端的线程池中进行处理,JDK 的线程池维护一个消息队列和 N 个活跃线程对消息队列中的任务进行处理。由于线程池可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。
伪异步 I/O 实际上仅仅只是对之前 I/O 线程模型的一个简单优化,它无法从根本上解决同步 I/O 导致的通信线程阻塞问题。下面我们就简单分析下如果通信对方返回应答时间过长,会引起的级联故障。
- 服务端处理缓慢,返回应答消息耗费 60s,平时只需要 10ms。
- 采用伪异步 I/O 的线程正在读取故障服务节点的响应,由于读取输入流是阻塞的,因此,它将会被同步阻塞 60s。
- 假如所有的可用线程都被故障服务器阻塞,那后续所有的 I/O 消息都将在队列中排队。
- 由于线程池采用阻塞队列实现,当队列积满之后,后续入队列的操作将被阻塞。
- 由于前端只有一个 Accptor 线程接收客户端接入,它被阻塞在线程池的同步阻塞队列之后,新的客户端请求消息将被拒绝,客户端会发生大量的连接超时。
- 由于几乎所有的连接都超时,调用者会认为系统已经崩溃,无法接收新的请求消息。
NIO 编程
与 Socket 类和 ServerSocket 类相对应,NIO 也提供了 SocketChannel 和 ServerSocketChannel 两种不同的套接字通道实现。这两种新增的通道都支持阻塞和非阻塞两种模式。阻塞模式使用非常简单,但是性能和可靠性都不好,非阻塞模式则正好相反。开发人员一般可以根据自己的需要来选择合适的模式,一般来说,低负载、低并发的应用程序可以选择同步阻塞 I/O 以降低编程复杂度,但是对于高负载、高并发的网络应用,需要使用 NIO 的非阻塞模式进行开发。
详见 Java NIO API
AIO 编程
NIO2.0 引入了新的异步通道的概念,并提供了异步文件通道和异步套接字通道的实现。异步通道提供两种方式获取获取操作结果:
- 通过 java.util.concurrent.Future 类来表示异步操作的结果;
- 在执行异步操作的时候传入一个 java.nio.channels;
- CompletionHandler 接口的实现类作为操作完成的回调。
NIO2.0 的异步套接字通道是真正的异步非阻塞 I/O,它对应 UNIX 网络编程中的事件驱动 I/O(AIO),它不需要通过多路复用器(Selector)对注册的通道进行轮询操作即可实现异步读写,从而简化了 NIO 的编程模型。
几种 IO 模型对比
BIO | 伪异步 IO | NIO | AIO | |
---|---|---|---|---|
客户端个数:IO 线程数 | 1:1 | M:N (M > N) | M:1(1 个 IO 线程处理多个客户端连接) | M:0(不需要启动额外的 I/O 线程,被动回调) |
I/O 类型(同步) | 同步 IO | 同步 IO | 同步 IO | 异步 IO |
I/O 类型 (阻塞) | 阻塞 IO | 阻塞 IO | 非阻塞 IO | 非阻塞 IO |
调试难度 | 简单 | 简单 | 负责 | 复杂 |
可靠性 | 非常差 | 非常差 | 高 | 高 |
吞吐量 | 低 | 中 | 高 | 高 |
API 使用难度 | 简单 | 简单 | 非常难 | 复杂 |
为什么要用 Netty
为什么不建议直接使用 JDK 原生 NIO 框架去进行开发?
- NIO 的类库和 API 繁杂,使用麻烦,你需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等。
- 需要具备其他的额外技能做铺垫,例如熟悉 Java 多线程编程。这是因为 NIO 编程涉及到 Reactor 模式,你必须对多线程和网路编程非常熟悉,才能编写出高质量的 NIO 程序。
- 可靠性能力补齐,工作量和难度都非常大。例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等问题,NIO 编程的特点是功能开发相对容易,但是可靠性能力补齐的工作量和难度都非常大。
- JDK NIO 的 BUG,例如臭名昭著的 epoll bug,它会导致 Selector 空轮询,最终导致 CPU 100%。官方声称在 JDK1.6 版本的 update18 修复了该问题,但是直到 JDK1.7 版本该问题仍旧存在,只不过该 BUG 发生概率降低了一些而已,它并没有被根本解决。该 BUG 以及与该 BUG 相关的问题单可以参见以下链接内容。
选择 Netty 的理由
Netty 是业界最流行的 NIO 框架之一,它的健壮性、功能、性能、可定制性和可扩展性在同类框架中都是首屈一指的,它已经得到成百上千的商用项目验证,例如:Dubbo、RocketMQ、Spark、Spring5、Elasticsearch 等,他具有如下优点:
- 异步事件通知框架,可开发出高性能的服务端和客户端;
- 封装了 JDK 底层 BIO、NIO 模型,提高简单易用的 API,开发门槛低;
- 成熟、稳定,Netty 修复了已经发现的所有 JDK NIO BUG,业务开发人员不需要再为 NIO 的 BUG 而烦恼;
- 功能强大,预置了多种编解码功能,解决了拆包粘包问题,支持多种主流协议;
- 定制能力强,可以通过 ChannelHandler 对通信框架进行灵活地扩展;
- 性能高,通过与其他业界主流的 NIO 框架对比,Netty 的综合性能最优;
- 社区活跃,版本迭代周期短,发现的 BUG 可以被及时修复,同时,更多的新功能会加入;
- 经历了大规模的商业应用考验,质量得到验证。在互联网、大数据、网络游戏、企业应用、电信软件等众多行业得到成功商用,证明了它已经完全能够满足不同行业的商业应用了。
Netty 架构
功能架构图
逻辑架构图
- Reactor 通信调度层:它由一系列辅助类完成,包括 Reactor 线程 NioEventLoop 及其父类,NioSocketChannel / NioServerSocketChannel 及其父类,ByteBuffer 以及由其衍生出来的各种 Buffer,Unsafe 以及其衍生出的各种内部类等。该层的主要职责就是监听网络的读写和连接操作,负责将网络层的数据读取到内存缓冲区中,然后触发各种网络事件,例如连接创建、连接激活、读事件、写事件等,将这些事件触发到 PipeLine 中,由 PipeLine 管理的职责链来进行后续的处理。
- 职责链 ChannelPipeline:它负责事件在职责链中的有序传播,同时负责动态地编排职责链。职责链可以选择监听和处理自己关心的事件,它可以拦截处理和向后 / 向前传播事件。不同应用的 Handler 节点的功能也不同,通常情况下,往往会开发编解码 Hanlder 用于消息的编解码,它可以将外部的协议消息转换成内部的 POJO 对象,这样上层业务则只需要关心处理业务逻辑即可,不需要感知底层的协议差异和线程模型差异,实现了架构层面的分层隔离。
- 业务逻辑编排层(Service ChannelHandler):业务逻辑编排层通常有两类:一类是纯粹的业务逻辑编排,还有一类是其他的应用层协议插件,用于特定协议相关的会话和链路管理。
Netty Reactor 模型
前面,我们介绍了三种常见的 Reactor 线程模型 ,Netty 是典型的 Reactor 模型结构,下图是 Netty 常见的主从 Reactor 模型示例图。
在创建 ServerBootstrap 类实例前,先创建两个 EventLoopGroup,一个 bossGroup,一个 workerGroup。它们实际上是两个独立的 Reactor 线程池,bossGroup 负责接收客户端的连接,workerGroup 负责处理 IO 相关的读写操作,或者执行系统 task、定时 task 等。
用于接收客户端请求的线程池职责如下:
- 接收客户端 TCP 连接,初始化 Channel 参数;
- 将链路状态变更事件通知给 ChannelPipeline;
处理 IO 操作的线程池职责如下:
- 异步读取远端数据,发送读事件到 ChannelPipeline;
- 异步发送数据到远端,调用 ChannelPipeline 的发送消息接口;
- 执行系统调用 Task;
- 执行定时任务 Task,如空闲链路检测和发送心跳消息等。
通过调整两个 EventLoopGroup 的线程数、是否共享线程池等方式,Netty 的 Reactor 线程模型可以在单线程、多线程和主从多线程间切换,用户可以根据实际情况灵活配置。