IO模型【NIO, BIO, AIO, IO多路复用】

1. 什么是IO

IO即input/output,在Linux世界中,一切皆文件,而文件就是一串二进制流,不管是FIFO、Socket、管道还是终端,都可以视为“流”,在信息的交换过程中,对这些流进行数据收发的操作被称为I/O操作。IO有内存IO、网络IO、磁盘IO三种,通常我们说的IO是指后面两种。如磁盘IO,从磁盘中读取数据到内存可以理解为一次输入,对应的,将内存中的数据写入磁盘,就算输出。

操作系统负责计算机的资源管理和进程的调度。我们电脑上跑着的应用程序,其实是需要经过操作系统,才能做一些特殊操作,如磁盘文件读写、内存的读写等等。因为这些都是比较危险的操作,不可以由应用程序乱来,只能交给底层操作系统来。也就是说,你的应用程序要把数据写入磁盘,只能通过调用操作系统开放出来的API来操作。

  • 从流中读取数据,系统调用read;写入数据,系统调用write

我们的应用程序是运行在用户空间的,它不存在实质的IO过程,真正的IO是由操作系统执行的。即应用程序的IO操作分为两种动作:IO调用和IO执行。IO调用由进程(应用程序的运行态)发起,而IO执行是通过操作系统内核来完成的。

