本文的部分内容来自于《Netty 权威指南(第2版)》第一、二章,有兴趣可找原书阅读。
1 Linux 网络 IO 模型
Linux 的内核将所有的外部设备都看作是一个文件来操作,对一个文件进行读写操作时会调用内核提供的系统命令,然后返回一个 file descriptor(fd,文件描述符),类似的,对一个 socket 读写时也会有相应的描述符,称为 socket fd(socket 描述符)。描述符就是一个数字,它指向内核中的一个结构体(包含文件路径、数据区等一些属性)。
根据 UNIX 网络编程对 I/O 模型的分类,UNIX 提供了 5 种 I/O 模型,但无论是哪一种,实际上都可以分为两个阶段(以 read 操作为例):
第一阶段:等待内核中的数据准备就绪;
第二阶段:将数据从内核拷贝到用户空间。
那什么是内核空间和用户空间?请看下图:

用户空间:也称为用户态,即上层应用程序的活动空间,应用程序的执行必须依赖于内核提供的资源。
内核空间:也称为内核态,控制计算机的硬件资源,并给上层应用程序提供运行环境。
注:两者都有自己的数据缓冲区,通常数据会先放到缓冲区。并且用户态可以通过系统调用切换到内核态,这是主动进入到内核态。在出现异常或者外围设备中断时进入到内核态,属于被动进入。
而这 5 种 I/O 模型就是根据它在上述两个阶段的不同表现来区分的:根据第一阶段用户进程(或者说用户线程)是否阻塞来区分阻塞 IO / 非阻塞 IO,根据第二阶段用户进程(或者说用户线程)是否阻塞来区分同步 IO / 异步 IO!(注意:每本书的定义可能不一样,不要过分纠结在一些技术术语的咬文嚼字上,知道是怎么一回事就行了)
假设在用户进程中有个调用叫 recvfrom,那么在不同的 IO 模型中,它的执行过程是这样的:
1.1 阻塞 IO
两个阶段都阻塞(BIO)。
换句话说,进程从调用 recvfrom 开始到它返回的整段时间内都是被阻塞的,如下图:

这是最常用的 IO 模型,缺省情况下,所有文件操作都是阻塞的。
1.2 非阻塞 IO
第一阶段不再阻塞,而是用轮询的方式检查数据是否准备好,第二阶段仍然阻塞。
调用 recvfrom 后,如果内核缓冲区中没有数据,那就直接返回一个错误,然后开始轮询,直到有数据到来。

1.3 IO 复用
在调用 recvfrom 之前,用户进程将一个或多个 fd 先传递给 select / poll 系统调用,让其阻塞在 select 上,即让多个 IO 的阻塞复用到同一个 select 阻塞,select / poll 就可以帮我们顺序、线性地扫描这些 fd 是否处于就绪状态,当发现有 fd 就绪时(也就是有数据可读了),再调用 recvfrom。此时对于 recvfrom 调用来说,它的第一阶段其实已经完成,自然不算阻塞,第二阶段仍然阻塞。
不过,因为 select / poll 是顺序扫描,而且它支持的 fd 数量有限,因此它的使用受到了很大限制。所以 Linux 找到了另一个替代方案 epoll,epoll 使用基于事件驱动的方式代替顺序扫描,因此性能更高,当有 fd 就绪时,立即回调 callback 函数。

