Linux----五种IO模型与非阻塞IO
这里写目录标题
1. IO的理解
1. IO:input,output
IO比较慢,为什么?为什么访问外设会比较慢?
网络通信不是网卡吗?也是与外设交互,以TCP为例,accept时会获得一个文件描述符,但是IO了,你想读就一定有数据吗?如果没有数据,进程就要阻塞,等数据来了才能把数据从接收缓冲区拷贝到应用层,当然write也要等待,传输层缓冲区不为满,才能继续写入
IO = 等+拷贝
外设让我们等的时间太久了,所以说IO比较慢
高效IO,什么叫高效IO呢?
单位时间内,减少IO中,等待的比重
2. 五种IO模型
以钓鱼的例子说明,这里的鱼要放到桶里
钓鱼 = 等 +调
1. 张三:专注钓鱼,鱼漂不动,张三不动 -> 阻塞IO
2. 李四:不会因为鱼没有上钩,卡在鱼漂上 -> 非阻塞IO
3. 王五:通过让鱼上钩反向通知我 -> 信号驱动IO
4. 赵六:拉了一卡车的鱼竿(50只鱼竿) -> 多路复用,多路转接
5. 田七:我是喜欢吃鱼,发起钓鱼,小李去钓鱼了 -> 异步IO
1. 张三:没有鱼就在那里一直等,除此之外什么都不做
2. 李四:没有鱼,可以做一些其它事情,边做事情,边等,等鱼漂动了,再放下手里的事,去钓
3. 王五:王五的鱼竿上绑了铃铛,王五一直在做其它的事,这个铃铛响了,他才去钓
4. 赵六:50只鱼竿都在钓鱼,哪个鱼漂动了,就去哪个鱼竿钓
5. 田七:田七是个大老板,让自己的下属小李去钓鱼,自己回家歇息
1. 钓:拷贝
6. 人物:进程
7. 河:OS
8. 桶:用户缓冲区
9. 鱼:数据
10.铃铛:信号
11. 鱼竿:文件描述符
1. 阻塞 vs非阻塞
阻塞:因为IO条件不具备,阻塞会卡住,直到条件就绪
非阻塞:检测到IO条件不具备,出错返回
不同:等待方式不同
非阻塞效率高?
如果这两个进程,只有一个文件描述符,只对一个文件进行读写,那它们的IO效率是一样的
如果多个文件肯定就是非阻塞效率高了,因为多个文件不可能同时都没数据
或者缓冲区内的数据都满了,非阻塞可以对其它文件进行IO
但是只对一个文件进行读写,也可以说非阻塞的效率高
但是这个效率高,表示的是非阻塞做了更多其它的事情,不是IO效率
这几个钓鱼佬,谁钓鱼的效率最高?
赵六,它只要抬头看就基本上会有鱼上钩,或者刚钓完一个鱼,另一个鱼竿的鱼漂又动了,单位时间内等的比重就会很低
王五有没有等待呢?
王五只是不需要检测鱼漂是否动了,因为鱼漂动了铃铛会响,通知他,但是他参与钓的过程
阻塞,非阻塞,信号驱动,多路复用 -> 同步IO
只要有人参与了IO不管是[拷贝,等],就是同步的
同步IO vs 异步IO
IO = 等+拷贝,凡是参与IO等或拷贝的任意一个,或多个阶段 -> 同步IO
否则,叫发起IO,或者IO工作流和你的工作流无关 -> 异步IO
这里的同步和线程同步没有任何关系
3. 非阻塞IO
⼀个文件描述符,默认都是阻塞IO
fcntl
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
1. 这个函数的作用可以把一个文件描述符设置为非阻塞

