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,因为缓冲区满了

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