为什么使用IO多路复用?
在进行套接字通信的时候有一些阻塞函数:accept,read/recv,write/send
-需要不停的检测新的客户端的连接:需要不停的调用accept,需要占用一个线程/进程进行检测
-和客服端的连接建立成功了,通信
-发送数据:write/send,如果写缓冲区被写满,阻塞 -> 需要一个单独的线程/进程处理
-接收数据:read/recv,对方不给当前终端发送数据,当前终端阻塞 -> 需要单独线程处理数据接收
总结:套接字通信过程中有大量的阻塞操作,需要多个线程/进程处理阻塞任务
// 细节分析:
1. accept为什么会阻塞:
使用accept读了用于监听的文件描述符对应的读缓冲区,检测过程是阻塞的
2. read/recv为什么会阻塞:
使用这两个函数检测了通信的文件描述符的读缓冲区,检测过程是阻塞的
3. write/send为什么会阻塞:
使用这两个函数检测了通信的文件描述符的写缓冲区,如果被写满了就一直阻塞
// 结论:
使用多线程/多进程处理并发,其实本质就是使用不同的线程/进程检测文件描述符的缓冲区
-文件描述符:
-通信的
-监听的
-缓冲区:
-读缓冲区
-写缓冲区
IO多路转接:就是委托内核,调用一个系统函数来委托内核帮助我们去检测程序中的一系列文件描述符的状态,内核检测完毕后会给用户一个反馈,通过这些反馈可以直到哪些文件描述符有状态变化,有针对性地对这些文件描述符进行处理
在处理有状态变化的文件描述符的时候:
1. 内核检测到有新连接,建立新连接,调用accept()函数,此时肯定就不会阻塞
2. 内核检测到通信的文件描述符读缓冲区有数据 ==> 对端给当前终端发送数据,需要使用 read()/recv() 接收数据,此时也不会阻塞
3. 内核检测到通信的文件描述符的写缓冲区可以写 ==> 可以使用 write()/send() 发送数据 ==> 也不会阻塞
select
主旨思想
-先构造一张有关文件描述符的列表, 将要监听的文件描述符添加到列表中
-调用一个函数,监听该表中的文件描述符,直到这些描述符表中的一个进行IO操作时,该函数才返回。
该函数为阻塞函数
函数对文件描述符的检测操作是由内核完成的
-在返回时,它告诉进程有多少描述符要进行IO操作
select如何实现IO多路复用
1. select是一个跨平台的函数,Linux和windows平台都可以使用
2. 调用这个函数,该函数会调用对应系统的系统API,委托操作系统去执行某些操作
3. 在调用select的时候需要通过参数的形式将要检测的文件描述符的集合传递给内核,内核根据这个集合进行文件描述符的状态检测
-读集合:要检测这一系列文件描述符的读缓冲区
-监听的文件描述符,看有没有新客户端连接
-通信的文件描述符,看有通信数据到达
-写集合:要检测的这一系列文件描述符写缓冲区是否可写
-主要是通信描述符
-异常集合:检测集合中文件描述符进行读写操作的时候是否有异常
4. 内核根据传递的集合中的数据,对文件描述符进行线性检测,如果有满足条件的文件描述符,内核会通知调用者
-满足条件是怎么回事?
-对于读集合:文件描述符对应的读缓冲区中有数据
-对于写集合:文件描述符的写缓冲区可写
-对于异常集合:读写操作出现了错误
-内核如何通知调用者?
-内核会将用户传递给内核的读/写/异常集合进行修改,得到新的数据
5. 最终用户得到的信息?
-知道委托内核检测的集合中一共有多少个文件描述符状态发生了变化
-通过检测内核传出的读/写/异常集合可以判断出是哪个文件描述符发生了状态变化
-
#include <sys/select.h>
-
int select(int nfds, fd_set* readfds, fd_set* writefds,
-
fd_set* excrptfds, struct timeval* timeout);
-
参数:
-
-nfds: 这个值是检测的读/写/异常集合中最大的文件描述符+1
-
-读集合要检测文件描述符:5 6 7 8
-
-写集合要检测文件描述符:9 10 11 12
-
-异常集合要检测: 5 6 8 11 12
-
-根据上表描述,nfds值 == 12 + 1
-
-内核遍历文件描述符表是线性遍历,nfds是遍历结束的标志
-
-readfds: 读集合,存储若干个文件描述符,并且都是检测他们的读缓冲区
-
-常用:
-
1.判断有没有新连接
-
2.判断有没有通信数据
-
-传入传出参数:
-
-传入的是委托内核检测的文件描述符的集合
-
-传出的是内核检测到的满足条件的文件描述符
-
传出的文件描述符个数<=传入的文件描述符的个数
-
-writefds: 写集合,读集合,存储若干个文件描述符,并且都是检测他们的写缓冲区是否可写,一般情况下都是可写的
-
-exceptfds: 异常的集合,检测集合的文件描述符有没有读写错误
-
一般情况下很好用
-
不检测指定为NULL
-
-timeout: 表示一个时间段
-
因为select在检测文件描述符集合的时候需要时间,默认如果没有满足条件的文件描述符函数会阻塞,如果timeout指定了一个时间段,并且在这个时间段中没有检测到有满足条件的文件描述符,函数结束阻塞
-
-如果指定为NULL, 在没有发现满足条件的文件描述符时 则 函数阻塞
-
-如果值为0,函数调用之后,马上返回
-
返回值:
-
>0: 检测完成之后,满足条件的文件描述符的总个数(三个集合的总和)
-
=0: 没有检测到满足条件的文件描述符且超时时间到了,强制函数返回
-
<0: 函数调用失败
-
// fd_set类型数据操作函数
-
// 将文件描述符fd 从 set 集合中删除
-
void FD_CLR(int fd, fd_set* set);
-
// 判断文件描述fd 是不是在 set 集合中,如果在返回1,如果不在返回0
-
int FD_ISSET(int fd, fd_set* set);
-
// 将文件描述符fd 添加到set集合中
-
void FD_SET(int fd, fd_set* set);
-
// 清空set集合中设置的所有数值
-
void FD_ZERO(fd_set* set);
/*
fd_Set数据类型和文件描述符表有什么关系:
-所有的文件描述符都存储在文件描述符表中 -> 内核中
-默认大小: 1024个整数,数组下标:0-1023
-fd_set 记录着要委托内核检测哪一个文件描述符:
-如何记录的?
sizeof(fe_set) = 128 * 8 = 1024 bit
从低地址位 -> 高地址位: 0-1023
-结论:
fd_set中的每一个标志位 和 文件描述符中的元素下标值一一对应
*/
伪代码:
-
int main()
-
{
-
// 1. 创建监听的套接字
-
int lfd = socket();
-
// 2. 绑定
-
bind();
-
// 3. 设置监听
-
listen();
-
// 4. 初始化要检测的文件描述符集合
-
fd_set reads;
-
FD_ZERO(&reads);
-
FD_SET(lfd, &reads); // 将lfd添加到检测读的集合中
-
// 5. 使用select()函数检测集合中文件描述符的状态
-
// 如果要知道文件描述符持续的状态变化,就需要不停的检测
-
int nfds = lfds;
-
while (1)
-
{
-
// reads传入的时候代表委托内核检测读集合,调用完毕后,记录的是读缓冲区中有数据的文件描述符
-
int num = select(nfds + 1, &reads, NULL, NULL, NULL);
-
// 遍历文件描述符表, i 就是文件描述符中各个文件描述符的值
-
for (int i = 0; i <= nfds; ++i)
-
{
-
// 判断有没有新连接
-
if (FD_ISSET(lfd, &reads))
-
{
-
// 建立新连接,得到通信的cfd
-
int cfd = accept(lfd, NULL, NULL);
-
// 将cfd添加到检测到的读集合中,在下次调用select的时候就可以检测它的读事件了
-
FD_SET(cfd, &reads);
-
// 更新最大的文件描述符
-
nfds = max(nfds, cfd);
-
}
-
// 判断有没有通信的数据
-
{
-
// 除了lfd,其余的文件描述符都是通信的,说明有客户端连接
-
if (FD_ISSET(i, &reads))
-
{
-
// 接收数据
-
int len = read(i, buf, sizeof(buf));
-
if (len == 0)
-
{
-
// 说明客户端断开连接了
-
// 需要从检测的集会中删除
-
FD_CLR(i, &reads);
-
close(i);
-
}
-
// 处理数据的业务...
-
}
-
}
-
}
-
}
-
return 0;
-
}
poll
这里对poll做简单描述,因为select支持windows和Linux系统,poll是对select的一些缺点的改进,但是poll只能在Linux系统中使用,而我们后面会学到epoll也可以在Linux中使用且使用另一种模型 ,效率比poll快,所以一般情况下很少使用poll
select最大并发上限时1024,poll无上限(和内存有关)
-poll的检测方式与select相同,线性检测
-
#include <poll.h>
-
struct pollfd {
-
int fd;
-
short events; // requested events
-
short revents; // returned events
-
}
-
参数:
-
-fd: 委托内核检测的文件描述符,在select中是由某个标志位来存储的
-
-events: fd期待的事件
-
-POLLIN: 检测fd的缓冲区数据读事件
-
-POLLOUT: 检测fd对应的写缓冲区数据写事件
-
-同时检测读写事件: events = POLLIN | POLLOUT
-
-revents: 给内核使用的变量,内核通过此变量返回实际发生的事件
-
int poll(struct pollfd* fds, nfds_t nfds, int timeout);
-
参数:
-
-fds: 传递struct pollfd类型的数组地址,要检测的文件描述符的集合
-
-nfds: 数组中最大的有效元素的下标,遍历数组用的
-
-timeout: 超时时长
-
// poll函数默认是阻塞的,该函数可以检测一系列文件描述符状态
-
-没有状态变化,一直阻塞,有状态变化解除阻塞
-
-没有状态变化,超时时间到达了,解除阻塞了
-
- -1, 如果没有状态变化一直阻塞
epoll
与其他两种方式的对比
不支持跨平台,只能在linux中使用
检测方式: 树状(红黑树)模型,检测效率较高
select: 会发生多次数据的拷贝
-给内核传入时 -> 用户区拷贝到内核区
-内核检测完传出时 -> 内核拷贝到用户区
epoll: 委托epoll检测的文件描述符集合用户和内核使用的是同一块内存,无数据拷贝
select: 返回时 只能知道有多少, 而epoll可以精确的知道哪个发生了状态的变化
epoll的使用方式
epoll的使用方式
epoll是一个树状模型,使用epoll需要调用三个函数
基本步骤:
1. 需要创建一个树状模型,此时树上是没有节点的
2. 需要往epoll检测树上添加要检测的节点
从文件描述符的类型:
-监听的
-通信的
从检测的事件上:
-读 / 写 / 异常
3. 开始委托内核对树上的节点进行检测
4. 处理的过程:
-监听的: 建立的新连接
-通信的:接受和发送数据
-
#include <sys/epoll.h>
-
// 创建一个epoll模型,红黑树的模型
-
int epoll_create(int size);
-
参数:
-
-size: 无实际意义,但需要大于0
-
返回值:
-
-成功:返回一个有效的文件描述符,可以理解为红黑树的根节点,通过这个文件描述符可以访问创建的实例
-
-失败: -1
-
typedef union epoll_data {
-
void *ptr;
-
int fd; // 常用的一个成员
-
uint32_t u32;
-
uint64_t u64;
-
} epoll_data_t;
-
struct epoll_event {
-
uint32_t events; // Epoll events
-
epoll_data_t data;
-
}
-
-events:
-
-EPOLLIN: 读事件,检测文件描述符的读缓冲区
-
-EPOLLOUT: 写事件,检测写缓冲区是否可写
-
-data,fd = epoll_ctl() 的第三个参数的fd
-
// 对epoll树的操作函数,可以实现对epoll模型上的节点的 添加 删除 修改
-
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
-
参数:
-
-epfd: epoll_create()的返回值,可以找到epoll的实例了
-
-fd: 要操作的文件描述符
-
-种类:
-
-监听的
-
-通信的
-
-event: fd对应的事件
-
-op: 有以下选项:
-
-EPOLL_CTL_ADD: 添加新节点fd到epfd的epoll树上
-
-EPOLL_CTL_MOD: 修改已经存在的节点
-
-比如,原来检测的是读时间,现在可以修改为写事件(通过*event)
-
-EPOLL_CTL_DEL: 删除fd在epfd的epoll树上的实例
-
-event:
-
-添加: 需要检测文件描述符的什么事件
-
-修改: 修改文件描述符的事件
-
-修改: NULL
-
// epoll检测函数
-
// 默认是一个阻塞函数,委托内核检测epoll树上的文件描述符的状态
-
// 如果没有状态变化,该函数默认一直阻塞
-
// 检测到满足条件的文件描述符,函数返回
-
int epoll_wait(int epfd, struct epoll_event* events, int maxEvents, int timeout);
-
参数:
-
-epfd: epoll_create()的返回值,可以找到epoll树的实例
-
-events: 这是一个传出参数,与epoll_ctl()的event不同,传出参数记录的是当前这轮检测捕捉到的有状态变化的文件描述符信息,它是一系列的,是结构体数组的地址,如果检测到10个有变化,那么会写入到此结构体数组的前十个中(如果有位置的话)
-
-maxEvents: 记录了events数组的最大容量
-
-timeout: 超时时长,单位ms,与poll相同
-
- -1: 委托内核检测epoll树上的文件描述符的状态 如果没有状态变化,该函数默认一直阻塞
-
- 0: 调用后马上返回
-
- >0: 设置的超时时间,时间到达前阻塞,到达后就解除阻塞 返回
-
-返回值:
-
-成功: 有多少文件描述符发生了状态变化
伪代码:
-
int main()
-
{
-
// 1. 创建监听的套接字
-
int lfd = socket();
-
bind();
-
listen();
-
// 创建epoll模型
-
int epfd = epoll.create();
-
// 设置lfd的监听事件(其实也就是读事件)
-
struct epoll_event ev;
-
ev.events = EPOLLIN;
-
ev.data.fd = lfd;
-
// 将需要检测的文件描述符添加到epoll模型中
-
epoll_ctl(epfd, epoll_ctl_add, lfd, &ev);
-
// 开始检测
-
struct epoll_event events[1024];
-
while (1)
-
{
-
int num = epoll_wait(epfd, events, size, -1);
-
// 对状态发生变化的文件描述符进行处理
-
for (int i = 0; i < num; ++i)
-
{
-
// 可以做严谨判断 根据具体业务来
-
if (events[i].revents & EPOLLOUT)
-
{
-
continue;
-
}
-
// 文件描述符要么是通信的要么是监听的
-
// 返回的num个文件描述符都在events数组中前num个
-
int curfd = events[i].data.fd;
-
if (curfd = lfd)
-
{
-
// 得到建立连接的对端fd
-
int cfd = accept(lfd, NULL, NULL);
-
// 将对端fd添加到epoll树中
-
ev.fd = cfd;
-
ev.events = EPOLLIN;
-
epoll_ctl(epfd, EPOLLIN_CTL_ADD, cfd, &ev);
-
}
-
else
-
{
-
// 通信
-
int len = recv(curfd, buf, size, -1);
-
if (len == 0)
-
{
-
// 说明客户端断开连接
-
// 要把curfd从epoll树上删除
-
epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
-
close(curfd);
-
}
-
else if (len == -1)
-
{
-
// 异常
-
}
-
else
-
{
-
// 对数据的处理...
-
// 下面的举例是 发送
-
// 根据具体业务来
-
send();
-
}
-
}
-
}
-
}
-
}
epoll的两种工作模式
水平模式
-水平模式(LT, level triggered):默认是水平模式
-阻塞和非阻塞的套接字都是支持的(主要是接收和写数据是否是阻塞)
-read/recv
-write/send 都默认阻塞
-工作特点: 检测频率高,效率低些
场景:客户端给服务器发送数据,每次发送1k数据,服务器使用epoll检测
检测到缓冲区中有数据,每次接受500字节
发送的快,接受的慢
-读事件:
接收端接收数据的量少,接受一次数据包接受不完,还有500字节在读缓冲区中
在这种场景下,只要是 epoll_wait() 检测到读缓冲区有数据就会通知用户一次
-不管数据有没有读完,只要有数据就通知
-通知就是 epoll_wait() 函数返回,我们就可以处理传出参数中的文件描述符的状态
-写事件:
检测写缓冲区是否可用,只要可写(有容量) epoll_wait()就返回
-一般情况下我们不对写缓冲区做处理,因为一般情况下写缓冲区都可以写
边沿模式
-边沿模式(ET, edge-triggered): 需要手动设置,效率高(通知的次数少)
-只支持非阻塞
-工作特点: epoll_wait()检测次数少了,效率就高了
场景:客户端给服务器发送数据,每次发送1k数据,服务器使用epoll检测
检测到缓冲区中有数据,每次接受500字节
发送的快,接受的慢
-读事件:
接收端每次收到一条 新的 数据, epollwait()就会通知一次
-如果这一次通知后,没有将缓存中的数据全部读出,epoll_wait()也不会通知
-接收到新的数据,epoll_wait()只通知一次,不管数据有没有读完
-写事件:
检测写缓冲区是否可用
-检测到可用通知一次,再检测到缓冲区可用就不通知了
-写缓冲原来是不可用,后来可用,epoll_wait()检测到就通知一次,后面不会通知了
如何设置边沿模式
// 在epoll模型上添加新节点
int cfd = accept(lfd, NULL, NULL)
// cfd 添加到检测的原始集合中
ev.events = EPOLLIN | EPOLLET; // 设置文件描述符的边沿模式
ev.data.fd = cfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
边沿模式存在的一些问题
通过测试 如果 epoll_wait()只通知一次,并且接收端接收数据的缓存比较小,导致服务器端通信的文件描述符中的数据越来越多,数据如果不能全部读出,就无法处理客户端请求,如何解决这个问题?
// 解决方案: 在epoll_wait() 通知的这一次中,将客户端发送的数据全部读出
-方案一:接收端(服务器端)准备一个特别大的内存块,用来存储待接收的数据
弊端:1. 多大算大?数据的大小不可预期,导致我们不知道如何确定内存块的大小上限
2. 向操作系统申请的内存太大的话,申请内存的操作会失败
故一般不使用此方案,在一般情况下主要使用方案二
-方案二: 循环进行数据接收
while(1)
{
int len = read(cfd, buf, sizeof(buf));
// 读完之后需要跳出循环
// 如果客户端和服务器的连接还保持着,如果数据接收完毕,read函数阻塞
// read一旦阻塞 整个服务器端程序(单线程/单进程)也会阻塞
// 故第二种方案还是有些问题
}
-解决上述问题: 需要把读数据的操作修改为非阻塞
-read()/recv(), write()/send()的阻塞是函数行为还是文件描述符的行为?文件描述符的行为,与函数是没关系的,这些函数都是去检测文件描述符的读写缓冲区,是文件描述符导致的,所以去修改文件描述符的属性,把文件描述符设置为非阻塞
-
设置文件描述符的非阻塞:
-
// 使用fcntl函数设置文件描述符的非阻塞
-
#include <fcntl.h>
-
int fcntl(int fd, int cmd, ... )
-
// 因为文件描述符行为默认是阻塞的,因此要追加非阻塞行为
-
// 获取文件描述符的flag属性
-
int flag = fcntl(cfd, F_GETFL);
-
// 给flag追加非阻塞
-
flag = flag | O_NONBLOCK;
-
// 把新的flag属性设置到文件描述符中
-
fcntl(cfd, F_SETFL, flag);
-
非阻塞模式下 一直不停的读,如何判断数据读完了?
-
// 在非阻塞模式下读数据可能遇到的错误:
-
// recv error : Resource temporayily unavilable -> 资源不可用
-
// 原因: 循环的读数据,当通信的文件描述符对应的读缓冲区数据被读完, recv/read不会阻塞,继续读缓冲区,但是缓冲区中没有数据,这是read/recv调用失败返回-1,这时候错误号error = EAGAIN or EWOULDBOLCK, 一般情况下使用EAGAIN判断就可以了
-
// 比如:
-
// 循环读数据
-
while (1)
-
{
-
// 此处逻辑代码省略...
-
if (erron == EAGAIN)
-
{
-
cout << "数据读完了" << endl; // 这种错误是正常现象
-
break; // 直接break掉循环读数据的过程
-
}
-
else
-
{
-
// 这里说明是其他错误,出现问题
-
error("recv error");
-
exit(0);
-
}
-
}