从 NIO 到 Netty


八月整个月没有写一篇文章,有很多原因:尝试入门 Docker 和 Kubernetes 失败、(公司任务)调研 Prometheus、SkyWalking 等监控工具花了不少时间,以及想写 I/O 但入门门槛好高。八月过去了,九月份要重新拾起笔了。

九月的第一周,从 NIO 开始,一路趟到 Netty。


这个月的目标,是以 Netty 为基,学习网络 I/O 和计算机网络,我本想通过一周的时间学完 Netty,结果一周过去了,刚刚明白这是个什么东西……Netty 的入门门槛比较高,首先需要有 NIO 的前导知识,其次要理解 Netty 多如牛毛的类设计。本周的目标是梳理清楚 NIO 和 Netty 都有哪些类。

先从 NIO 学起吧。

I/O(Input/Output) 即输入输出,入和出的标准指的是电脑内存,从磁盘流入内存、从网络流入内存,这些都叫做输入(Input),反之,从电脑内存流到外设,这些叫做输出(Output)。

I/O 是一项基本操作,从 JDK 1.0 开始就有了,而后在 JDK 1.4 和 JDK 1.7 又推出了新的 I/O 包。

  • JDK 1.0 的 I/O 以 InputStream、OutputStream、Writer、Reader 为首,由于它们会阻塞程序的特点,这种最初的 I/O 又被称为 BIO(Blocking IO)。
  • JDK 1.4 的 I/O 被称为 NIO,N 有双层含义,即代表 Non-blocking(非阻塞),也代表 New(新)。见名知意,这一代的 I/O 的核心特点就是非阻塞。
  • JDK 1.7 的 I/O 被称为 AIO(Asynchronous IO),Asynchronous 的意思是异步,这一代 I/O 的特点是异步(同时也是非阻塞的)。

由于各种原因(核心原因是性能表现一般),Netty 最终没有选择最新的 AIO,而是基于 NIO 来实现。因此本篇也不学习 AIO 了,来学习相对旧一点的 NIO,至于 AIO 以后有机会再学习吧。

(NIO 部分主要参考自《Java NIO:Buffer、Channel 和 Selector》,讲得很好)


NIO 有三大组件:Buffer、Channel、Selector。

Buffer

Buffer 就是缓冲区,它本质上就是一块内存。读写都是它,读是从这块 Buffer 读到 cpu 里,写是 cpu 写到这块 Buffer 中。

Buffer 的子类有很多:

  • ByteBuffer
  • MappedByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

其中最重要的是 ByteBuffer,其他 xxxBuffer 都是 ByteBuffer 的包装类。

Buffer 可以理解为数组,ByteBuffer 就是 byte[],IntBuffer 就是 int[],这样理解起来很容易。

Buffer 有四个需要理解的概念:capacity、position、limit 和 mark。

capacity

容量,也就是数字的大小,初始化后就不变了。

1
2
// 初始化一块 capacity 为 1024 的 ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(1024);

position

当前读/写的位置,初始是 0,写多少就是多少,读多少也就是多少。

比如一个长达 1024 的 Buffer 实例,写了 100 个 byte,position 就是 100。切换到读模式,position 再从 0 开始,读了 50 个 byte,position 就是 50。

limit

读/写的最大限制,写模式是就是 Buffer 的大小(capacity),读模式是实际数据的大小(比如长达 1024 的 Buffer 实例,写了 100 个 byte,切换到读模式,limit 就是 100)。

mark

mark 一下的那个 mark,就是打点做一下标记。

比如在 position 为 10 的地方 mark 了一下,mark 就是 10,往里再写了 5 个 byte,觉得不对,回退一下,就可以回到上次 mark 的位置 10。


看图简单回顾一下 capacity、position 和 limit 这三个概念。(下图来源自 javadoop 博客,画得很好,我就不重画了)

NIO-Buffer

Channel

Channel 直译是通道,在理解和使用上都类似于原始 I/O 的流,只不过 I/O 流把读写拆开,拆成 InputStream 和 OutputStream,而 Channel 同时支持读写。

