近段在看 Kafka 的网络模型时,遇到了很多 Java NIO 的内容,在学习 Java NIO 的过程中,发现需要把 UNIX 的这几种网络 IO 模型以及 Linux 的 IO 多路复用理解清楚,才能更好地理解 Java NIO,本文就是在学习 UNIX 的五种网络 IO 模型以及 Linux IO 多路复用模型后,做的一篇总结。

本文主要探讨的问题有以下两个:

  1. Unix 中的五种网络 IO 模型;
  2. Linux 中 IO 多路复用的实现。

基本概念

在介绍网络模型之前,先简单介绍一些基本概念。

文件描述符 fd

文件描述符(file descriptor,简称 fd)在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

在 Linux 中,内核将所有的外部设备都当做一个文件来进行操作,而对一个文件的读写操作会调用内核提供的系统命令,返回一个 fd,对一个 socket 的读写也会有相应的描述符,称为 socketfd(socket 描述符),实际上描述符就是一个数字,它指向内核中的一个结构体(文件路径、数据区等一些属性)。

用户空间与内核空间、内核态与用户态

这个是经常提到的概念,具体含义可以参考这篇文章用户空间与内核空间,进程上下文与中断上下文【总结】,大概内容如下:

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操心系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核,保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对 linux 操作系统而言(以32位操作系统为例)

  • 将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为内核空间;
  • 将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为用户空间。

