[toc]

Linux 的设计哲学中有一条准则 万物皆文件 ,体现为:读取系统信息是读文件;获取用户输入是读文件;通过 socket 发送数据是写文件。不少操作是通过读写文件完成的。

而相对于 CPU 执行指令的速度,磁盘等介质读写数据的速度慢得多(见下表)。因此当前程序执行到 IO 指令后,并不能马上拿到 IO 读写结果。

CPU SDD 硬盘 HDD 硬盘 网络
时间单位 ns us ms ms

注:1 ms = $10^3$ us = $10^6$ ns

如何处理 CPU 指令处理时间 与 各种介质读写时间之间的巨大鸿沟,是让 CPU 等一等还是让 CPU 先处理其它事情?就是本文接下来的内容。

IO 模型

同步阻塞 BIO

当前程序执行到 IO 指令时暂停,等待 IO 返回结果才继续向后执行。

用一个通俗点的场景来说:

去商场吃饭,想吃的餐厅还在排队,就坐在门外的椅子上专门等排位。

我想吃饭(开始读写文件),但是由于饭店做饭的速度(IO 读写速度)赶不上人们吃饭的速度(执行指令速度),于是我啥也不干,专心等排位(当前程序啥也不干,就等 IO 返回结果)

用一段 c 语言代码来演示效果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <string.h>
#include <unistd.h> 

#define STRING_LEN 100

int main()
{
    char str[STRING_LEN];
    memset(str, 0, STRING_LEN);

    // 卡在这,等待用户输入
    int length = read(STDIN_FILENO, str, STRING_LEN);
  
    printf("input str:%s, len:%d\n", str, length);

    return 0;
}

程序执行后,就卡住了。此刻执行到 read 产生了 同步阻塞 情况,等待用户输入数据。只有用户输入完数据后,才会继续执行后续的 printf 等指令。

image-20211007154854064

通过 strace 命令也可以看出,程序阻塞在 read 系统调用上

image-20210925135717745

同步非阻塞 NIO

执行 IO 指令时,向系统提交读写请求,但并不等待 IO 执行结果,而是继续向后执行程序指令,后续再检查 IO 执行结果。

用一个通俗点的场景来说:

去商场吃饭,想吃的餐厅还在排队,于是就先叫号,然后去逛其它店,但会时常回来查看是否到号

我想吃饭(开始读写文件),但是由于饭店做饭的速度(IO 读写速度)赶不上人们吃饭的速度(执行指令速度)。于是我先排个号(提交 IO 申请),然后去逛其它店,过一会再回来看有没有排到我。

用伪代码来表示:

1
2
3
4
5
6
7
8
9
while (1) {
	ret = read();
	if ret == need_again {
		// 等一会再重试
		sleep(3)
	} else if ret == success {
		// 成功读取数据
	}
}

第一次调用 read 函数,会提交 IO 请求。后续程序会主动再次使用 read 查询读数据的进度,直到成功读取数据。 image-20211007155026196

对比同步阻塞,同步非阻塞虽然不会导致程序卡住(阻塞),但是需要程序主动轮询去查询 IO 状态。

C语言实例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>

#define STRING_LEN 100

int main()
{
    char str[STRING_LEN];
    memset(str, 0, STRING_LEN);

    // 设置为非阻塞模式
    if (fcntl(STDIN_FILENO, F_SETFL, O_NONBLOCK) == -1) {
        perror("fcntl failure");
    }

    int length = -1;
    while (1) {
        length = read(STDIN_FILENO, str, STRING_LEN);
        if (length == -1) {
            // 需要继续重试
            if (errno == EAGAIN) {
                printf("read again\n");
            }
        } else if (length > 0) {
            // 读取到输入的字符串
            printf("input str:%s, len:%d\n", str, length);
            return 0;
        }

        sleep(3);
        printf("3 secends pass\n");
    }

    return 0;
}

异步非阻塞 AIO

执行读写指令,提交 IO 请求时,并不等待执行结果,继续向后执行命令。当 IO 数据准备就绪时,系统会通知用户程序 IO 请求的执行结果。

用一个通俗点的场景来说:

去商场吃饭,想吃的餐厅还在排队,于是就先手机叫号,然后去逛其它店。当号排到时,手机会收到通知。

伪代码如下:

1
2
3
4
5
6
7
8
ready(data) {
	print("data is ready, content:%s", data)
}

main () {
	read(ready)
	// do something else
}

相比于同步阻塞、同步非阻塞模型,异步 IO 模型是系统主动通知用户程序,而不需要用户程序主动去获取 IO 结果。

image-20211007155104826

IO 多路复用 IO multiplexing

同时发起多个阻塞的读写请求,当有一个请求完成,就结束阻塞态,让用户程序继续处理

假设有这样的场景:

某程序为网站的服务端,当用户请求时,读取用户数据并进行后续处理。而在同一时间内,可能会有多个用户请求。

如果还是使用 BIO,程序每次则只能读取并处理一个用户的请求。当并发量较高时,则可能会出现用户请求排队,等待服务端处理的情况。

这时就该使用 IO 多路复用了。使用 IO 多路复用,同时读取多个用户请求的数据,当有用户请求数据读取完毕时,就进行后续逻辑处理。避免了用户请求排队,等待服务器处理的情况。

还是用通俗的场景来举例子:

去商场吃饭,但是想每个餐厅都尝一下。因此每个餐厅都取号,哪个排到号了就吃哪个餐厅。

对于 BIO 模型,它是当前程序暂停,等待 IO 操作完成。而多路复用也是当前程序暂停,不过是等待多个 IO 操作的完成。因此相对而言,多路复用通常更省时些。

在 Linux 下,IO 多路复用有三种主要的系统调用,而本文主要说明这三种多路复用的大体原理与区别,具体用法及实现原理可看参考资料。

select

将要监听的 fd 以数组形式提交给系统内核,系统内核会轮询数组中 fd 对应的文件。若文件就绪或者超时时间到,则返回。

poll

将要监听的 fd 以链表形式提交给系统内核,系统内核会轮询链表中 fd 对应的文件。若文件就绪或者超时时间到,则返回。

epoll

将要监听的 fd 提交到系统内核,并将回调函数挂载到设备IO 上。当有设备 ready 时,回调函数修改对应 fd 的状态。当 fd 状态变更或超时时,epoll 返回到用户态。

select poll epoll
fd 最大数量 通常为 1024 无上限 无上限
fd 拷贝情况 每次拷贝 每次拷贝 只拷贝一次
工作模式 LT LT LT 与 ET
时间复杂度 O(n) O(n) O(1)

参考文献

让 CPU 告诉你硬盘和网络到底有多慢

select、poll、epoll之间的区别

《UNIX 环境高级编程》

《Linux 系统编程》