I/O 模型

我们知道,I/O 主要指的是磁盘或者网络 I/O(Socket I/O),平常我们讲的 I/O 模型其实说的是 UNIX/Linux 环境下的网络 I/O 模型,这方面讲解比较详细的是 Stevens 的《UNIX Network Programming, Volume 1: The Sockets Networking API, Third Edition》。但是在 POSIX 的标准中,其实只有同步 I/O 和异步 I/O 两种 I/O 模型,它俩的区别就是在整个 I/O 操作完成之前,是否会导致请求进程阻塞。

概念说明

在学习 I/O 模型之前,需要先了解一些 Linux 下的基本概念,如用户空间与内核空间、内核态与用户态、进程与线程的上下文切换、文件描述符、缓存 I/O 等。

用户空间与内核空间

现代的操作系统一般都使用虚拟内存,要使用虚拟内存就要使用虚拟地址,进程使用的虚拟内存地址会由操作系统与相关硬件协作,转换成真正的物理内存地址。

对于 32 位的操作系统而言,它的寻址空间(虚拟内存空间)为 4GB。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,同时也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核,保证内核的安全,操作系统将虚拟内存空间划分为了两部分。虚拟内存地址较高的 1GB(从 0xC0000000 到 0xFFFFFFFF)为内核空间,给操作系统内核使用;地址较低的 3GB(0x00000000 到 0xBFFFFFFF)为用户空间,给各个进程使用。

用户态与内核态

用户态与内核态是操作系统的两种运行级别。Intel x86 架构提供了 Ring0 到 Ring 3 共四种运行级别,Ring0 权限最高,Ring3 权限最低。Linux 使用 Ring0 作为内核态,Ring3 作为用户态。处于用户态的进程只能访问用户空间,而处于内核态的进程可以访问内核空间和用户空间。当一个进程需要使用操作系统提供的服务时,需要主动调用操作系统提供的系统调用接口,通过中断的方式将进程的运行由用户态切换到内核态。

用户栈与内核栈

内核在创建进程时,除了创建 task_struct 结构,还会为进程创建相应的堆栈。每个进程都会有两个栈,一个用户栈,存在于用户空间;一个内核栈,存在于内核空间。当进程运行在用户态时,CPU 的堆栈指针寄存器存放的是用户栈的地址,程序使用的是用户栈和用户空间;当进程运行在内核态时,CPU 的堆栈指针寄存器存放的是内核栈的地址,使用的是内核栈和内核空间。

进程上下文切换

为了控制进程的执行,内核有能力将一个正在运行的进程挂起并恢复以前某个被挂起的进程的执行。进程上下文的切换需要执行一些额外的操作,包括:

  1. 保存将要被挂起的进程的处理器上下文,包括程序计数器和其他寄存器。
  2. 更新 PCB 信息。
  3. 把进程的 PCB 移入相应的队列,如就绪、在某事件阻塞等队列。
  4. 选择另一个进程执行,并更新其 PCB。
  5. 更新内存管理的数据结构。
  6. 恢复将要执行的进程的处理器上下文。

总之,这是一个很耗资源的过程。

线程上下文切换

进程的切换需要切换页目录以使用新的地址空间,同时还需要切换内核栈、保存和切换硬件上下文。而在同一个进程下,线程的切换不需要切换页目录(因为在同一个进程下,各个线程共享进程的资源,包括地址空间),只需要保存和切换程序计数器、一些寄存器的内容和栈。

由于现代的操作系统大多使用线程作为任务的实际执行单位,所以不同进程下的线程切换其实就是进程切换。

文件描述符

文件描述符(File descriptor)的概念往往出现在 UNIX/Linux 环境中,它是一个用于描述指向文件的引用的抽象化概念。

文件描述符在形式上是一个非负整数,实际上,它是一个索引值,指向内核为每个进程所维护的该进程打开的文件的记录表。当程序打开或新建一个文件时,内核向进程返回一个文件描述符。

缓存 I/O

大多数的文件系统默认的 I/O 操作都是缓存 I/O。在 Linux 中,以 write 为例,数据会先被拷贝到进程缓存区中,再拷贝到操作系统内核的缓存区中,最后才写入到存储设备中。

有时为了保证数据的安全性,避免断电等极端情况下导致写入缓冲区的数据丢失,操作系统提供了 fsyncfdatasync 来强制将缓冲区中的数据立即写入到存储设备。

五种 I/O 模型

通常来说,I/O 操作包括对磁盘的读写、对 Socket 的读写以及对外设的读写。

当用户线程发起一个 I/O 请求(此处以读请求为例)时,内核会先查看要读取的数据是否就绪,即数据是否已经从目标设备拷贝到内核缓冲区中。对于阻塞 I/O 来说,如果数据没有就绪,则会一直在那等待,直到数据准备完毕;对于非阻塞 I/O 来说,如果数据没有就绪,则会返回一个标志信息来告知 CPU 数据还没就绪。当数据准备完毕时,CPU 再将数据从内核缓冲区拷贝到用户线程,这样就完成了一次完整的 I/O 读操作。可以看到一次完整的 I/O 读操作包含两个阶段:

  1. 检查数据是否已经拷贝到内核缓冲区中。
  2. 将数据从内核缓冲区拷贝到用户空间。

