所谓 I/O,就是 Input/Output,输入/输出,在操作系统中,输入输出操作其实并不简单
工作在用户态的应用程序想要读取磁盘中的具体文件内容,就需要经过 System Call(系统调用)陷入内核态
因此,在操作系统中,输入输出操作通常都会包括以下两个阶段:
以网络通信即 Socket 上的输入操作为例,对应的第一阶就是等待数据从网络中到达网卡(对于网络 I/O 来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的 TCP 包。这个时候内核就要等待足够的数据到来),然后从网卡中将数据拷贝到内核缓冲区,这样,数据就准备就完成了;第二阶段就是把数据从内核缓冲区复制到用户缓冲区。
操作系统系统如何去管理输入和输出,从而获取输入和输出的数据?这就是 I/O 模型。
linux 中有以下五种 I/O 模型:
在 Linux 中,默认情况下所有的 Socket 都是 Blocking,它符合人们最常见的思考逻辑。
上面我们介绍了输入输出操作通常都会包括两个阶段,并不是凭空想想,而是对应具体的 I/O 系统调用的,以网络通信为例,Blocking I/O 就对应阻塞的系统调用 recvfrom
第一阶段,准备数据:当用户进程通过系统调用 recvfrom 进行数据读取,操作系统就开始了 I/O 的第一个阶段-准备数据。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞住
解释下 阻塞 的概念:源自操作系统对进程/线程状态的描述概念,其定义为:操作系统把进程/线程从“运行(running)状态” 挂起为 “阻塞(blocked)状态”(又称“等待(waiting)状态”)。当进程/线程处于阻塞状态,则意味着其处于暂停运行状态,暂时不会被 CPU 调度执行
第二阶段,数据拷贝:当内核一直等到数据准备好了,它就会将数据从内核空间中拷贝到用户空间,然后系统调用 recvfrom 返回结果,用户进程才解除阻塞的状态,重新运行起来
在上述步骤中,用户进程调用 recvfrom,该系统调用直到数据准备好且被复制到用户缓冲区中才返回。
从调用 recvfrom 开始,到它返回数据的整段时间,用户进程都是被阻塞住的!这就是 Blocking I/O 的特点,可以简单记忆为 “IO 执行的两个阶段用户进程都被阻塞住了”
recvfrom 成功返回后,用户进程才开始继续处理。
参考《Unix 网络编程:第一卷》,书中是这样描述 Non-Blocking I/O 的:
"进程把一个套接字设置成非阻塞是在通知内核,当所请求的 I/O 操作非得把本进程投入睡眠才能完成时,不要把进程投入睡眠,而是返回一个错误"
意思就是,如果某个用户进程进行系统调用 recvform 尝试获取数据,但这时候数据还没准备好:
如下图所示:
非阻塞的 recvform 系统调用之后,如果数据还没准备好,应用进程不会被阻塞住,recvfrom 立即返回一个 EWOULDBLOCK 错误。用户进程在收到 recvfrom 调用的返回信息之后,可以干点别的事情,然后再发起 recvform 系统调用。
重复上面的过程,不断地进行 recvform 系统调用。这个过程通常被称之为**轮询 (polling)**。轮询检查内核数据,直到数据准备好,再拷贝数据到用户进程,进行数据处理。
需要注意的是,当 recvfrom 系统调用进行拷贝数据的时候,用户进程同样是被阻塞住的。
因此,Non-Blocking I/O 的特点就是用户进程需要不断的主动询问内核数据准备好了没有,可以简单记忆为 “IO 执行的第一阶段用户进程非阻塞,第二阶段用户进程阻塞”
由于 Non-Blocking I/O 需要不断主动轮询,轮询会消耗大量的 CPU 时间,而后台可能有多个任务在同时进行,人们就想到了循环查询多个任务的完成状态,只要有任何一个任务完成,就去处理它。这就是 I/O Multiplexing。
I/O Multiplexing 引入了新的系统调用 select/poll/epoll(也成为多路复用器),这几个系统调用也是重点,不过本文就不过多阐述了。
具体来说,I/O Multiplexing 就是将多个应用进程的 Socket 注册到一个多路复用器(select/poll/epoll)上,然后使用一个进程来监听该多路复用器,多路复用器会不断的轮询所有注册进来的 Socket,只要有一个 Socket 的数据准备好,就会返回该 Socket。再由应用进程发起真正的 IO 系统调用(也就是 recvfrom,和 Blocking I/O 一样),来完成数据读取。
简单来说,I/O Multiplexing 就是同时阻塞了多个应用进程,而且可以同时对多个 Socket 进行检测,直到有数据可读或可写时,才真正开始 I/O 操作。
比较上图和 Blocking I/O,你会发现 I/O Multiplexing 的 I/O 操作和 Blocking I/O 似乎差不多,事实上,IO 多路复用还更差一些,因为这里需要使用两个系统调用 (select 和 recvfrom),而 Blocking IO 只需要一个系统调用 (recvfrom)。
但是,IO 多路复用的优势并不是对单个连接能处理得更快,而是只需要一个进程就可以同时处理多个 I/O,能同时处理更多的连接。
Signal Blocking I/O 就是当用户进程发起 I/O 操作的时候,首先通过系统调用 sigaction 向内核注册一个信号处理函数,这个系统调用会立即返回不会阻塞用户进程;当内核数据准备好了就会发送一个 SIGIO 信号给用户进程,这样用户进程就知道内核数据准备好了,可以开始执行 I/O 系统调用了。
和 Non-Blocking I/O 一样,信号驱动 IO 的用户进程在 I/O 的第一阶段准备数据是非阻塞的,在第二阶段数据拷贝是阻塞的
不过信号驱动 IO 基于回调机制,其实现和开发应用难度大,因此在实际中并不常用。
异步 I/O,先来解释下什么是异步?
POSIX 的定义如下:
根据这个定义,我们可以做一个分类了,那就是上述四种 I/O 都是同步 I/O!因为它们无一例外都会在第二阶段阻塞住用户进程直到 I/O 操作完成。
这就是为什么你会看见有人把 “阻塞 I/O” 称之为 “同步 阻塞 I/O”,把 “非阻塞 I/O” 称之为 “同步 非阻塞 I/O” 了
而异步 IO 所谓的在整个 I/O 操作期间都不会阻塞用户进程,其通常的工作机制是:
用户进程告知内核启动某个 I/O 操作,并让内核在整个操作(包括将数据从内核复制到用户缓冲区)完成后通知用户进程。
这与 Signal Blocking I/O 的本质区别就是:
下图给出了一个异步调用的例子:
用户进程进行异步系统调用 aio_read 之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户进程可以去做别的事情。等到数据准备好了,内核直接拷贝数据给用户进程(不需要用户进程再主动发起 recvfrom 系统调用),拷贝完毕后内核才会给用户进程发送通知,告诉用户进程操作已经完成了。
所以,异步 IO 的两个阶段,用户进程都是非阻塞的,用户进程将整个 IO 操作都交由内核完成,内核完成后会发送通知。在此期间,用户进程不需要去检查 IO 操作的状态,也不需要主动的去拷贝数据。
本文理清了五种 I/O 模型,并区分了阻塞/非阻塞、同步和异步的概念
最后上张图对比下,加深印象