应用程序发起一次IO操作包含两个阶段:

  • IO调用:应用程序进程向操作系统内核发起调用(系统调用readwrite
  • IO执行:操作系统内核完成IO操作

操作系统内核完成IO操作还包括两个过程:

  • 准备数据过程:内核等待IO设备准备好数据
  • 拷贝数据阶段:将数据从内核缓冲区拷贝到用户进程缓冲区

其实IO就是把进程内部的数据转移到外部设备,或者把外部设备的数据迁移到进程内部。外部设备一般指硬盘、socket通讯的网卡。一个完整的IO过程包括以下几个步骤:

  • 应用程序向操作系统发起IO调用请求
  • 操作系统准备数据,将IO外部设备的数据加载到内核缓冲区
  • 操作系统拷贝数据,即把内核缓冲区的数据拷贝到用户进程的缓冲区

磁盘IO:

网络IO:

2. IO模型

IO模型按照同步和异步进行区分,可以分为五种IO模型。

关于同步&异步、阻塞&非阻塞的概念在彻底搞清楚「同步&异步 阻塞&非阻塞」中介绍过了。IO大体可以分为两大类:同步IO和异步IO。

  • 同步IO:用户进程发起IO请求后,必须等待IO操作完成,控制权才会返回给用户进程
  • 异步IO:用户进程发起IO请求后立即返回,可继续执行用户进程,待IO操作完成后操作系统内核通过消息或者回调的方式通知用户进程结果。

接下来,我们来对五种IO模型分别展开介绍。

2.1 阻塞IO(Blocking IO, BIO)

阻塞和非阻塞的概念描述的是用户进程调用内核IO操作的方式:阻塞式IO是指直到IO操作全部完成后才能返回用户进程;非阻塞式IO则无需等待IO操作全部完成再返回,即便IO操作未完成也可以返回给用户进程一个状态值。默认情况下,像Java IO以及socket都是阻塞式IO模型,一个典型的BIO读数据流程如下图所示

  1. 当应用进程调用了recvfrom系统调用后,系统内核就开始了IO的第一个阶段:准备数据
  2. 对于IO(无论是网络IO还是磁盘IO)来说,很多时候数据在一开始还没到达时,系统内核就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。
  3. 当系统内核一直等到数据准备好了,它就会将数据从系统内核中拷贝到用户内存中,然后系统内核返回结果,用户进程才解除阻塞的状态,重新运行起来。

实际上,除非特别指定,几乎所有的 IO 接口都阻塞型的。这给网络编程带来了一个很大的问题,如在调用 send 的同时,线程处于阻塞状态,则在此期间,线程将无法执行任何运算或响应任何网络请求。

2.2 非阻塞IO(Non-Blocking IO, NIO)

当用户进程发出非阻塞IO操作时,如果内核中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个错误。从用户进程角度讲,它发起IO操作后,并不需要等待,而是马上就得到了一个结果 当用户进程判断结果是一个错误时,它就知道数据还没有准备好,于是它可以再次发送IO操作。一旦内核中的数据准备好了,并且又再次收到了用户进程的系统调用,那么它马上就将数据复制到了用户内存中,然后返回正确的返回值。一个非阻塞socket执行read操作的流程:

非阻塞IO的流程:

  1. 应用进程向操作系统内核发起recvfrom读取数据请求
  2. 操作系统内核数据还没准备好,会立即返回EWOULDBLOK错误码
  3. 应用进程轮询调用,继续向操作系统内核发送recvfrom读取数据请求
  4. 操作系统内核数据准备就绪,从内核缓冲区拷贝数据至用户空间(注意拷贝数据的过程还是阻塞的)
  5. 完成调用,返回应用进程

非阻塞式IO相比于阻塞式IO的优点在于当内核数据未准备就绪时不会一直阻塞当前线程,而是会立即返回一个错误码;但非阻塞式IO的缺点也很明显,就是需要应用进程不断轮询调用,这会大量消耗CPU资源。

2.3 IO多路复用

IO多路复用的核心思想:操作系统提供一组系统调用(select/poll/epoll),通过该系统调用可以同时监视多个文件描述符fd,一旦某个描述符为就绪(一般指内核缓冲区可读/可写)状态,操作系统内核能够将就绪状态返回给用户进程,随后用户进程发起recvfrom系统调用,进行IO操作。

首先我们先来介绍下文件描述符。

文件描述符

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

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

image-20220919091104526

通过 open(),socket() 创建文件后,都有一个 fd(文件描述符) 与之对应,对于每一个进程,都有有一个文件描述符列表(File Discriptor Table) 来记录其打开的文件,这个列表的每一项都指向其背后的具体文件,而每一项对应的数组下标就是 fd,对于用户而言,可以认为 fd 代表其背后指向的文件。

fd 的值从 0 开始,其中 0,1,2 是固定的,分别指向标准输入(指向键盘),标准输出/标准错误(指向显示器),之后每打开一个文件,fd 都会从 3 开始递增,但需要注意的是 fd 并不一定都是递增的,如果关闭了文件,之前的 fd 是可以被回收利用的。

IO 多路复用其实也就是用一个进程来监听多个 fd 的数据事件,用户可以把自己感兴趣的 fd 及对应感兴趣的事件(accept,read/write)传给内核,然后内核就会检测 fd ,一旦某个 socket 有事件了,内核可以唤醒用户进程来处理。那么怎样才能知道某个 fd 是否有事件呢,一种很容易想到的做法是搞个轮询,每次调用一下 read(fd),让内核告知是否数据已就绪,但是这样的话如果有 n 个感兴趣的 fd 就会有 n 次 read 系统调用,开销很大,显然不可接受。

所以使用 IO 多路复用监听 fd 的事件可行,但必须解决以下三个涉及到性能瓶颈的点:

  1. 如何高效将用户感兴趣的 fd 和事件传给内核
  2. 某个 socket 数据就绪后,内核如何高效通知用户进程进行处理
  3. 用户进程如何高效处理事件

前面两步的处理目前有 select,poll,epoll 三种 IO 多路事件模型,我们一起来看看,看完你就会知道为啥 epoll 的性能是如此高效了。

select

我们先来看下 select 函数的定义:

1
2
3
4
5
6
// 返回:若有就绪描述符则为其数目,若超时则为 0,若出错则为-1
int select(int maxfd, // 待测试的描述符基数,为待测试的最大描述符加1
fd_set *readset, // 读描述符集合
fd_set *writeset, // 写描述符集合
fd_set *exceptset, // 异常描述符集合
const struct timeval *timeout); // 阻塞时间

这里需要说明一下,为啥 maxfd 为待测试的描述符加 1 呢,主要是因为数组的下标是从 0 开始的,假设进程新建了一个 listenfd,它的 fd 为 3,那么代表它有 4 个 感兴趣的 fd(每个进程有固定的 fd = 0,1,2 这三个描述符),由此可知 maxfd = 3 + 1 = 4

接下来我们来看看读,写,异常集合是怎么回事,如何设置针对 fd 的感兴趣事件呢,其实事件集合是采用了一种位结构(bitset)的方式,比如现在假设我们对标准输入(fd = 0),listenfd(fd = 3)的读事件感兴趣,那么就可以在 readset 对应的位上置 1

画外音: 使用 FD_SET 可将相应位置置1,如 FD_SET(listenfd, &readset)

如下

即 readset 为 {1, 0,0, 1},在调用 select 后会将 readset 传给内核,如果内核发现 listenfd 有连接已就绪的事件,则内核也会在将相应位置置 1(其他无就绪事件的 fd 对应的位置为 0)然后会回传给用户线程,此时的 readset 如下,即 {1,0,0,0}

于是进程就可以根据 readset 相应位置是否是 1(用 FD_ISSET(i, &read_set) 来判断)来判断读事件是否就绪了

需要注意的是由于 accept 的 socket 会越来越多,maxfd 和事件 set 都需要及时调整(比如新 accept 一个已连接的 socket,maxfd 可能会变,另外也需要将其加入到读写描述符集合中以让内核监听其读写事件)

可以看到 select 将感兴趣的事件集合存在一个数组里,然后一次性将数组拷贝给了内核,这样 n 次系统调用就转化为了一次,然后用户进程会阻塞在 select 系统调用上,由内核不断循环遍历,如果遍历后发现某些 socket 有事件(accept 或 read/write 准备好了),就会唤醒进程,并且会把数据已就绪的 socket 数量传给用户进程,动图如下

select 的伪代码如下:

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
39
40
41
42
43
44
45
46
47
int listen_fd,conn_fd; //监听套接字和已连接套接字的变量
listen_fd = socket() //创建套接字
bind(listen_fd) //绑定套接字
listen(listen_fd) //在套接字上进行监听,将套接字转为监听套接字

fd_set master_rset; //被监听的描述符集合,关注描述符上的读事件

int max_fd = listen_fd

//初始化 master_rset 数组,使用 FD_ZERO 宏设置每个元素为 0
FD_ZERO(&master_rset);
//使用 FD_SET 宏设置 master_rset 数组中位置为 listen_fd 的文件描述符为 1,表示需要监听该文件描述符
FD_SET(listen_fd,&master_rset);

fd_set working_set;

while(1) {

// 每次都要将 master_set copy 给 working_set,因为 select 返回后 working_set 会被内核修改
working_set = master_set
/**
* 调用 select 函数,检测 master_rset 数组保存的文件描述符是否已有读事件就绪,
* 返回就绪的文件描述符个数,我们只关心读事件,所以其它参数都设置为 null 了
*/
nready = select(max_fd+1, &working_set, NULL, NULL, NULL);

// 依次检查已连接套接字的文件描述符
for (i = 0; i < max_fd && nready > 0; i++) {
// 说明 fd = i 的事件已就绪
if (FD_ISSET(i, &working_set)) {
nready -= 1;
//调用 FD_ISSET 宏,在 working_set 数组中检测 listen_fd 对应的文件描述符是否就绪
if (FD_ISSET(listen_fd, &working_set)) {
//如果 listen_fd 已经就绪,表明已有客户端连接;调用 accept 函数建立连接
conn_fd = accept();
//设置 master_rset 数组中 conn_fd 对应位置的文件描述符为 1,表示需要监听该文件描述符
FD_SET(conn_fd, &master_rset);
if (conn_fd > max_fd) {
max_fd = conn_fd;
}
} else {
//有数据可读,进行读数据处理
read(i, xxx)
}
}
}
}

看起来 select 很好,但在生产上用处依然不多,主要是因为 select 有以下劣势:

  1. 每次调用 select,都需要把 fdset 从用户态拷贝到内核态,在高并发下是个巨大的性能开销(可优化为不拷贝)
  2. 调用 select 阻塞后,用户进程虽然没有轮询,但在内核还是通过遍历的方式来检查 fd 的就绪状态(可通过异步 IO 唤醒的方式)
  3. select 只返回已就绪 fd 的数量,用户线程还得再遍历所有的 fd 查看哪些 fd 已准备好了事件(可优化为直接返回给用户进程数据已就绪的 fd 列表)

正在由于 1,2 两个缺点,所以 select 限制了 maxfd 的大小为 1024,表示只能监听 1024 个 fd 的事件,这离 C10k 显然还是有距离的

poll

poll 的机制其实和 select 一样,唯一比较大的区别其实是把 1024 这个限制给放开了,虽然通过放开限制可以使内核监听上万 socket,但由于以上说的两点劣势,它的性能依然不高,所以生产上也不怎么使用。

epoll

接下来我们再来介绍下生产上用得最多的 epoll,epoll 其实和 select,poll 这两个系统调用不一样,它本来其实是个内核的数据结构,这个数据结构允许进程监听多个 socket 的 事件,一般我们通过 epoll_create 来创建这个实例

img

然后我们再调用 epoll_ctl 把 感兴趣的 fd 加入到 epoll 实例中的 interest_list,然后再调用 epoll_wait 即可将控制权交给内核,这样内核就会检测此 interest_list,如果发现 socket 已就绪就会把已就绪的 fd 加入到一个 ready_list(简称 rdlist) 中,然后再唤醒用户进程,之后用户进程只要遍历此 rdlist 即可

img

为了方便快速对 fd 进行增删改查,必须设计好 interest list 的数据结构,经综合考虑,内核使用了红黑树,而 rdlist 则采用了链表的形式,这样一旦在红黑树上发现了就绪的 socket ,就会把它放到 rdlist 中

epoll 的伪代码如下

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
39
int sock_fd,conn_fd; //监听套接字和已连接套接字的变量
sock_fd = socket() //创建套接字
bind(sock_fd) //绑定套接字
listen(sock_fd) //在套接字上进行监听,将套接字转为监听套接字

epfd = epoll_create(); //创建epoll实例,
//创建epoll_event结构体数组,保存套接字对应文件描述符和监听事件类型
ep_events = (epoll_event*)malloc(sizeof(epoll_event) * EPOLL_SIZE);

//创建epoll_event变量
struct epoll_event ee
//监听读事件
ee.events = EPOLLIN;
//监听的文件描述符是刚创建的监听套接字
ee.data.fd = sock_fd;

//将监听套接字加入到监听列表中
epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, &ee);