1. 使用F_GETFL将当前的文件描述符的属性取出来(这是一个位图),放到fl里面
2. 再使用F_SETFL将文件描述符设置回去
3. 设置回去的同时,加上⼀个O_NONBLOCK参数,表示把文件描述符设置为非阻塞
#include <iostream>
using namespace std;
#include <fcntl.h>
#include <unistd.h>
void SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main()
{
SetNonBlock(0);
char buffer[1024];
while (true)
{
ssize_t n = read(0, buffer, sizeof(buffer) - 1);
if (n > 0)
{
cout << buffer << endl;
}
else if (n < 0)
{
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
std::cout << "数据没有准备好..." << std::endl; // C++也有语言级输出缓冲区
sleep(1);
continue;
}
else if (errno == EINTR)
{
//因为收到信号可能会执行自定义捕捉方法,再回来时跳过read,导致进程不在read阻塞了,此时进程可能会退出,这里继续阻塞,不要退出
continue;
}
else
{
// read读取出错了
break;
}
}
else
{
// Linux中ctrl + d:标识输入结束,read返回值是0,类似读到文件结尾
break;
}
}
return 0;
}

这里的错误码会被设置,表示为什么出错
fcntl的返回值有三种情况
1. 如果返回值大于0,表示有数据,可以之间读取到
2. 如果返回值等于0,表示没有数据可读了,类似读到文件结尾,read直接退出,打印出n的结果是0
3. 如果返回值<0,并不代表read读取出错,分为三种情况,根据错误码被设置的不同,结果不同
4. select

1. IO = 等+ 拷贝
read/write/recv/send/recvfrom/sendto:这些接口一次等待传入一个fd
select:一次等待传入的多个fd
一旦多个fd,有任意一个或者多个fd就绪了,select会通知上层,告诉调用方,哪些fd可以IO了
select就是通过等待多个fd的一种就绪事件通知机制
什么叫可读?
底层有数据,读事件就绪
什么叫可写?
底层有空间,写事件就绪
对于fd,一般默认,读事件不就续,写事件是默认就绪的,因为最开始的fd,接收缓冲区和发送缓冲区都是空的
fd_set:内核提供用户的数据结构,一次可以向fd_set添加多个fd
这个数据结构是位图
用户发给内核
内核发给用户
表示你让我关心的0号和7号下标的fd已经可以IO了,可以IO比特位置为1
4.1 select参数
1. nfds:maxfd+1
比如:你添加了0,1,7这三个文件描述符,那么这个参数就应该设置为8
2. timout:时长
1. 设置为NULL:不设置时长,一直阻塞着,直到有fd就绪,通知上层,一直不退
2. 假设timout设置为5,0:设置时长为5s,和0微秒,timeout时间内,阻塞等待,超时就非阻塞返回
假设这里在2秒时有一个fd就绪了,此时返回值就是3秒,返回的是剩余时间的大小
3. 假设timeout设置为0,0:设置时长为0s,和0微秒,非阻塞,不管有没有数据就绪都直接返回
4.2 readfds:输入输出参数
1. 输入的时候:
1. 用户告诉内核,你要帮我关心哪些fd上的读事件
2. 比特位的位置,表示fd编号,比特位的内容,表示是否关心
2. 返回的时候:
1. 内核告诉用户,你让我关心的哪些fd上面的读事件已经就绪了
2. 比特位的位置,表示fd编号,比特位的内容表示是否就绪
1. 位图是输入输出的,所以这个位图一定会频繁的变更
3. 位图有多少个比特位,决定了select最多能关心多少个fd
fd_set是一个系统提供的数据类型(struct),fd_set大小固定,证明select能同时等待多少个fd有上限,我这里是1024个比特位
4. 如果把fd添加到readfds集合中,表示该fd,只关心读事件
这个只字告诉内核,你只需要帮我关心fd的读事件
1. 如果同时关心读写?
把fd添加到 readfds 和 writefds 里
2. 如果先关心读,再关心写?
先把fd添加到readfds,返回后,再把fd添加到writefds
3. 如果关心读,写,异常
把fd添加到readfds,writefds,和 exceptfds 里
4.3 select的返回值
1. 大于0:是几,就表示有fd就绪了
2. 等于0:超时了,在timout时间内,没有fd就绪
3. 小于0:select报错
1. 服务器刚启动时,默认只有一个fd
accept的本质是阻塞IO,accept关心的是listenfd的读事件
因为accept只是获取连接,相当于读取连接,就是listenfd的读事件
将listenfd添加到select函数中,让select帮我关心读事件
连接到来 == 读事件就绪
读事件没就绪之前:
1. 非阻塞,一直轮询
2. 阻塞设置了时间,就等间隔时间到了,再轮询
3. nullptr,一直阻塞,直到读事件就绪
void FD_CLR(int fd, fd_set *set); // 把fd从fd_set集合中移除,位图对应位设为0
int FD_ISSET(int fd, fd_set *set); // 判断fd是否在fd_set集合中
void FD_ZERO(fd_set *set);// 清空集合,把位图的所有比特位置0
void FD_SET(int fd, fd_set *set);//把fd加入fd_set集合,位图对应位位置1
select的特点
1. 可监控的文件描述符个数取决于sizeof(fd_set)的值,我这边服务器上sizeof(fd_set)=128
每bit表示一个文件描述符,我服务器上支持的最大文件描述符是128*8=1024
2. 将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd
1. 用于再select返回后,array作为源数据和fd_set进行FD_ISSET判断
2. select返回后会把以前加入的,但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(但是要先FD_ZERO),扫描array的同时取得fd最大值maxfd,用于select的第一个参数
3. 每次调用select,都需要把fd集合从用户态拷贝到内核态
select缺点
1. 每次调用select,都需要手动设置fd集合,从接口使用角度来说也非常不便
2. 每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大,内核也要遍历fd,否则怎么知道哪个文件描述符的读事件就绪呢?
3. select支持的文件描述符数量太小
5. poll
5.1 poll函数接口
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd结构
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
|=:只要有一个对应位为1,结果就是1
&=:两个对应位都为1,结果才是1
事件就是宏,一个整数
1. 第一个参数:可以理解为指向数组起始元素地址的指针
2. 第二个参数:表示数组的长度
3. 第三个参数:ms为单位,这是-1表示永久阻塞,剩下的和select一样
1. 结构体内第一个成员:fd
2. 结构体内第二个成员:位图结构,events|=POLLIN,表示用户告诉内核我要关心POLLIN事件
起始肯定要把events和revents都设置为0,然后events |= POLLIN
表示把对应的比特位设置为1,表示关心
3. 结构体内第三个成员:位图结构,revents |= POLLIN,内核告诉用户你要让我关心的fd上面的events事件就绪了
起始肯定要把events和revents都设置为0,然后revents &= POLLIN
表示内核是否把revents里POLLIN对应的比特位置为1了,如果revents里对应的比特位为1
它们&才为1,表示就绪
返回值小于0,表示出错
• 返回值等于0,表示poll函数等待超时
• 返回值大于0,表示poll由于监听的文件描述符就绪而返回
返回值和select一样