Channel 和上面所说的 Buffer 打交道,读写都是对 Channel 自己而言的,读是读 Channel,把数据从 Channel 填充到 Buffer 中,写是写 Channel,把 Buffer 中的数据写入到 Channel 中。

通常用到的 Channel 有四种:

  • FileChannel:文件 Channel

  • DatagramChannel:UDP Channel(UDP:User Datagram Protocol,用户数据报协议)

  • SocketChannel:TCP Channel

  • ServerSocketChannel:监听 TCP 连接的 Channel,每建立一个连接都会创建一个 SocketChannel

Channel 最常用的方法是 read() 和 write(),这两个方法都是 Channel 的实例方法,可能有些反直觉。

1
2
3
4
// 把 channel 中的数据读到 buffer 里
channel.read(buffer);
// 把 buffer 中的数据写到 channel 里
channel.write(buffer);

Selector

Selector 选择器,又被成为多路复用器。

多路复用是学通信专业课时经常听到的概念,意思是一个信道上同时传输多路信号,比如带宽 10 G 的信道,可以容纳 5 条 2 G 的信号同时传输。多路复用的意义,是把多个低速信道整合到一个高速信道进行传输,能够更高效地利用高速信道,避免浪费。Selector 是 I/O 领域的多路复用器,单个 I/O 操作消耗小,但是占用了整个线程,如果把所有的 I/O 操作集中在一起,就避免了浪费。

Selector 的原理我乍一看还有点复杂,涉及到操作系统的内容,先放一放吧,日后再学习。

Selector 的使用逻辑是这样的:创建一个全局唯一的 Selector,然后所有的 Channel 都注册到这个 Selector 里,并指明关心 Channel 的哪一种事件。然后让 Selector 一直监听,如果监听到有 Channel 被使用了,就用 Selector 调出 Channel 再进行操作。在整个流程中,Selector 是始终处于工作的,但是 Channel 并不是,注册完就该干什么干什么去了,当需要使用时,Selector 会唤醒它。下面是简要的使用代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 创建一个全局的 Selector 实例
Selector selector = Selector.open();
// 将 channel 注册到 selector 里,并指明 selector 只监听【channel 有数据可以进行读取】这件事
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

...

// selector 获取监听到有事件的 channel
Set<SelectionKey> selectedKeys = selector.selectedKeys();
// 逐个处理有事件的 channel
for (SelectionKey key : selectedKeys) {
...
}

Selector 一共可以监听四类 Channel 的时间(可同时监听多类),分别是:

事件 作用
SelectionKey.OP_READ 通道中有数据可以进行读取 1 << 0(即 00000001)
SelectionKey.OP_WRITE 可以往通道中写入数据 1 << 2(即 00000100)
SelectionKey.OP_CONNECT 成功建立 TCP 连接 1 << 3(即 00001000)
SelectionKey.OP_ACCEPT 接受 TCP 连接 1 << 4(即 00010000)

终于要说到 Netty 了。

Netty 是一套 I/O 框架,它在 NIO 的基础之上,完成了一整套代码,从某种程度上讲可以认为是对 NIO 的再包装。由于 JDK 提供的原生 IO API 都比较难用,Netty 使用起来简单且性能很高,因此获得了广泛的好评。通常情况下,大家在讨论 Netty,都是在讨论网络 IO。

Netty 的特点是异步、非阻塞、高性能,这三个定语边学习边体会吧。

初学 Netty 会有很高的学习门槛,因为 Netty 中的类实在是太多了,写一个 Hello World 程序也需要几十行代码,并且需要有 NIO 的先导知识。Netty 是基于 NIO 实现的,并且直接使用了 NIO 中 Channel 和 Selector 的概念(实现不一样,但是概念是一样的),因此上文简单介绍了 NIO 三大组件的概念,但是没有介绍具体的 API 方法(因为没必要)。

本周目的并不是学通 Netty,而至少了解 Netty 的基本类有哪些。下面开始拆解着学习 Netty 的各个类。

Channel

概念同 NIO 的 Channel,是 I/O 操作的核心,用以 read、write、connect、bind 等。

我们只关注两个类,分别是充当服务端的 NioServerSocketChannel,以及充当客户端的 NioSocketChannel。

Netty_Channel

