携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第12天,点击查看活动详情 >>
常见的五种IO模型
基本知识准备
在IO过程中出现最多的就是用户空间和内核空间的交互了。
用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问
内核空间可以执行特权命令(Ring0),调用一切系统资源
写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备。
读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区
在读取数据的时候,用户进程需要经历两个阶段:
- 等待数据就绪
- 读取数据
阻塞IO(BIO)
顾名思义,阻塞IO就是两个阶段都必须阻塞等待。
阶段一:
用户进程尝试读取数据,可是数据尚未达到(未准备好)此时内核也是处于等待状态,而用户进程就是阻塞状态。
阶段二:
此时数据已经到达并来到了内核缓冲区,代表已经就绪;但是数据还只是在内核空间中,并没有被拷贝到用户空间中,所以这时候用户进程还是不能处理数据,继续阻塞。
直到数据拷贝到用户空间中,用户进程才解除阻塞,开始处理数据。
阻塞IO过程图解:
非阻塞IO(NIO)
在非阻塞IO中,recvfrom操作会立即返回结果而不是阻塞用户进程。
阶段一:
用户进程尝试读取数据,可是数据尚未达到(未准备好)此时内核是处于等待状态;但是由于是非阻塞IO,此时用户会返回异常,即用户进程并不会阻塞等待;①用户进程拿到error后,再次尝试读取,①循环往复,直到数据就绪。
整个过程和CPU的轮询很是相似!
阶段二:
此时数据已经到达并来到了内核缓冲区,代表已经就绪;但是数据还只是在内核空间中,并没有被拷贝到用户空间中,所以这时候用户进程还是不能处理数据,继续阻塞。
直到数据拷贝到用户空间中,用户进程才解除阻塞,开始处理数据。
非阻塞IO过程图解:
IO多路复用
刚刚上面提到的BIO和NIO,两者的第二阶段是一样的,在数据没到用户空间之前都是阻塞等待的,区别就在于第一阶段:
如果调用recvfrom时,恰好没有数据,阻塞IO会使CPU阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。
如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据
可以看到上述两种模式,在单线程的情况下,只能依次处理IO事件;只要当前正在处理的IO事件未就绪,哪怕有其他已经就绪的,在等待的IO事件,该线程也无法去处理。
这个流程就好像是平常生活中饭堂打饭一样,只有一个窗口,窗口的前面排了长长一队,但凡你前面那个没想好吃什么,没开始点菜,没走,那都没轮到你
解决以上缺点,有以下两种方案: 、
- 方案一:多开几个窗口(多线程)
- 方案二:不排队了,规定谁先想好的先得到服务,没想好吃什么的,哪里凉快哪里去。
方案一中要多开线程,多开窗口,就意味着更大的开销和更大的成本,所以我们会更倾向于使用方案二。
那么问题来了,我们怎么才知道哪个人想好了就绪了呢?
很简单啊,饭堂阿姨留意一下窗口前的人就行了,只要有人喊了,就说明有人就绪了。
在这种场景下,声音是一种标志,是一种信号,对于操作系统中的就是==》
文件描述符(FD) :是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。
IO多路复用就是基于FD,采用方案二来解决问题的。
IO多路复用过程图解:
由上图可知:
(以读取网卡数据为例)
阶段一:
- 用户进程调用select,指定要监听的FD集合;
- 内核监听FD对应的多个socket;
- 任意一个或多个socket数据就绪则返回readable;
- 此过程中用户进程阻塞
阶段二:
- 用户进程找到就绪的socket
- 依次调用recvfrom读取数据
- 内核将数据拷贝到用户空间
- 用户进程处理数据
IO多路复用:是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。但是监听FD、通知的方式又有以下几种:
- select(正是上述图中的那种)
- poll
- 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。
通过上图的学习,我们可以很直观地感受到select模式的缺点:
- 一开始需要将整个'fd_set'从用户空间拷贝到内核空间,在select结束之后还要再次拷贝回用户空间
- select无法得知具体是哪个fd就绪,需要遍历整个fd_set
- 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有了两点改变:
- select模式中的fd_set大小固定为1024,而pollfd在内核中采用链表,理论上无上限
- 监听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流程动图演示:
epoll相对于前面两种select和poll的改进:
- 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高
- 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
- 利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降
信号驱动IO
信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。
阶段一:
①用户进程调用sigaction,注册信号处理函数
②内核返回成功,开始监听FD
③用户进程不阻塞等待,可以执行其它业务
④当内核数据就绪后,回调用户进程的SIGIO处理函数
阶段二:
①收到SIGIO回调信号
②调用recvfrom,读取
③内核将数据拷贝到用户空间
④用户进程处理数据
信号驱动IO图解:
缺点: 当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。
异步IO
异步IO的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。(这个过程就像是CPU的中断机制一样,用户进程把任务放出去后,就可以去干其他事情了,待内核处理完毕后,内核会给信号用户进程)
这里需要区别一下的是,异步IO和信号驱动IO都有点像是中断模式,但是两者的区别是,信号驱动IO在第一阶段结束的时候就发出中断信号了,第二阶段需要用户进程参与;而异步IO则实现了真正的异步,内核只有把所有东西都处理完了(数据都拷贝回到用户空间了)才会发出中断信号
阶段一:
①用户进程调用aio_read,创建信号回调函数
②内核等待数据就绪
③用户进程无需阻塞,可以做任何事情
阶段二:
①内核数据就绪
②内核数据拷贝到用户缓冲区
③拷贝完成,内核递交信号触发aio_read中的回调函数
④用户进程处理数据
异步IO图解:
缺点:在高并发场景下,因为IO效率较低,所以会积累很多任务在系统中,容易导致系统崩溃。(可以用限流等方式解决,但是实现方式繁琐复杂)
总结:
一共介绍了五种IO模型,只有最后一种异步IO才是真正的异步(内核空间与用户空间的拷贝过程并不需要用户进程关心)