阻塞 I/O(blocking I/O)

blocking io

recvfrom 函数是一个系统调用,用来接收指定的 Socket 传来的数据。当用户进程调用了这个函数时,就陷入了内核代码中,此时处理器处于特权级中,可以使用内核的内存空间,准备数据的阶段也就开始了(对于网络 I/O 来说,很多时候数据在一开始还没有到达。比如,由于还没有收到一个完整的 UDP 包,这个时候内核就需要等待足够的数据到来)。在用户进程这边,整个进程就会被阻塞,当数据准备就绪时,第二个阶段开始进行,CPU 会将数据从内核缓冲区拷贝到用户内存空间,这个过程也会一直阻塞,直到数据拷贝结束,recvfrom 函数返回,用户进程才会解除阻塞状态,继续向下执行。

阻塞 I/O 的特点就是在 IO 操作的两个阶段都发生了阻塞。

非阻塞 I/O(non-blocking I/O)

non-blocking io

recvfrom 函数有一个参数,可以设置以非阻塞的方式检查内核数据是否准备就绪。当好数据还没就绪时,recvfrom 函数直接返回一个错误值 EWOULDBLOCK;当内核中的数据准备就绪并且用户进程再次调用 recfrom 时,CPU 会马上进行 I/O 读操作的第二个阶段,将内核缓冲区的数据拷贝到用户内存空间中。

非阻塞 I/O 的特点就是在等待数据就绪阶段不会阻塞,而在数据拷贝阶段才会阻塞。

I/O 多路复用(I/O multiplexing)

io multiplexing

在用户进程中调用 select 来监听多个 Socket 对象,该函数会阻塞当前进程,直到当某个 Socket 中有数据准备就绪,select 函数就会返回,此时用户进程再调用 recvfrom 将内核缓冲区中的数据拷贝到用户内存空间中。

非阻塞 I/O 需要在用户进程中不断主动轮询,而轮询会消耗大量的 CPU 时间。与其只轮询一个任务查看内核数据的准备状态,不如轮询多个任务,只要有任何一个任务的内核数据准备就绪就进行处理。UNIX/Linux 下的 selectpoll 这两个函数所做的工作与之类似,也是通过不断轮询来查看内核数据的准备情况,与非阻塞 I/O 相同的是它们都是内核级别的系统调用,并且它们都会阻塞用户进程,但是它们不会像非阻塞 I/O 那样因为用户进程轮询调用 recvfrom 而频繁从用户态切换到内核态。

I/O 多路复用在数据准备阶段和数据拷贝阶段都会阻塞用户进程,但是在数据准备阶段并不是阻塞在 recvfrom 这样的系统调用上,而是阻塞在 select、poll 这样的系统调用上。与阻塞 I/O 不同的是,I/O 多路复用可以同时监听和处理多个 Socket。所以,如果 Web 服务器处理的连接数不是很多时,使用 select 或者 poll 并不一定比使用多线程阻塞 I/O 性能更好,还有可能延迟更高。select/poll 的优势并不是单个连接能够处理得更快,而是能够处理更多的连接。

信号驱动式 I/O(signal-driven I/O)

由于很少使用,可以不了解。

异步 I/O(asynchronous I/O)

asynchronous io

当用户进程发起 I/O 读请求后,aio_read 函数会立刻返回,用户进程可以去做其他事情,内核会等待数据准备就绪后,主动将数据拷贝到用户内存空间中,然后再向用户进程发送一个 signal,通知用户进程操作完成。

在 Linux 中主要有两种异步 I/O 的底层实现,一个是 glibc 库,另一个是 Linux 的原生内核实现,并由 libaio 库封装调用接口。但是它们都存在一些问题,因此通常我们直接使用的是一些第三方的异步事件库,主流的有 libevent、libev 和 libuv。对于应用程序而言,这些库封装了跟操作系统底层的交互,异步事件库在不同的操作系统上会使用操作系统提供的最优处理机制来实现某一种事件。在 Windows 平台下,异步 I/O 的底层实现是 IOCP,与 Linux 平台相比,该技术比较成熟。

总结和延伸

按照 POSIX 的标准,阻塞 I/O、非阻塞 I/O 和 I/O 多路复用都属于同步 I/O。

select 和 poll 函数是通过不断轮询的方式来监听多个 Socket。不同的是,select 能够监听的 Socket 有数量限制,一般是 1024 个;而 poll 能够监听的 Socket 没有数量限制。还有一个 epoll 函数,它比前面两个函数的效率要高,它并不采用轮询的方式监听 Socket,而是当 Socket 有变化时通过回调的方式主动通知。

广义上讲,同步和异步关注的是消息通知机制。同步即在调用没有得到结果之前会一直等待,换句话说,就是调用者主动等待调用的结果;异步即在调用发出后就立马返回了,当结果计算完毕后会主动通知调用者(通过回调等方式)。而阻塞与非阻塞关注的是程序在等待调用结果(消息或返回值等)时的状态。

参考

Linux 源代码

Linux 在线源代码

Linux 系统调用列表

聊聊 Linux 五种 IO 模型

为什么 NIO 被称为同步非阻塞?

怎样理解阻塞非阻塞与同步异步的区别?

linux下的异步 IO(AIO)是否已成熟?