POLLIN和POLLOUT分别对应读写事件
1. poll的作用和定位:poll只负责等,一次可以等待多个fd,事件就绪,就可以对上层进行通知,跟select一样
2. 调用的时候,fd && events 有效:用户告诉内核,你要帮我关心,fd上的events事件
3. poll成功返回时,fd && revents有效:内核告诉用户,你要让我关心的events事件,已经就绪了
poll解决了select的什么问题?
1. poll输入输出参数分离了,所以poll不用再向select那样进行参数重置了
2. poll等待的fd个数没有上限
如果fd==-1,在内核中,内核不关心这类fd的events
poll的优点
不同于select使用三个位图来表示三个fdset的方式,poll使用一个pollfd的指针实现
1. pollfd结构包含了要监视的events和就绪的revents,不再使用select“参数-值”传递的方式,接使用比select更方便
poll并没有最大数量限制(但是数量过大后性能也是会下降)
poll的缺点
1. poll中监听的文件描述符数目增多时
和select函数⼀样,poll返回后,需要轮询pollfd来获取就绪的描述符(循环)
2. 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,
因此随着监视的描述符数量的增长,其效率也会线性下降,因为要循环
每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中(特点)
6. epoll
epoll核心定位:基于对多个fd等待的就绪事件通知机制,事件就绪,就可以对上层进行通知,跟select,poll一样
按照man手册的说法:是为处理大批量句柄而作了改进的poll
6.1 epoll的接口
1. 创建一个epoll模型
int epoll_create(int size);