Netty 的 Channel 是全部重新设计的,图中两个 Channel 子类都间接实现了 Channel 接口,但是这个 Channel 接口实际上是 Netty 自定义的接口,而非 NIO 的 Channel。


在使用上,Netty 的 Channel 和 JDK 的 Channel 是一一对应的:

Netty NIO
NioServerSocketChannel ServerSocketChannel
NioSocketChannel SocketChannel

具体的实现是:Netty 各种 Channel 子类都继承自 AbstractNioChannel,这个类的其中一个实例变量就是 NIO 的 Channel 实例,换句话说,Netty 中每个 Channel 的内部,都包着一个 NIO 的 Channel 实例。

(下图来源自《Netty 源码解析(二) - Netty 中的 Channel》)

Netty-NIO-Channel

Netty 的 Channel 枝叶繁杂,每个类内部又有一大堆的方法,为了方便起见,这里只看 NioServerSocketChannel 和 NioSocketChannel 这两个的构造方法。

new NioServerSocketChannel()

  • 在本类中,设置了 config 成员变量,它是 Channel 的配置,内部保存了 JavaSocket,并做了些别的事
  • 在父类的父类 AbstractNioChannel 中
    • 设置了 NIO 的 ServerSocketChannel
    • 设置了 readInterestOp 为 SelectionKey.OP_ACCEPT,这代表接收数据时操作为 accept
    • 设置了 Channel 为非阻塞
  • 在父类的父类的父类 AbstractChannel 中,设置了 pipeline、unsafe 和其他成员变量

new NioSocketChannel()

  • 在本类中,设置了 config 成员变量,它是 Channel 的配置,内部保存了 JavaSocket,并做了些别的事
  • 在父类的父类 AbstractNioChannel 中
    • 设置了 NIO 的 ServerSocketChannel
    • 设置了 readInterestOp 为 SelectionKey.OP_READ,这代表接收数据时操作为 read
    • 设置了 Channel 为非阻塞
  • 在父类的父类的父类 AbstractChannel 中,设置了 pipeline、unsafe 和其他成员变量

能够看出,这两种 Channel 的构造方法只有一处不一样:设置接收数据时操作一个是 accept,一个是 read。

Bootstrap

AbstractBootstrap is a helper class that makes it easy to bootstrap a Channel.(Javadoc 解释)

AbstractBootstrap 是一个帮助类,可以用来轻松地创建 Channel。

我们只关注两个类,分别是帮助创建 NioSocketChannel 的 Bootstrap,以及帮助创建 NioServerSocketChannel 的 ServerBootstrap。

Netty_Bootstrap

bootstrap 是一个计算机中通用的概念,代表“引导程序”,Spring Boot 的 boot 也是相同的概念,可以认为是 bootstrap 的缩写,意思是“一种简单快速的配置方式”。知乎上能够查到这个词的出处:《Boot 一词是为什么被用作计算机并作为引导解释的?或者说他的由来?》,挺有意思的。

Netty 中 Channel 是一个巨无霸的设计,里面有特别多的内容,Bootstrap 类是 Netty 提供的工厂类,用来便捷地创建 Channel 实例。

初学 Netty 时几乎所有的工夫都花在 Bootstrap 上,我们下一周专门来学它。

Handler

处理器,处理一个 I/O 事件或拦截一个 I/O 事件。

Netty_Handler

每个 Channel 内部都有一个 pipeline(流水线),所有 I/O 事件都会经过这条 pipeline 被加工处理,pipeline 由多个 handler 构成,也就是一个由 handler 构成的双向链表。(下图依然来源于《Netty 源码解析……》)

Netty_pipeline

Netty 在整体上把 Handler 分成两类:inbound 和 outbound,并各自派生出一条路线。inbound 代表 I/O 操作往内部进行,例如 read、accept,而 outbound 代表 I/O 操作往外部进行,例如 write、connect、flush。

ChannelInboundHandler 和 ChannelOutboundHandler 这两个 handler 都是接口,定义了一些方法,而两个 xxxAdapter 是它们的实现类。