while (1) {
//等待返回已经就绪的描述符
n = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
//遍历所有就绪的描述符
for (int i = 0; i < n; i++) {
//如果是监听套接字描述符就绪,表明有一个新客户端连接到来
if (ep_events[i].data.fd == sock_fd) {
conn_fd = accept(sock_fd); //调用accept()建立连接
ee.events = EPOLLIN;
ee.data.fd = conn_fd;
//添加对新创建的已连接套接字描述符的监听,监听后续在已连接套接字上的读事件
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ee);

} else { //如果是已连接套接字描述符就绪,则可以读数据
...//读取数据并处理
read(ep_events[i].data.fd, ..)
}
}
}
复制代码

epoll 的动图如下

epoll 图解,图片来自《低并发编程》

可以看到 epoll 很好地解决了 select 的痛点

  1. 「每次调用 select 都把 fd 集合拷贝给内核」优化为「只有第一次调用 epoll_ctl 添加感兴趣的 fd 到内核的 epoll 实例中,之后只要调用 epoll_wait 即可,数据集合不再需要拷贝」
  2. 「用户进程调用 select 阻塞后,内核会通过遍历的方式来同步检查 fd 的就绪状态」优化为「内核使用异步事件通知」
  3. 「select 仅返回已就绪 fd 的数量,用户线程还得再遍历一下所有的 fd 来挨个检查哪个 fd 的事件已就绪了」优化为「内核直接返回已就绪的 fd 集合」