1. 从linux2.6.8之后,size参数是被忽略的
2. 用完之后,必须调用close()关闭
3. 返回值是一个文件描述符
2. 用户告诉内核,你要帮我关心哪一个fd,上面的event事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
op:
1. EPOLL_CTL_ADD :注册新的fd到epfd中;
2. EPOLL_CTL_MOD :修改已经注册的fd的监听事件;
3. EPOLL_CTL_DEL :从epfd中删除⼀个fd

events可以是以下几个宏的集合:
1. EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
2. EPOLLOUT:表示对应的文件描述符可以写
3. EPOLLET:将EPOLL设为边缘触发模式
3. 内核通知用户:你让我关心的fd们,上面哪些事件已经就绪
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
返回值和timeout(ms)跟select和poll一样
events:理解为数组
maxevents:设置数组的大小
6.2 epoll的原理
1. 当我们调用 epoll_create时,OS会把我们创建
1. 红黑树
2. 就绪队列
3. 回调队列
调用epoll_ctl时,就是创建一个红黑树结点,然后把fd和events设置进去
一个数据结构对象可以属于多种数据结构
这个epoll_create的返回值是epfd,epfd可以找到文件描述符表,找到struct file,struct file内部有有一个private_data的指针,指向eventpoll,eventpoll内部有红黑树和就绪队列和等待队列,而红黑树和就绪队列的内部结点都是epitem,这个结构体里面维护红黑树和就绪队列的结构,所以为什么每个接口都要带epfd,就是为了找到就绪队列和红黑树,对他们进行操作