ChannelInitializer 是一个特殊的 handler,它本身是一个 handler,但它的实际作用是包装着别的 handler。也就是说,当把 ChannelInitializer 加入到 pipeline,就相当于把它包着的一堆 handler 都加入到 pipeline 中。这跟 Bootstrap 的设计有关,下一篇重点讲。

除了 inbound 和 outbound 之外,还有一种 duplex(双向),它同时支持 in 和 out,比如 LoggingHandler 就是这种类型。

EventLoop

Netty 的线程和线程池。

Netty_EventLoop

EventLoop 直译为“事件循环”,其实就是一个线程,一个复用的线程。同理 EventLoopGroup 就是管理线程的线程池。

我们学习的是 NioEventLoop,这个类的继承路线很庞大,学习起来也比较痛苦,仅一个构造方法就要辗转十个类左右,不停地调用父类的构造方法。

理解 Netty 的线程模型还是较为困难的,我们下篇或者下下篇再学习 EventLoop 的构造方法与工作流程。

ChannelFuture

Netty 中的各种异步调用,就是使用这套 Future 体系。

ChannelFuture

Netty 在 JUC 的 Future 基础上又创建了一个 Future(看上图,有两个 Future 类),并根据这个 Future 衍生出 ChannelFuture 和 Promise 两条路,这两条路最终在 DefaultChannelPromise 汇合。

Future

Netty 对 JUC 的 Future 进行了扩充,增加了一系列的方法,大致可以分成三类:

  1. isSuccess()、isCancellable() 等方法,获取异步任务的状态。
  2. sync()、await() 等方法,阻塞获取异步任务的结果,没执行完就等到执行完。
  3. addListener(…) 等 listener 相关方法,增加 listener 监听。

虽然我们还没有看实现类,但是可以先提前学习一个知识点:Netty 中使用 Future 处理异步事件是有两套方式的。

  1. 第一种方式,创建好异步任务之后,阻塞干等,什么时候等到异步执行结束带回来结果,什么时候继续往下执行。(调用 Future 实例的 sync() 或者 await() 方法,程序阻塞,一直等到异步任务完成为止,获取到完成后的结果,程序继续)
  2. 第二种方式,创建好异步任务之后,设置好 listener 监听的回调事件,当异步任务执行结束后,让异步任务自己去调用方法,主程序不掺和了。(调用 Future 实例的 addListener(…) 设置监听回调事件,异步任务一执行完毕,就去调用回调方法,全部过程都跟主程序无关)

ChannelFuture

ChannelFuture 几乎跟 Future 接口一模一样,只是在 Future 接口的基础上,增加了 channel() 方法。也就是说,ChannelFuture 实际上就是把 Future 和 I/O 操作中的 Channel 关联在一起,用于异步处理 Channel 中的事件。

简单来说,ChannelFuture 就是 Future + Channel。

Promise

Promise 概念抽象一点,它的意思是“许诺,约定”,它在 Future 的基础之上增加了 setSuccess()、setFailure() 之类的方法,可以设置 Future 异步任务的完成结果。Javadoc 这么形容这个类:

Special Future which is writable.

它是一个“可写”的 Future。

再说得清楚一点,Promise 是一个 set 类型的 Future,它可以人工设置 Future 的执行成功与失败。

Promise 在 set 任务成功或失败时,可以在 set 之后去通知所有监听的 listener,去执行回调方法,也就是我们刚才说的 Future 处理异步事件的第二种方式。


Netty 源码解析……》对 Future 的两种的处理异步事件方式的解读,比我要清晰很多,我抄一下:

……有两种编程方式,一种是用 await(),等 await() 方法返回后,得到 promise 的执行结果,然后处理它;另一种就是提供 Listener 实例,我们不太关心任务什么时候会执行完,只要它执行完了以后会去执行 listener 中的处理方法就行。


总结一下 Netty 的 Future 体系:Netty 重新定义了 Future,并延伸出两种路线,一种是 ChannelFuture(把 Future 和 Channel 绑定在一起),另一种是 Promise(可以设置 Future 的执行结果),最常使用的 DefaultChannelPromise 是两种路线的实现类。


本周就写到这里,铺垫了 NIO 和 Netty 的基础知识,下周就正式学习 Netty 的实现了。