很早以前就写过关于 Netty 的使用,最近发现还有网友在看之前写的那篇 Netty 文章,个人感觉那时候写的很粗糙,怕影响同行的阅读质量,所以决定重新写一些关于 Netty 的文章,补充以前的不足。
图片来自 Pexels
Netty 能做啥
简单说就是用来处理网络编程,写一款能进行网络通信的服务端和客户端程序。如果没有 Netty,在 Java 的世界中如何处理网络编程呢?
Java 自带的工具有:java.net 包,用于处理网络通信,后面 Java 提供了 NIO 工具包用于提供非阻塞的通信。
与 Netty 同级别的第三方工具包:Mina,在设计上与 Netty 有些许不同,但是核心都是提供网络通信的能力。
传统网络通信模型
说 Netty 之前还是先讲一下传统的网络编程是什么样子。传统的 Socket 编程开发步骤很简单,只需要使用 Socket 类创建客户端和服务端即可。
但是为啥现在没有人用它了呢?主要原因是它基于同步阻塞 IO 的线程模型去做的,在当今时代完全不能满足生产需要,自然被 Out。
同步阻塞 IO 模型
同步阻塞线程模型的问题在于一个请求必须绑定一个线程去处理,并且所有的请求都是同步操作,意味着该请求未处理完之前这个连接不会被释放,如果并发高的情况必然会导致系统压力过大。
Netty 的新线程模型
基于此,Java 新增了非阻塞的 IO 操作包 NIO, NIO 的线程模型采用了 Reactor 模式,即异步非阻塞的方式,解决了之前同步阻塞带来的问题。
NIO 的全称是 NoneBlocking IO,非阻塞 IO,区别与 BIO,BIO 的全称是 Blocking IO,阻塞 IO。
那这个阻塞是什么意思呢?
- Accept 是阻塞的,只有新连接来了,Accept 才会返回,主线程才能继。
- Read 是阻塞的,只有请求消息来了,Read 才能返回,子线程才能继续处理。
- Write 是阻塞的,只有客户端把消息收了,Write 才能返回,子线程才能继续读取下一个请求。
服务器在处理响应的设计模式方面目前主要分为两种:
- 线程驱动
- 事件驱动
同步阻塞就是线程驱动的模式,最明显的例子就是 Tomcat;对于事件驱动来说,没有必要为每一个连接都创建一个线程去维护。
参考观察者模式,可以设置一个事件池,用一个单线程去循环监听当前池中是否有完成的事件,如果有则取出该事件。
简单说一下 Reactor 模式是如何解决线程等待问题的:在等待 IO 的时候,线程可以先退出不用一直等待 IO 操作。
但是如果不等待那么 IO 处理完成之后返回给谁呢?Reactor 模型采用了事件驱动机制,要求线程在退出前向 event loop 注册回调函数,这样 IO 完成之后 event loop 就可以调用回调函数完成数据返回。
在 Reactor 中有 4 个角色,所有的数据流入的处理统一称为 Channel,就像是一个水管,Reactor 模型将每一种事件拆分为一个 event,相同类型的 event 归为一类,这一类的统一处理逻辑被称为一个 handler。
那么怎么去让一个或者多个线程去监听所有的 Channel 呢?所以就有 Selector。
Selector 就像是一个管理者,你可以将多个 Channel 注册到 一个 Selector 线程上,它会使用一个阻塞方法去捕获当前 Channel 上是否有事件发生,如果有则取出事件交给对应的 Handler 去处理。
Netty 是建立在 NIO 之上的,并且 Netty 在 NIO 上面又提供了更多高层次 API 的封装。
为什么不用 JDK 提供的 NIO
JDK 已经给我们提供了 NIO 的包,也是使用了 Reactor 模型来实现的异步非阻塞模式,那我们为啥在日常开发中没有听到谁直接使用 NIO 来开发网络编程呢?
实际上大家不使用的原因是因为它太难控制。Java NIO 类库中主要提供的功能包括:
- 缓冲区 Buffer
- 通道 Channel
- 多路复用器 Selector
缓冲区 Buffer 其实就是一个对象,即所有流入或者流出的数据都在 Buffer 中存在。
新 IO 与老的面向流 IO 的区别在于老 IO 直接面向字节流进行处理,新 IO 是面向缓冲区进行处理,读写数据都是先读写到缓冲区中。
缓冲区实质上是一个字节数组,NIO 提供了对缓冲区数据读写位置维护的操作能力。
Buffer 和 Channel 的关系
Channel 通道,所有 Buffer 内的数据都会往 Channel 上流,数据通过 Channel 流向处理逻辑,通过 Channel 将处理过的数据返回给客户端。
所以 Channel 是全双工的,可以支持读写,这是它与 Stream 的区别。如果你使用 Stream,读数据只能使用 InputStream 进行操作,写数据只能使用 OutputStream 进行操作。
用现实世界中的事物比喻的话,传统 IO 犹如水管,水流只能沿着管道往下流;NIO 犹如一条双向公路,两个方向都可以行车。
另外也正是因为 Buffer 的引入我们才能随意的控制每次传输读多少数据,如果上次读取失败,那么应该从多少偏移量重新读取,这是传统 I/O 流无法比拟的。
Selector 选择器,它是 NIO 的核心,一个 Selector 就是一个线程,NIO 允许一个 Selector 管理多个 Channel,即将 Channel 注册到 Selector 上。
Selector 会去监听注册的 Channel 上是否有事件准备就绪,如果有就取出处理。
Selector 的轮询监听
关于 NIO 的代码我就不写了,是很庞大的一堆,大家百度一下就能看到。总之基于这个思想来进行网络编程肯定是面对当今流量洪峰的最佳方式。而正好 Netty 底层基于 NIO 去做的封装,已经给你屏蔽了这一大坨操作。
网络编程还有一个问题就是跨平台性,NIO 底层是依赖系统的 IO API,不同的系统可能对 IO API 的实现也是不一样的,这里如何你使用 NIO 那么就需要考虑系统兼容性问题了。
另外还有一个问题就是 NIO 有个很著名的 bug,JDK 的 NIO 底层由 epoll 实现,若 Selector 的轮询结果为空,也没有 wakeup 或新消息处理,则发生空轮询,CPU 使用率 100%。
这个 Bug 官方声明已经修复,事实上没有被 Fix, 只是出现的概率会降低一些。
Netty 也对该 Bug 进行了处理:对 Selector 的 Select 操作周期进行统计,每完成一次空的 Select 操作进行一次计数,若在某个周期内连续发生 N 次空轮询,则触发了 Epoll 死循环 Bug。
那么这个时候就重建 Selector,判断是否是其他线程发起的重建请求,若不是则将原 SocketChannel 从旧的 Selector 上去除注册,重新注册到新的 Selector 上,并将原来的 Selector 关闭。
网络编程应该注意什么
既然说要学习 Netty, 它本身是基于 NIO 的封装用于网络通信,那么在编写一段用于网络通信的代码我们应该注意一些什么呢?弄清楚这些问题,我们大概就知道 Netty 都做了什么。
谈到网络就不能避免说到 OSI 7 层模型和 TCP / IP 4 层模型:
OSI 模型和对应的网络协议
Java 网络编程主要使用的是 Socket 套接字编程,基于 4 层协议的网络编程,即基于 TCP/ UDP 协议的封装。
编写一个 Socket 通信都有哪些步骤呢?
- 创建一个 ServerSocket,监听并绑定一个端口。
- 一系列客户端来请求这个端口。
- 服务器使用 Accept,获得一个来自客户端的 Socket 连接对象。
- 启动一个新线程处理连接:①读 Socket,得到字节流;②解码协议,得到 HTTP 请求对象;③处理 HTTP 请求,得到一个结果,封装成一个 HTTPResponse 对象;④编码协议,将结果序列化字节流;④写 Socket,将字节流发给客户端。
- 继续循环步骤 3。
根据以上的数据传输流程,我们可以提出一些问题:
- 如何约定字节流长度格式,以保证每次读到的字节流都是最新的而不会和上次重复。
- 传输字节流的编解码问题。
- 一个服务端肯定会有多个客户端链接,如何管理众多的客户端链接,比如如何维护断线重连,连接超时以及关闭机制。
上面这些问题我们在接下来的 Netty 学习中都会找到答案。
Netty 核心组件
在还未入门 Netty 之前我们先了解一下 Netty 里面都有哪些类,做到有的放矢,后面学习带着这些关键信息不回乱。
①Bootstrap、ServerBootstrap
一个 Netty 应用通常由一个 Bootstrap 开始,主要作用是配置整个 Netty 程序,串联各个组件,Netty 中 Bootstrap 类是客户端程序的启动引导类,ServerBootstrap 是服务端启动引导类。
②Future、ChannelFuture
在 Netty 中所有的 IO 操作都是异步的,不会立刻知道某个事件是否完成处理。
但是可以过一会等它执行完成或者直接注册一个监听,具体的实现就是通过 Future 和 ChannelFutures,用来注册一个监听,当操作执行成功或失败时监听会自动触发注册的监听事件。
③Channel
Netty 网络通信的组件,能够用于执行网络 I/O 操作。
Channel 为用户提供:
- 当前网络连接的通道的状态(例如是否打开,是否已连接)。
- 网络连接的配置参数 (例如接收缓冲区大小)。
- 提供异步的网络 I/O 操作(如建立连接,读写,绑定端口),异步调用意味着任何 I/O 调用都将立即返回,并且不保证在调用结束时所请求的 I/O 操作已完成。
- 调用立即返回一个 ChannelFuture 实例,通过注册监听器到 ChannelFuture 上,可以 I/O 操作成功、失败或取消时回调通知调用方。
- 支持关联 I/O 操作与对应的处理程序。
不同协议、不同的阻塞类型的连接都有不同的 Channel 类型与之对应。
下面是一些常用的 Channel 类型:
- NioSocketChannel,异步的客户端 TCP Socket 连接。
- NioServerSocketChannel,异步的服务器端 TCP Socket 连接。
- NioDatagramChannel,异步的 UDP 连接。
- NioSctpChannel,异步的客户端 Sctp 连接。
- NioSctpServerChannel,异步的 Sctp 服务器端连接,这些通道涵盖了 UDP 和 TCP 网络 IO 以及文件 IO。
④Selector
Netty 基于 Selector 对象实现 I/O 多路复用,通过 Selector 一个线程可以监听多个连接的 Channel 事件。
当向一个 Selector 中注册 Channel 后,Selector 内部的机制就可以自动不断地查询(Select)这些注册的 Channel 是否有已就绪的 I/O 事件(例如可读,可写,网络连接完成等),这样程序就可以很简单地使用一个线程高效地管理多个 Channel 。
⑤NioEventLoop
NioEventLoop 中维护了一个线程和任务队列,支持异步提交执行任务,线程启动时会调用 NioEventLoop 的 run 方法,执行 I/O 任务和非 I/O 任务:
- I/O 任务,即 selectionKey 中 ready 的事件,如 accept、connect、read、write 等,由 processSelectedKeys 方法触发。
- 非 IO 任务,添加到 taskQueue 中的任务,如 register0、bind0 等任务,由 runAllTasks 方法触发。
两种任务的执行时间比由变量 ioRatio 控制,默认为 50,则表示允许非 IO 任务执行的时间与 IO 任务的执行时间相等。
⑥NioEventLoopGroup
NioEventLoopGroup,主要管理 eventLoop 的生命周期,可以理解为一个线程池,内部维护了一组线程,每个线程(NioEventLoop)负责处理多个 Channel 上的事件,而一个 Channel 只对应于一个线程。
⑦ChannelHandler
ChannelHandler 是一个接口,处理 I/O 事件或拦截 I/O 操作,并将其转发到其 ChannelPipeline(业务处理链)中的下一个处理程序。
ChannelHandler 本身并没有提供很多方法,因为这个接口有许多的方法需要实现,方便使用期间,可以继承它的子类:
- ChannelInboundHandler 用于处理入站 I/O 事件。
- ChannelOutboundHandler 用于处理出站 I/O 操作。
或者使用以下适配器类:
- ChannelInboundHandlerAdapter 用于处理入站 I/O 事件。
- ChannelOutboundHandlerAdapter 用于处理出站 I/O 操作。
- ChannelDuplexHandler 用于处理入站和出站事件。
⑧ChannelHandlerContext
保存 Channel 相关的所有上下文信息,同时关联一个 ChannelHandler 对象。
⑨ChannelPipline
保存 ChannelHandler 的 List,用于处理或拦截 Channel 的入站事件和出站操作。
它实现了一种高级形式的拦截过滤器模式,使用户可以完全控制事件的处理方式,以及 Channel 中各个的 ChannelHandler 如何相互交互。
在 Netty 中每个 Channel 都有且仅有一个 ChannelPipeline 与之对应。
关于 Netty 的简介就先说这么多,后面会带着 Socket 通信应该解决的问题和上面提到的 Netty 关键组件讲解 Netty 是如何实现高性能网络通信的。
作者:rickiyang
编辑:陶家龙
出处:https://www.cnblogs.com/rickiyang/