大家好 我是积极向上的湘锅锅💪💪💪
简介
当用户应用想直接操作硬件上的数据时,是没有权限直接操作的,必须使用内核对外提供的指令或者函数
如果数据没还有准备好,必须等待数据就绪,总的来说,各种的IO模型主要是相差在俩个阶段
- 等待数据就绪:将磁盘的数据读取到内核缓冲区
- 读取数据:将内核缓冲区的数据读取到用户缓冲区
1. 阻塞IO
俩个阶段都阻塞
- 用户应用调用recvform函数,内核检查有无数据
- 如果没有数据,那就等待数据从磁盘读取到内核
- 再从内核拷贝到用户空间
在整个过程中,进程都要阻塞等待数据的就绪和拷贝
2. 非阻塞IO
非阻塞IO会在调用recvform函数立即返回结果,但是如果数据还没有准备好,跟阻塞IO区别在于第一阶段不再是阻塞等待,而是会一直调用recvform函数询问内核是否准备好
看的出来这思路跟自旋一样,都是让CPU去做无用功,去尝试获取数据,那就导致了CPU在高并发的情况下会使用率暴增
3 IO多路复用
文件描述符(File Descriptor),简称FD,是一个从0开始递增的无符号整数,用来关联Linux中的一个文件,在Linux中,一切皆文件,例如常规文件,视频,硬件设备等
IO多路复用: 是利用单个线程监听多个FD,在某个FD可读,可写时候得到通知,从而避免无效的等待,充分利用CPU资源
常见的监听FD,通知的方式有三种
- select
- poll
- epoll
差异:
- select和poll只会通知用户进程有FD就绪,但不确认具体是哪个FD,需要用户进程遍历整个FD来确认
- epoll则会在通知用户进程FD就绪的同时,把已经就绪的FD写入用户空间
3.1 IO多路复用-select
先从上往下看
- __fd_mask是一个long int,在c语言里面占4个字节,32个bit位
- 而fd_set是来装载FD的,是FD的一个集合,本身长度为32,因为前面修饰为__fd_mask,__fd_mask又是占32bit,所以总共可装载的FD为1024个bit,0代表该FD未准备就绪,1代表该FD准备就绪
- select函数里面值得注意的是有不同的fd_set集合,在select根据fd的特性是分隔开的,而超时时间可以理解为select要等待一个或者多个就绪后返回,或者超时返回,那超时时间就是限制是否等待,或者最多等待多久
第一阶段流程如下
用户空间所做的就是传参和调用函数,将fd_set拷贝到内核空间,内核就会遍历一遍fd_set,如果没有数据就绪,那就休眠等待,直到唤醒或者超时
如果数据就绪,select就会返回已经就的FD的个数
而内核那就会将就绪的FD保留,未就绪的从fd_set删除,然后再拷贝回用户缓冲区,把用户空间创建的rdfs给覆盖掉
因为fd_set是以01代表就绪还是未就绪,所以用户空间还得重新遍历一遍fd_set,来获取已经就绪的FD是哪些,然后读取数据
可以看得出来,其实过程相当麻烦且臃肿
- 需要进行俩次拷贝,从用户空间到内核空间,内核空间再到用户空间
- 用户空间还得重新遍历整个fd_set
- fd_set数量最多不能超过1024
3.2 IO多路复用-poll
与select相比,其实poll模式也就是在长度的限制上做了改进,poll模式下存储的FD个数将没有上限,但是同时也是一把双刃剑,监听的FD越多,每次遍历消耗的时间也越久,性能反而随之下降
poll函数:
- 相比select,poll函数更加的简洁了,将FD的参数单独剥离出来,只用一个pollfd的数组表示,而这个数组的长度是没有上限的
- 还有一点改进是poll函数中指明了需要查询的元素个数,也就是查询的长度是已知的,而select是未知的
- 超时时间跟select一致
pollfd:
- 不再使用二进制位表示,直接使用FD的整数表示
- 不再将同一事件类型放在相同集合,在一个不同的pollfd数组中,每一个pollfd都可以有不同的事件类型
- 实际发生的类型表示如果已经准备就绪,那就是第二个参数中所表明的事件类型,否则就为0表示
IO流程:
- 创建pollfd数组,将所要查询的fd添加进去,数组大小自定义
- 调用poll函数,将pollfd数组拷贝到内核空间,转为链表存储
- 等待数据准备就绪或者超时,拷贝pollfd到用户空间,并且返回已经就绪的FD个数
- 判断个数是否大于0
- 个数大于0则用户空间扫描pollfd数组,找到就绪的FD
3.3 IO多路复用-epoll
相比于前俩种模式,epoll做出了相当大的改进
首先,会将需要监听的FD添加到内核空间
调用epoll_create函数,会创建一个eventpoll实例
而里面的俩个参数,分别是一颗红黑树(省内存、且有序)和链表,分别代表要监听的FD和已经就绪的FD,跟前俩种模式相比,也是将监听的FD和就绪的FD分开来,不再是一个数组进行保存
而提供的epoll_ctl函数,其中参数也很明了
也就是向这颗红黑树添加节点,并且设置callback函数进行监听,如果有准备就绪的FD,自动触发,这个对应的FD就加入链表当中
然后,就需要从这个链表上面取出数据了
调用epoll_wait函数,值得注意的是,在参数里面,设置一个空的数组,并且数组设置最大长度
有什么作用?
因为我们知道需要查询的FD已经在红黑树里面,所以只需要拷贝一份链表里面的FD到空数组里面,也就是说,在这个数组里面,一定是已经就绪的FD,也就减去了需要遍历找出哪些FD就绪的的时空消耗
整个流程如下
跟前俩种模式相比
- 不再是每一次都需要传所有要监听的FD,只需要一次创建eventpoll实例,在之后新加入的FD,调用event_ctl即可,而等待操作也是如此,大大减少了拷贝的次数
- 不再是需要将整个FD数组都拷贝回用户空间,也不需要再去遍历FD数组找到就绪的FD,直接返回的就是就绪的FD
- FD数量也没有上限,但是理论上来讲从性能角度来看,链表也不能太长
- 红黑树的性能不会随着FD增加而改变
4. 信号驱动IO
信号驱动IO表示在内核SIGIO建立信号关联回调,如果有FD就绪了,就会发出SIGIO信号通知用户,期间用户可以执行其他的业务,无需阻塞等待,但是就绪之后,还是需要调用recvform函数将数据拷贝回用户空间
问题来了,为什么信号驱动IO看起来那么的完美无缺,为什么没有广泛的使用呢?
假设一个场景,当产生大量的IO操作的时候,同一时间可能返回大量的信号到信号队列里面,那么极有可能回产生队列溢出,并发性能低
5. 异步IO
异步的概念运用的已经极其广泛,异步IO也是如此
在俩个阶段都是非阻塞
整个过程中用户只需要调用aio_read函数通知内核我想要哪些数据,然后就不管了
剩下的事情,等待数据,拷贝数据,全都交给内核去做,拷贝完了之后再通知用户数据已经就绪拷贝好
好比一个场景:
一个人想吃饭,给妈妈说要吃什么,妈妈全部给准备好,然后再通知这个人去吃饭
同样,在高并发的情况下,由于用户只需要调用aio_read,如果请求过多,内核的负担是很大的,需要进行限流
该篇内容的拓展推荐:
Redis 单线程与多线程模型详解