本文的部分内容来自于《Netty 权威指南(第2版)》第一、二章,有兴趣可找原书阅读。
1 Linux 网络 IO 模型
Linux 的内核将所有的外部设备都看作是一个文件来操作,对一个文件进行读写操作时,会调用内核提供的系统命令,然后返回一个 file descriptor(fd,文件描述符),而对一个 socket 读写时也会有相应的描述符,称为 socketfd(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 上,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 可以将内核空间的一段内存区域同时映射到多个用户进程,所以它也可以实现进程间的通信。
epoll 的 API 更加简单
包括创建一个 epoll 描述符、添加监听事件、阻塞等待所监听的事件发生、关闭 epoll 描述符等等。
Java NIO 的核心类库多路复用器 Selector 就是基于 epoll 的技术实现,但是要注意 Selector 并没有用事件驱动,而是不断轮询注册在其上的 Channel,这和上文中非阻塞 IO 的方式结合起来了,如果某个 Channel 上面发生读或者写事件,这个 Channel 就处于就绪状态,会被 Selector 轮询出来,然后通过 SelectionKey 就可以获取就绪 Channel 的集合,再进行后续的 IO 操作。一个 Selector 可以同时轮询多个 Channel,由于 JDK 使用了 epoll 代替传统的 select 实现,所以它并没有最大连接的限制,这意味着只需要一个线程负责 Selector 的轮询,就可接入成千上万的客户端。
1.4 信号驱动 IO
在调用 recvfrom 之前,用户进程通过调用 sigaction 先执行一个信号处理函数(此函数立即返回,用户进程可继续执行,是非阻塞的),当内核中数据准备就绪时,它就为用户进程生成一个信号,通过这个信号来回调通知用户进程可以调用 recvfrom 来读取数据了。此时对于 recvfrom 来说,它的第一阶段其实已经完成,自然不算阻塞,第二阶段仍然阻塞。
1.5 异步 IO
用户进程调用 read 时,两个阶段都不再阻塞(AIO,对应 UNIX 网络编程事件驱动 IO)。相当于用户进程告知内核启动某个操作后,便继续向下执行,不再等待,而内核会在整个操作完成后通知用户进程。异步 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 或者 AIO 的能力。
当并发访问量增大、响应时间延迟增大之后,采用 Java BIO 开发的服务端软件只能通过硬件的不断扩容来满足高并发和低时延,它极大地增加了企业的成本,并且随着集群规模的不断膨胀,系统的可维护性也面临巨大挑战。Java BIO 存在的主要问题如下:
- 没有数据缓冲区,IO 性能存在问题;
- 没有 Channel 概念,只有输入和输出流;
- 通信线程阻塞时间长;
- 支持的字符集有限。
2.2 Java NIO
在 IO 编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者 IO 多路复用技术进行处理。IO 多路复用技术通过把多个 IO 的阻塞复用到同一个 select 的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程模型相比,IO 多路复用的最大优势是系统开销小,系统不需要创建新的额外线程,也不需要维护这些线程的运行,节省了系统资源,IO 多路复用的主要应用场景如下:
- 服务器需要同时处理多个处于监听状态或者多个连接状态的套接字;
- 服务器需要同时处理多种网络协议的套接字。
Java NIO 的类库包括:
- 进行异步 IO 操作的缓冲区 ByteBuffer 等;
- 进行异步 IO 操作的管道 Pipe;
- 进行各种 IO 操作的 Channel,比如 ServerSocketChannel;
- 实现 NIO 操作的多路复用器 Selector;
- 文件通道 FileChannel;
- 提供了能够批量获取文件属性的 API,这些 API 具有平台无关性;
- 提供 AIO 功能,支持基于文件的异步 IO 操作和针对网络套接字的异步操作;
- 多种字符集的编码能力和解码能力。
2.3 Java AIO
hint :
阻塞会让出 CPU 时间片
“半包读”、“半包写”问题
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 程序并不是一件简单的事情,需要足够的编程经验。