IO 多路复用+非阻塞

那么当 IO 多路程复用检查到数据就绪后(select(),poll(),epoll_wait() 返回后),该怎么处理呢,有人说直接 accept,read/write 不就完了,话是没错,但之前我们也说了,这些操作其实默认是阻塞的,我们需要将其改成非阻塞,为什么呢,数据不是已经就绪了吗,说明 accept,read/write 这些操作可以正常获取数据啊?

其实数据就绪只是说在调用 select(),poll(),epoll_wait() 返回时的数据是就绪的,但当你再去调用 accept,read/write 时可能就会变成未就绪了,举个例子:当某个 socket 接收缓冲区有新数据分节到达,然后 select 报告这个 socket 描述符可读,但随后,协议栈检查到这个新分节有错误,然后丢弃这个分节,这时候调用 read 则无数据可读,这样的话就会产生一个严重的后果:由于线程阻塞在了 read 上,便再也不能调用 select 来监听 socket 事件了,所以 IO 多路程复用一定要和非阻塞配合使用,也就是说要把 listenfd 和 connectfd 设置为非阻塞才行。

2.4 信号驱动IO

这种模式一般很少用,所以不重点说了,大概说一下,如图所示:

首先我们允许Socket进行信号驱动IO,并安装一个信号处理函数,进程继续运行并不阻塞。