每个进程可以通过系统调用进入内核,因此,Linux 内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有 4G 字节的虚拟空间。

  • 当一个任务(进程)执行系统调用而陷入内核代码中执行时,称进程处于内核运行态(内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈,每个进程都有自己的内核栈;
  • 当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。此时处理器在特权级最低的(3级)用户代码中运行。当正在执行用户程序而突然被中断程序中断时,此时用户程序也可以象征性地称为处于进程的内核态。因为中断处理程序将使用当前进程的内核栈。

上下文切换

当一个进程在执行时,CPU 的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。

当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的上下文,以便在再次执行该进程时,能够必得到切换时的状态执行下去。在 Linux 中,当前进程上下文均保存在进程的任务数据结构中。在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中继服务结束时能恢复被中断进程的执行。

UNIX 的网络 IO 模型

根据 UNIX 网络编程对 IO 模型的分类,UNIX 提供了以下 5 种 IO 模型。

阻塞 IO 模型

最常用的 IO 模型就是阻塞 IO 模型,在缺省条件下,所有文件操作都是阻塞的,以 socket 读为例来介绍一下此模型,如下图所示。

阻塞 IO 模型

在用户空间调用 recvfrom,系统调用直到数据包达到且被复制到应用进程的缓冲区中或中间发生异常返回,在这个期间进程会一直等待。进程从调用 recvfrom 开始到它返回的整段时间内都是被阻塞的,因此,被称为阻塞 IO 模型。

非阻塞 IO 模型

recvfrom 从应用到内核的时,如果该缓冲区没有数据,就会直接返回 EWOULDBLOCK 错误,一般都对非阻塞 IO 模型进行轮询检查这个状态,看看内核是不是有数据到来,流程如下图所示。

非阻塞 IO 模型

也就是说非阻塞的 recvform 系统调用调用之后,进程并没有被阻塞,内核马上返回给进程。

  • 如果数据还没准备好,此时会返回一个 error。进程在返回之后,可以干点别的事情,然后再发起 recvform 系统调用。重复上面的过程,循环往复的进行 recvform 系统调用,这个过程通常被称之为轮询

轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。

在 Linux 下,可以通过设置 socket 使其变为 non-blocking。

IO 多路复用模型

Linux 提供 select、poll、epoll,进程通过讲一个或者多个 fd 传递给 select、poll、epoll 系统调用,阻塞在 select 操作(这个是内核级别的调用)上,这样的话,可以同时监听多个 fd 是否处于就绪状态。其中,

  • select/poll 是顺序扫描 fd 是否就绪,而且支持的 fd 数量有限;
  • epoll 是基于事件驱动方式代替顺序扫描性能更高。

这个后面详细讲述,具体流程如下图所示。

IO 多路复用模型

多路复用的特点是通过一种机制一个进程能同时等待 IO 文件描述符,内核监视这些文件描述符(套接字描述符),其中的任意一个进入读就绪状态,select, poll,epoll 函数就可以返回,它最大的优势就是可以同时处理多个连接。

信号驱动 IO 模型

首先需要开启 socket 信号驱动 IO 功能,并通过系统调用 sigaction 执行一个信号处理函数(非阻塞,立即返回)。当数据就绪时,会为该进程生成一个 SIGIO 信号,通过信号回调通知应用程序调用 recvfrom 来读取数据,并通知主循环喊出处理数据,流程如下图所示。

信号驱动 IO 模型

异步 IO 模型

告知内核启动某个事件,并让内核在整个操作完成后(包括将数据从内核复制到用户自己的缓冲区)通过我们,流程如下图所示。

异步 IO 模型

与信号驱动模式的主要区别是:

  • 信号驱动 IO 由内核通知我们何时可以开始一个 IO 操作;
  • 异步 IO 操作由内核通知我们 IO 何时完成。

内核是通过向应用程序发送 signal 或执行一个基于线程的回调函数来完成这次 IO 处理过程,告诉用户 read 操作已经完成,在 Linux 中,通知的方式是信号:

  1. 当进程正处于用户态时,应用需要立马进行处理,一般情况下,是先将事件登记一下,放进一个队列中;
  2. 当进程正处于内核态时,比如正在以同步阻塞模式读磁盘,那么只能先把这个通知挂起来,等内核态的事情完成之后,再触发信号通知;
  3. 如果这个进程现在被挂起来了,比如 sleep,那就把这个进程唤醒,等 CPU 空闲时,就会调度这个进程,触发信号通知。

几种 IO 模型比较

几种模型的比较

Linux 的 IO 多路复用模型

IO 多路复用通过把多个 IO 阻塞复用到同一个 select 的阻塞上,从而使得系统在单线程的情况下,可以同时处理多个 client 请求,与传统的多线程/多进程模型相比,IO 多路复用的最大优势是系统开销小,系统不需要创建新的额外的进程或线程,也不需要维护这些进程和线程的运行,节省了系统资源,IO 多路复用的主要场景如下:

  1. Server 需要同时处理多个处于监听状态或者连接状态的 socket;
  2. Server 需要同时处理多种网络协议的 socket。

IO 多路复用实际上就是通过一种机制,一个进程可以监视多个描 fd,一旦某个 fd 就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作,目前支持 IO 多路复用的系统有 select、pselect、poll、epoll,但它们本质上都是同步 IO。

在 Linux 网络编程中,最初是选用 select 做轮询和网络事件通知,然而 select 的一些固有缺陷导致了它的应用受到了很大的限制,最终 Linux 选择 epoll。

select

select 函数监视的 fd 分3类,分别是 writefdsreadfds、和 exceptfds。调用后select 函数会阻塞,直到有 fd 就绪(有数据 可读、可写、或者有 except),或者超时(timeout 指定等待时间,如果立即返回设为 null 即可),函数返回。当select函数返回后,可以通过遍历 fdset,来找到就绪的 fd。

select 目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select 的一个最大的缺陷就是单个进程对打开的 fd 是有一定限制的,它由 FD_SETSIZE 限制,默认值是1024,如果修改的话,就需要重新编译内核,不过这会带来网络效率的下降。

select 和 poll 另一个缺陷就是随着 fd 数目的增加,可能只有很少一部分 socket 是活跃的,但是 select/poll 每次调用时都会线性扫描全部的集合,导致效率呈现线性的下降。

poll

poll 本质上和 select 没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个 fd 对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有 fd 后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历 fd。这个过程经历了多次无谓的遍历。

它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样以下两个缺点:

  1. 大量的 fd 的数组被整体复制于用户态和内核地址空间之间;
  2. poll 还有一个特点是【水平触发】,如果报告了 fd 后,没有被处理,那么下次 poll 时会再次报告该 fd;
  3. fd 增加时,线性扫描导致性能下降。

epoll

epoll 支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些 fd 变为就绪态,并且只会通知一次。还有一个特点是,epoll 使用【事件】的就绪通知方式,通过 epoll_ctl 注册 fd,一旦该 fd 就绪,内核就会采用类似 callback 的回调机制来激活该 fd,epoll_wait 便可以收到通知。

epoll的优点:

  1. 没有最大并发连接的限制,它支持的 fd 上限受操作系统最大文件句柄数;
  2. 效率提升,不是轮询的方式,不会随着 fd 数目的增加效率下降。epoll 只会对【活跃】的 socket 进行操作,这是因为在内核实现中 epoll 是根据每个 fd 上面的 callback 函数实现的,只有【活跃】的 socket 才会主动的去调用 callback 函数,其他 idle 状态的 socket 则不会。epoll 的性能不会受 fd 总数的限制。
  3. select/poll 都需要内核把 fd 消息通知给用户空间,而 epoll 是通过内核和用户空间 mmap 同一块内存实现。

epoll 对 fd 的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT 模式是默认模式,LT 模式与 ET 模式的区别如下:

  • LT 模式:当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件,下次调用 epoll_wait 时,会再次响应应用程序并通知此事件;
  • ET 模式:当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件,如果不处理,下次调用 epoll_wait 时,不会再次响应应用程序并通知此事件。

三种模型的区别

类别 select poll epoll
支持的最大连接数 FD_SETSIZE 限制 基于链表存储,没有限制 受系统最大句柄数限制
fd 剧增的影响 线性扫描 fd 导致性能很低 同 select 基于 fd 上 callback 实现,没有性能下降的问题
消息传递机制 内核需要将消息传递到用户空间,需要内核拷贝 同 select epoll 通过内核与用户空间共享内存来实现

介绍完 IO 多路复用之后,后续我们看一下 Java 网络编程中的 NIO 模型及其背后的实现机制。


参考