【linux内核】五大经典IO模型(原理+动图+代码详解)

1,693 阅读10分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第12天,点击查看活动详情 >>

常见的五种IO模型

基本知识准备

在IO过程中出现最多的就是用户空间和内核空间的交互了。

用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问

内核空间可以执行特权命令(Ring0),调用一切系统资源

image.png

写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备。

读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区

动画.gif

在读取数据的时候,用户进程需要经历两个阶段:

  1. 等待数据就绪
  2. 读取数据

image.png

阻塞IO(BIO)

顾名思义,阻塞IO就是两个阶段都必须阻塞等待。

阶段一:

用户进程尝试读取数据,可是数据尚未达到(未准备好)此时内核也是处于等待状态,而用户进程就是阻塞状态。

阶段二:

此时数据已经到达并来到了内核缓冲区,代表已经就绪;但是数据还只是在内核空间中,并没有被拷贝到用户空间中,所以这时候用户进程还是不能处理数据,继续阻塞。

直到数据拷贝到用户空间中,用户进程才解除阻塞,开始处理数据。

阻塞IO过程图解:

image.png

非阻塞IO(NIO)

在非阻塞IO中,recvfrom操作会立即返回结果而不是阻塞用户进程。

阶段一:

用户进程尝试读取数据,可是数据尚未达到(未准备好)此时内核是处于等待状态;但是由于是非阻塞IO,此时用户会返回异常,即用户进程并不会阻塞等待;①用户进程拿到error后,再次尝试读取,①循环往复,直到数据就绪。

整个过程和CPU的轮询很是相似!

阶段二:

此时数据已经到达并来到了内核缓冲区,代表已经就绪;但是数据还只是在内核空间中,并没有被拷贝到用户空间中,所以这时候用户进程还是不能处理数据,继续阻塞。

直到数据拷贝到用户空间中,用户进程才解除阻塞,开始处理数据。

非阻塞IO过程图解:

image.png

IO多路复用

刚刚上面提到的BIO和NIO,两者的第二阶段是一样的,在数据没到用户空间之前都是阻塞等待的,区别就在于第一阶段:

如果调用recvfrom时,恰好没有数据,阻塞IO会使CPU阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。

如果调用recvfrom时,恰好数据,则用户进程可以直接进入第二阶段,读取并处理数据

可以看到上述两种模式,在单线程的情况下,只能依次处理IO事件;只要当前正在处理的IO事件未就绪,哪怕有其他已经就绪的,在等待的IO事件,该线程也无法去处理。

这个流程就好像是平常生活中饭堂打饭一样,只有一个窗口,窗口的前面排了长长一队,但凡你前面那个没想好吃什么,没开始点菜,没走,那都没轮到你

解决以上缺点,有以下两种方案:

  1. 方案一:多开几个窗口(多线程)
  2. 方案二:不排队了,规定谁先想好的先得到服务,没想好吃什么的,哪里凉快哪里去。

方案一中要多开线程,多开窗口,就意味着更大的开销和更大的成本,所以我们会更倾向于使用方案二。

那么问题来了,我们怎么才知道哪个人想好了就绪了呢?

很简单啊,饭堂阿姨留意一下窗口前的人就行了,只要有人喊了,就说明有人就绪了。

在这种场景下,声音是一种标志,是一种信号,对于操作系统中的就是==》

文件描述符(FD) :是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。

IO多路复用就是基于FD,采用方案二来解决问题的。

IO多路复用过程图解:

image.png

由上图可知:

(以读取网卡数据为例)

阶段一:

  1. 用户进程调用select,指定要监听的FD集合;
  2. 内核监听FD对应的多个socket;
  3. 任意一个或多个socket数据就绪则返回readable;
  4. 此过程中用户进程阻塞

阶段二:

  1. 用户进程找到就绪的socket
  2. 依次调用recvfrom读取数据
  3. 内核将数据拷贝到用户空间
  4. 用户进程处理数据

IO多路复用:是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。但是监听FD、通知的方式又有以下几种:

  1. select(正是上述图中的那种)
  2. poll
  3. epoll

select:

select是Linux最早是由的I/O多路复用技术。

下面我们一起看看它的源码实现:

// 定义类型别名 __fd_mask,本质是 long int
typedef long int __fd_mask;
•/* fd_set 记录要监听的fd集合,及其对应状态 */
typedef struct {
    // fds_bits是long类型数组,长度为 1024/32 = 32
    // 共1024个bit位,每个bit位代表一个fd,0代表未就绪,1代表就绪
    __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
    // ...
} fd_set;
•// select函数,用于监听fd_set,也就是多个fd的集合
int select(
    int nfds, // 要监视的fd_set的最大fd + 1
    fd_set *readfds, // 要监听读事件的fd集合
    fd_set *writefds,// 要监听写事件的fd集合
    fd_set *exceptfds, // // 要监听异常事件的fd集合
    // 超时时间,null-用不超时;0-不阻塞等待;大于0-固定等待时间
    struct timeval *timeout
);
​