当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。

流程如下:

  • 开启套接字信号驱动IO功能
  • 系统调用Sigaction执行信号处理函数(非阻塞,立刻返回)
  • 数据就绪,生成Sigio信号,通过信号回调通知应用来读取数据

此种IO方式存在的一个很大的问题:Linux中信号队列是有限制的,如果超过这个数字问题就无法读取数据

2.5 异步IO(Asynchronous IO, AIO)

前面讲的BIO,NIO和信号驱动,在数据从内核复制到应用缓冲的时候,都是阻塞的,因此都不算是真正的异步。AIO实现了IO全流程的非阻塞,就是应用进程发出系统调用后,是立即返回的,但是立即返回的不是处理结果,而是表示提交成功类似的意思。等内核数据准备好,将数据拷贝到用户进程缓冲区,发送信号通知用户进程IO操作执行完毕。

异步IO来自于POSIX AIO规范,它专门提供了能异步IO的读写类函数,如aio_read(),aio_write()等。

使用异步IO函数时,要求指定IO完成时或IO出现错误时的通知方式,通知方式主要分两类:

  • 发送指定的信号来通知
  • 在另一个线程中执行指定的回调函数

异步IO流程如下所示:

  • 当用户线程调用了aio_read系统调用,立刻就可以开始去做其它的事,用户线程不阻塞
  • 内核就开始了IO的第一个阶段:准备数据。当内核一直等到数据准备好了,它就会将数据从内核内核缓冲区,拷贝到用户缓冲区
  • 内核会给用户线程发送一个信号,或者回调用户线程注册的回调接口,告诉用户线程Read操作完成了
  • 用户线程读取用户缓冲区的数据,完成后续的业务操作

相对于同步IO,异步IO不是顺序执行。

用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。

等到数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。

对比信号驱动IO,异步IO的主要区别在于:

  • 信号驱动由内核告诉我们何时可以开始一个IO操作(数据在内核缓冲区中),而异步IO则由内核通知IO操作何时已经完成(数据已经在用户空间中)。

异步IO又叫做事件驱动IO,在Unix中,为异步方式访问文件定义了一套库函数,定义了AIO的一系列接口。

  • 使用aio_read或者aio_write发起异步IO操作,使用aio_error检查正在运行的IO操作的状态。

目前Linux中AIO的内核实现只对文件IO有效,如果要实现真正的AIO,需要用户自己来实现。

目前有很多开源的异步IO库,例如libevent、libev、libuv。

3. 巨人的肩膀