与 select 相比,epoll 作了很多重大改进:
一个进程打开的 socket fd 数量不受限制
select 的最大缺陷就是单个进程打开的 fd 数量是有限制的,默认值是 1024,这对于那些需要支持上万个 TCP 连接的大型服务器来说显然太少了。而 epoll 没有这个限制,它仅受限于操作系统的最大文件句柄数,这个值通常跟系统的内存有关。
IO 效率不会随着 fd 数量的增加而线性下降
在 WAN 环境下,当 socket 集合很大时,由于网络延时或链路空闲,任一时刻只有少部分的 socket 是活跃的,但是 select / poll 每次都会线性扫描全部集合,导致效率会呈现线性下降。而 epoll 只对活跃的 socket 进行操作,因为 epoll 是根据每个 fd 上面的 callback 函数实现的,只有活跃的 socket 才会主动调用 callback 函数。
使用 mmap 加速内核与用户空间的消息传递
无论是 select、poll 还是 epoll,都需要内核把 fd 消息通知给用户空间,如何避免不必要的内存复制就显得非常重要,epoll 是通过内核和用户空间 mmap 同一块内存来实现的。下面简单介绍一下 mmap(知道的可跳过):
古代的皇帝有很多妃嫔,难免争风吃醋,位高的贵妃仗势欺人,一些小妃嫔无法正面回击,于是就想些别的点子,比如扎小人。扎小人的方法是:用户这边由于无法扎到正主,所以只能拿布片等物品模拟一个小人出来,在上面画上正主的经脉,写上名字,再施以某种魔法,然后用针扎这个小人的穴道,远程那位正主的相应部位就会受到同样的折磨。虽然有点神乎其技,但在 Linux 的内核开发里,mmap 就拥有这种魔法。
系统调用 mmap(Memory map),即内存映射,简而言之就是将内核空间的一段内存区域映射到用户空间。因为用户空间无法直接操作寄存器的物理地址,于是通过 mmap 进行内存映射,将物理地址映射到用户空间的虚拟地址上,映射成功后,用户通过读写自己手边的虚拟地址,就可以实现对物理地址的读取/写入,反之,内核空间对这段区域的修改也会直接反映到用户空间。
mmap 有诸多用途,比如它可以将内核空间的一段内存区域同时映射到多个用户进程,以此便可以实现进程间的通信;还有在内核与用户空间之间需要传输大量数据时,使用 mmap 后效率非常高。它是一种零拷贝技术,其 IO 模型如下图所示:
epoll 的 API 更加简单
包括创建一个 epoll 描述符、添加监听事件、阻塞等待所监听的事件发生、关闭 epoll 描述符等等。
Java NIO 的核心类库多路复用器 Selector 就是基于 epoll 的技术实现。要说明的是,因为 Selector 一开始是基于 select / poll 模型实现,后来才用 epoll 模型替换 select / poll,算是一次性能优化,所以 Selector 仍然在用某种形式的轮询来检查通道事件,但这种轮询是高效的,因此,可以说 Selector 结合了轮询和事件驱动的特性。
Selector 的工作方式是不断轮询注册在其上的 Channel,如果某个 Channel 上面发生读或者写事件,这个 Channel 就处于就绪状态,会被 Selector 轮询出来,然后通过 SelectionKey 就可以获取就绪 Channel 的集合,再进行后续的 IO 操作。一个 Selector 可以同时轮询多个 Channel,由于 JDK 使用 epoll 优化了传统的 select 实现,所以它并没有最大连接的限制,同时也不会导致性能线性下降,这意味着只需要一个线程负责 Selector 的轮询,就可接入成千上万的客户端。
1.4 信号驱动 IO
在调用 recvfrom 之前,用户进程通过调用 sigaction 先执行一个信号处理函数(此函数立即返回,用户进程可继续执行,是非阻塞的),当内核中数据准备就绪时,它就为用户进程生成一个信号,通过这个信号来回调通知用户进程可以调用 recvfrom 来读取数据了。此时对于 recvfrom 来说,它的第一阶段其实已经完成,自然不算阻塞,第二阶段仍然阻塞。

1.5 异步 IO
用户进程调用 read 时,两个阶段都不再阻塞(AIO)。相当于用户进程告知内核启动某个操作后,便继续向下执行,不再等待,而内核会在整个操作完成后通知用户进程。异步 IO 和信号驱动 IO 的主要区别是:信号驱动 IO 是内核通知我们何时可以开始一个 IO 操作,而异步 IO 是内核通知我们 IO 操作何时已经完成。