这个回调队列里的base指针可以指向每个红黑树结点,然后一旦fd就绪,
回调队列就执行回调方法,把红黑树结点连接到就绪队列里,回调方法在传输层的tcp和udp的socket里面有回调指针,在那里进行回调
然后epoll直接从就绪队列里,获取revents和fd
怎么看待就绪队列?
1. 就绪队列,epoll的本质就是一个基于事件就绪的生产者消费者模型,epoll接口是线程安全的,上图也可以看到内核源代码里有锁和信号量
2. 获取就绪事件,如果用户定义的缓冲区大小不够了怎么办?
没事,数据没拿完,就绪队列继续保存着,把你拿完数据的队列的结点删除,下次可以继续从就绪队列里拿还没拿完的数据
3. epoll_ctl完成的事情:
1. 在红黑树中插入节点
2. 向底层回调注册回调方法
OS会严格按照0下标开始,依次拷贝(就绪队列的数据到用户数组)
保存就绪事件和fd(保存在就绪队列里),就是把存0号fd的队列的就绪事件和fd,拷贝到0号数组下标内部
应用层,处理就绪事件的时候,都是OS严格按照文件描述符和下标对应拷贝的,处理的都是就绪的,基本不需要非法检测
6.3 epoll的优点(和select的缺点对应)
1. 接口使用方便:虽然拆分成了三个函数,但是反而使用起来更方便高效,不需要每次循环都设置关注的文件描述符,也做到了输入输出参数分离开
2. 数据拷贝轻量:只在合适的时候调用EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中,这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)
3. 事件回调机制:避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中,epoll_wait返回直接访问就绪队列就知道哪些文件描述符就绪,这个操作时间复杂度O(1),即使文件描述符数目很多,效率也不会受到影响
4. 没有数量限制:文件描述符数目无上限
7. poll vs select vs epoll (以读为例)
1. select支持的文件描述符数量太小,poll和epoll没有限制
2. 用户和内核都会修改,select的同一个读事件,
位图会变化,用户要关心01237这四个fd,1000 1111
OS告诉用户一个fd都没有就绪,把位图修改为全0
但是用户要继续关心01237这四个fd,1000 1111,
所以要重新循环遍历辅助数组,重新设置位图,和fd的最大值
2. poll的结构体的两个成员(short events, short revents),
不像select对同一个位图修改,所有poll不会出现这种问题
2. epoll:两个数据结构来完成的(红黑树,就绪队列),更不会出现这种问题
3. poll也就是解决了select的位图问题,还有fd不足问题,其它和select问题一样
4. select和poll都是用辅助数组存储fd的,
所以每次都要循环遍历数组中fd是否>0,数组默认设置的fd为-1,
而epoll用的内核红黑树存储,
直接调用EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中,无需遍历
5. epoll使用事件回调机制:避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中,然后把epoll_wait返回,直接访问就绪队列就知道哪些文件描述符就绪,这个操作时间复杂度O(1),即使文件描述符数目很多,效率也不会受到影响
而select和poll则需要遍历数组,找到数组为-1的值,把新获取的文件描述符覆盖掉-1,才可以
8. Reactor
epoll:如果事件就绪,但是我们不进行处理,epoll会一直通知我,为什么?
1. 如果有事件就绪,但是没有处理,或者是没来的急及处理,EPOLL模型会一直通知我们,我们把这个模式叫 LT模式
EPOLL模型的两种模式
1. LT模式:水平触发(默认)
2. ET模式:边缘触发
如何理解水平触发和边缘触发(以读为例)?
1. LT模式:只要传输层接收缓冲区有报文,就要一直通知上层,上层可以不读完,因为上层知道你下次还会通知上层
2. ET模式:
1. 传输层接收缓冲区只有从无到有,从有到多
2. 也就是从网络拿到的报文,导致传输层接收缓冲区数据变化的时候,才会通知上层
3. 即便上层把数据拿走了一部分,后来再也没有新增了,ET再也不通知上层了,倒逼上层必须收到通知,把本轮数据读完,否则连接一断,数据可能丢失
ET通知就绪,用户必须把缓冲区本轮的数据读取完,怎么读取完?循环读取,recv的时候,万一数据读完,因为用户并不清楚传输层接收缓冲区的数据已经读完了,recv还会被调用,此时recv会阻塞,如果只有一个进程的情况下,该进程就被放到阻塞队列里了,还要唤醒,效率太低,所以ET模式,必须将fd设置为非阻塞的工作模式
ET+非阻塞
1. LT模式rev一次需要被设置为非阻塞吗?
都可以
2. 不考虑recv返回值(实际读到字节的大小) < 期望值的情况,无脑循环直到EAGAIN
似乎LT也可以向ET那样,循环+非阻塞啊
ET是OS约束程序员,必须循环+非阻塞,否则数据可能丢失,必须循环+非阻塞,增加IO读写的确定性
为什么要保证一次读完这种确定性?
recv:本质是,为了应答时给对方在概率上,提供一个更大的缓冲区,提高TCP传输效率
底水位线:假设设置的值为100字节,那么接收缓冲区数据大小<100字节的时候,就不通知上层
PSH:psh仅仅是让fd就绪,而是ET模式逼着用户把数据全部读完,否则可能数据丢失
而LT没人约束程序员,导致可能会每个人实现LT的方式都不一样
为了保证统一性,LT一般就是阻塞读取
LT VS ET:谁更高效?
1. ET通知效率更高,有效通知数量更多
2. ET尽快读完所有的数据,可以给对方有概率的更新一个更大的16位窗口,提高对方滑动窗口大小,提高网络发送报文的并发度,和数据传输效率
1. 读事件关心需要进行常设,而写事件关心是需要按需设置,如果写事件常设了,
epoll就会一直写事件就绪
2. 写事件应该如何按需设置?如何发送?
直接发送,如果发送的时候,把发送缓冲区写满了(写事件不就绪),再把对写事件的关心设置到epoll内部
3. 手动开启对epollout的关心,默认就要触发一次写事件就绪,
不过错误码被设置,返回-1,因为缓冲区满了
更多推荐



所有评论(0)