select模式的具体实流程(动图演示):

fd_set并不是一个存储任意整数的数组,其实它只存放0和1,比如监听fd1,2,5,那么fd_set从右往左第一个、第二个、第五个置 1。

IO多路复用-select.gif

通过上图的学习,我们可以很直观地感受到select模式的缺点:

  1. 一开始需要将整个'fd_set'从用户空间拷贝到内核空间,在select结束之后还要再次拷贝回用户空间
  2. select无法得知具体是哪个fd就绪,需要遍历整个fd_set
  3. fd_set监听的fd数量不能超过1024(源码决定的)

poll:

poll模式是在select模式的基础上进行的改进,但是只是很简单的改进。

老规矩,我们先来看看它源码是怎么实现的:

// pollfd 中的事件类型
#define POLLIN     //可读事件
#define POLLOUT    //可写事件
#define POLLERR    //错误事件
#define POLLNVAL   //fd未打开// pollfd结构
struct pollfd {
    int fd;           /* 要监听的fd  */
    short int events; /* 要监听的事件类型:读、写、异常 */
    short int revents;/* 实际发生的事件类型 */
};
•// poll函数
int poll(
    struct pollfd *fds, // pollfd数组,可以自定义大小
    nfds_t nfds, // 数组元素个数
    int timeout // 超时时间
);
​

IO流程:

①创建pollfd数组,向其中添加关注的fd信息,数组大小自定义

②调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限

③内核遍历fd,判断是否就绪

④数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n

⑤用户进程判断n是否大于0

⑥大于0则遍历pollfd数组,找到就绪的fd

通过上述流程,我们发现相对于select,poll有了两点改变:

  1. select模式中的fd_set大小固定为1024,而pollfd在内核中采用链表,理论上无上限
  2. 监听FD越多(fd_set越大),每次遍历消耗时间也越久,性能反而会下降

虽然poll解决了select中监听fd的上限,但是poll中还是要遍历所有的FD,且如果fd监听过多会导致性能下降。

epoll:

先来看看epoll的实现:

struct eventpoll {
    //...
    struct rb_root  rbr; // 一颗红黑树,记录要监听的FD
    struct list_head rdlist;// 一个链表,记录就绪的FD
    //...
};
•// 1.创建一个epoll实例,内部是event poll,返回对应的句柄epfd
int epoll_create(int size);
​
// 2.将一个FD添加到epoll的红黑树中,并设置ep_poll_callback
// callback触发时,就把对应的FD加入到rdlist这个就绪列表中
int epoll_ctl(
    int epfd,  // epoll实例的句柄
    int op,    // 要执行的操作,包括:ADD、MOD、DEL
    int fd,    // 要监听的FD
    struct epoll_event *event // 要监听的事件类型:读、写、异常等
);
​
// 3.检查rdlist列表是否为空,不为空则返回就绪的FD的数量
int epoll_wait(
    int epfd,                   // epoll实例的句柄
    struct epoll_event *events, // 空event数组,用于接收就绪的FD
    int maxevents,              // events数组的最大长度
    int timeout   // 超时时间,-1用不超时;0不阻塞;大于0为阻塞时间
);
​

epoll流程动图演示:

IO多路复用-epoll.gif

epoll相对于前面两种select和poll的改进:

  1. 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高
  2. 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
  3. 利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降

信号驱动IO

信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。

阶段一:

①用户进程调用sigaction,注册信号处理函数

②内核返回成功,开始监听FD

③用户进程不阻塞等待,可以执行其它业务

④当内核数据就绪后,回调用户进程的SIGIO处理函数

阶段二:

①收到SIGIO回调信号

②调用recvfrom,读取

③内核将数据拷贝到用户空间

④用户进程处理数据

信号驱动IO图解:

image.png

缺点: 当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。

异步IO

异步IO的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。(这个过程就像是CPU的中断机制一样,用户进程把任务放出去后,就可以去干其他事情了,待内核处理完毕后,内核会给信号用户进程)

这里需要区别一下的是,异步IO和信号驱动IO都有点像是中断模式,但是两者的区别是,信号驱动IO在第一阶段结束的时候就发出中断信号了,第二阶段需要用户进程参与;而异步IO则实现了真正的异步,内核只有把所有东西都处理完了(数据都拷贝回到用户空间了)才会发出中断信号

阶段一:

①用户进程调用aio_read,创建信号回调函数

②内核等待数据就绪

③用户进程无需阻塞,可以做任何事情

阶段二:

①内核数据就绪

②内核数据拷贝到用户缓冲区

③拷贝完成,内核递交信号触发aio_read中的回调函数

④用户进程处理数据

异步IO图解:

image.png

缺点:在高并发场景下,因为IO效率较低,所以会积累很多任务在系统中,容易导致系统崩溃。(可以用限流等方式解决,但是实现方式繁琐复杂)

总结:

一共介绍了五种IO模型,只有最后一种异步IO才是真正的异步(内核空间与用户空间的拷贝过程并不需要用户进程关心)

image.png