2 在 Java IO 中的应用
经过前文的介绍,可知对于操作系统而言,底层是支持异步 IO 通信的。那么 Java IO 又是如何对此提供支持的呢?
2.1 Java BIO
在 JDK 1.4 推出 Java NIO 之前,所有基于 Java 的 socket 通信都采用了同步阻塞模式(BIO),这种一请求一应答的通信模型简化了上层的应用开发,但是在性能和可靠性方面却存在着巨大的瓶颈。因此,在很长一段时间里,大型应用服务器都用 C 或者 C++ 语言开发,因为它们可以直接使用操作系统提供的异步 IO 的能力。
线程是 JVM 非常宝贵的系统资源,当并发访问量增大、响应时间延迟增大之后,由于服务端的线程个数和客户端并发访问数呈 1:1 的正比关系,线程数的膨胀会让系统性能急剧下降,采用 Java BIO 开发的服务端软件只能通过硬件的不断扩容来满足高并发和低时延,它极大地增加了企业的成本,并且随着集群规模的不断膨胀,系统的可维护性也面临巨大挑战,随时可能发生线程堆栈溢出、无法创建新线程、进程宕机等问题。Java BIO 存在的主要问题如下:
- 没有数据缓冲区,IO 性能存在问题;
- 没有 Channel 概念,只有输入和输出流;
- 通信线程阻塞时间长;
- 支持的字符集有限。
2.2 Java NIO
在 IO 编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者 IO 多路复用技术进行处理。IO 多路复用技术通过把多个 IO 的阻塞复用到同一个 select 的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程模型相比,IO 多路复用的最大优势是系统开销小,系统不需要创建新的额外线程,也不需要维护这些线程的运行,节省了系统资源,IO 多路复用的主要应用场景如下:
- 服务器需要同时处理多个处于监听状态或者连接状态的套接字;
- 服务器需要同时处理多种网络协议的套接字。
Java NIO 的类库提供了:
- 实现 NIO 操作的多路复用器 Selector,它是 Java NIO 编程的基础,前文有过介绍;
- 进行异步 IO 操作的缓冲区(比如 ByteBuffer 等),任何时候读写 NIO 中的数据,都是通过缓冲区进行;
- 进行各种 IO 操作的 Channel(比如用于网络读写的 ServerSocketChannel 和用于文件操作的 FileChannel 等),Stream 流只在一个方向上移动,而 Channel 通道是双向的(全双工),它同时支持读写操作,比流能更好地映射底层操作系统的 API;
- 进行异步 IO 操作的管道 Pipe;
- 多种字符集的编码能力和解码能力。
2.3 Java AIO
或者也可以称为 Java NIO 2.0(对应于 UNIX 网络编程中的事件驱动 IO),它的类库提供了:
- 提供 AIO 功能,支持基于文件的异步 IO 操作和针对网络套接字的异步操作;
- 提供了能够批量获取文件属性的 API,这些 API 具有平台无关性;
- 提供了标准文件系统的 SPI,供各个服务提供商扩展实现;
- 对配置和多播数据报的支持。
异步通道提供以下两种方式获取操作结果:
- 通过 java.util.concurrent.Future 类来表示异步操作的结果;
- 在执行异步操作的时候传入一个 java.nio.channels。
CompletionHandler 接口的实现类作为操作完成的回调。在实际项目中,异步的网络套接字 Channel 是被动执行对象,它不需要像 NIO 编程那样创建一个独立的 IO 线程来处理读写操作,对于 AsynchronousServerSocketChannel 和 AsynchronousSocketChannel,它们都由 JDK 底层的线程池负责回调并驱动读写操作。
2.4 三种 Java IO 对比
Java BIO:一个线程处理一个客户端连接(1:1)
Java NIO:一个线程处理多个客户端连接(1:n)
Java AIO:被动回调,不需要启动额外的线程(0:n)
2.5 选择 Netty
实际开发时,为什么不用 JDK NIO 的原生类库,而选择 Netty?
JDK NIO 有本身固有的复杂性和 Bug,开发出高质量的 NIO 程序并不是一件简单的事情。虽然用原生 JDK NIO 开发功能相对容易,但是其可靠性能力补足的工作量和难度都非常大,作为一个服务端,需要能够处理网络的闪断、客户端的重复接入、客户端的安全认证、消息的编解码、半包读写、失败缓存、网络拥塞、异常码流等情况,如果没有足够的编程经验积累,一个稳定的服务端往往需要一年甚至更长的时间。更为糟糕的是,一旦在生产环境发生问题,不但调试定位难度大,还可能带来巨大损失。
而 Netty 经历了大规模的商业应用考验,质量得到验证。社区活跃,成熟稳定,已成为最流行的 NIO 框架之一。
2.5.1 粘包和拆包
产生粘包和拆包问题的主要原因是,操作系统在发送 TCP 数据的时候,底层会有一个缓冲区,例如它的大小为 1024 个字节,如果一次请求发送的数据量比较小,未达到缓冲区的大小,则 TCP 会将多个请求合并为一个请求进行发送,如此便形成了粘包问题;如果一次请求发送的数据量比较大,超过了缓冲区的大小,则 TCP 又会将其拆分为多次请求发送,也就是将一个大的包拆分成了多个小包进行发送,这就是拆包问题。
常见的解决方案有四种:
(1)客户端在发送数据包的时候,每个包都固定长度,比如固定为 1024 个字节,如果客户端发送的数据长度不足 1024 个字节,则通过补充空格的方式补全到指定长度;
(2)客户端在每个数据包的末尾使用固定的分隔符,例如 \r\n,如果一个包被拆分了,则等待下一个包发送过来之后找到其中的 \r\n,然后与前一个包的剩余部分进行合并,这样就得到了完整的包;
(3)将消息分为头部和消息体,在头部中保存当前整个消息的长度,只有在读取到足够长度的消息之后才算是读到了一个完整的消息;
(4)通过自定义协议对粘包和拆包进行处理。