高级IO:四种典型IO方式,多路转接IO
四种典型IO方式:
IO:输入输出(需要经历两个过程---等待IO就绪,进行数据拷贝)
典型IO:阻塞IO,非阻塞IO,信号驱动IO,异步IO
1.阻塞IO:发起IO调用,若IO未就绪(IO条件不具备)则一直等待
优点 :流程最为简单; 缺点:效率较为低下
2.非阻塞IO:发起IO调用,若IO未就绪,则立即报错返回
优点:效率相较于阻塞有所提高; 缺点:需要循环进行操作,不够实时
3.信号驱动:自定义IO就绪信号处理,等待IO就绪收到信号后打断当前操作进行IO
优点:效率更高; 缺点:操作流程更为复杂,需要定义信号处理
4.异步IO:自定义IO信号处理,发起IO调用,调用立即返回,但让系统完成IO,完成后通过信号通知进程
优点:对于资源利用率极高,效率极高; 缺点:流程最为复杂
从阻塞IO到异步IO是一个对资源利用率以及效率提高的过程,也是流程变得复杂的过程。
阻塞:为了完成某个功能,发起一个调用。若完成功能条件不具备,则一直等待
非阻塞:发起一个调用,若完成功能条件不具备,则立即报错返回
阻塞与非阻塞:通常用于描述某个接口发起调用后是否能够立即返回
同步:一个功能完成后,才能进行下一个,若不能立即完成则一直等待
异步:发起一个调用,让别人完成具体功能,不用等待功能完成后才能继续推进
异步阻塞与异步非阻塞:
异步阻塞:发起一个调用,让系统完成任务,进程一直等着系统完成任务
异步非阻塞:发起一个调用,让系统完成任务,进程继续做自己的事情
IO多路转接:IO多路复用
作用:针对大量描述符进行IO就绪事件监控,让进程仅仅针对已经就绪了的IO事件的描述符进行IO操作,避免了进程对未就绪的描述符进行操作所带来的性能损失或者阻塞。
实现:select、poll、epoll
IO就绪事件:可读、可写、异常
select模型:针对大量描述符进行IO就绪事件监控
操作流程:
1.定义指定IO事件的描述符集合,将需要监控指定事件的描述符添加到对应集合中
2.发起监控调用,将需要监控的事件描述符集合拷贝到内核,进行事件监控。若监控超时了都没有描述符就绪则返回,或者若有描述符就绪了指定监控的事件则返回。在监控调用返回前,都会将描述符集合中没有就绪事件的描述符移除,调用返回后,集合中保留的只有就绪的描述符。
3.判断哪个描述符还在哪个集合中,就知道哪个描述符就绪了什么事件,进而进行对应的IO操作。
接口认识:
1.定义集合:fd_set rfds, wfds, efds
2.清空集合:void FD_ZERO(fd_set* set)
3.将描述符添加到集合中:void FD_SET(int fd, fd_set* set)
4.发起监控调用:int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout)
nfds:所有集合中最大的描述符的值+1
readfds/writefds/exceptfds:可读,可写,异常,不监控则置NULL
timeout:监控超时等待时间,struct timeval{tv_usec, tv_sec},一直等待则置NULL,非阻塞等待则置0
返回值:大于0表示就绪的文件操作符个数,等于0表示超时了,小于0表示出错
5.调用返回后,判断哪个描述符还在集合中确定哪个描述符就绪了什么事件:int FD_ISSET(int fd, fd_set* set)
6.从指定集合中移除指定的描述符:void FD_CLR(int fd, fd_set* set)
封装一个select类向外提供简单接口完成对大量描述符的监控:
#include <iostream>
#include <vector>
#include <time.h>
#include <sys/select.h>
#include "tcpsocket.hpp"
class Select{
public:
Select()
:_max_fd(-1)
{
FD_ZERO(&_rfds);
}
bool Add(TcpSocket &sock){
//添加监控
int fd = sock.GetFd();
FD_SET(fd, &_rfds);
_max_fd = _max_fd > fd ? _max_fd : fd;
return true;
}
bool Del(TcpSocket &sock){
//移除监控
int fd = sock.GetFd();
FD_CLR(fd, &_rfds); //从集合中移除描述符
//重新判断最大的描述符
for(int i = _max_fd; i >= 0; i--)
{
if(FD_ISSET(i, &_rfds))
{
_max_fd = i;
break;
}
}
return true;
}
bool Wait(std::vector<TcpSocket> *arry){
//通过参数返回就绪的套接字数组
struct timeval tv;
tv.tv_sec = 3;
tv.tv_usec = 0;
fd_set tmp = _rfds;
int ret = select(_max_fd + 1, &tmp, NULL, NULL, &tv);
if(ret < 0)
{
perror("select error");
return false;
}
else if(ret == 0)
{
arry->clear();
return true;
}
for(int i = 0; i <= _max_fd; i++)
{
if(FD_ISSET(i, &tmp))
{
TcpSocket sock;
sock.SetFd(i);
arry->push_back(sock);
}
}
return true;
}
private:
fd_set _rfds; //需要监控的描述符集合---备份
int _max_fd; //保存当前集合中最大的描述符
};
多路转接模型是针对一个或多个描述符进行IO就绪事件监控的功能,通常应用于tcp服务器端,针对大量套接字描述符进行监控,让程序能够仅仅针对就绪的描述符进行操作,进而提高处理效率。而udp服务端大多针对单个套接字进行操作,大多数情况下也会用到多路转接模型,因为多路转接模型不但可以进行IO就绪事件监控,还可以进行超时控制。
select特性总结:
优点:跨平台移植性较好
缺点:
1.select所能监控的描述符有数量上限,上限取决于宏_FD_SETSIZE
2.select每次进行监控都要重新向集合中添加描述符(每次都会修改),并且每次都要重新将集合拷贝到内核
3.select监控原理是在内核中进行轮询遍历,性能随着描述符的增多而下降。
a.将集合中的描述符遍历一遍看看有没有就绪的
b.有就直接移除未就绪的返回,没有则挂起等待
c.有描述符就绪/超时后被唤醒,重新遍历一遍移除未就绪的后返回
4.select返回的是就绪集合,需要用户自己判断哪个描述符还在哪个集合中,才能确定哪个描述符就绪了哪个事件
poll:
接口认识:
int poll(struct pollfd* fds, nfds_t nfds, int timeout)
struct pollfd{
int fd; //要监控的描述符
short events; //fd描述符要监控的事件,POLLIN-读/POLLOUT-写
short revents; //监控调用返回后,用于记录实际就绪的事件
}
fds:描述符事件结构数组; nfds:数组中有效节点个数; timeout:超时时间
返回值:等于0表示超时; 小于0表示出错; 大于0表示有监控的事件就绪
操作流程:
1.定义事件结构体数组,为每个需要监控的描述符定义事件结构
2.发起监控调用,将数组中有效节点拷贝到内核进行监控,超时/就绪则调用返回,返回前将描述符实际就绪的事件记录到对应节点的revents成员中
3.调用返回后,遍历事件数组,通过每个节点的revents成员确定对应节点描述符是否就绪了某个事件
优缺点:
优点:
1.poll能够监控的描述符数量没有上限限制
2.代码操作流程相较于select更加简单
缺点:
1.跨平台移植性较差
2.监控原理依然是轮询遍历,性能会随着描述符的增多而下降
3.监控返回后依然需要遍历事件结构数组确定描述符是否就绪
epoll:Linux下最好用的多路转接模型
接口认识:
1.int epoll_create(int size)---在内核中创建epoll句柄
size:监控的数量上限,Linux2.6之后被忽略,大于0即可
返回值:成功返回描述符,失败返回-1
2.int epoll_ctl(int epfd, int op, int fd, struct epoll_event* ev)
epfd:epoll_create返回的epoll描述符
op:EPOLL_CTL_ADD / EPOLL_CTL_DEL / EPOLL_CTL_MOD
fd:针对op类型所操作的监控描述符
ev:针对fd描述符所定义的事件结构体
struct epoll_event{
uint32_t events; //要监控的事件,监控返回后实际就绪事件(EPOLLIN / EPOLLOUT / EPOLLET)
epoll_data_t {void* ptr; int fd; } data; //自定义信息,通常用于保存事件节点对应要监控的描述符
}
返回值:成功返回0;出错返回-1
3.int epoll_wait(int epfd, struct epoll_event* evs, int maxe, int timeout)
epfd:epoll描述符
evs:epoll_event数组首地址,用于保存就绪的描述符对应事件结构
maxe:通常为evs数组的节点个数,指定要获取的事件最大个数
timeout:超时时间(毫秒)
返回值:超时返回0;出错返回-1;有就绪返回就绪事件个数
操作流程:
1.在内核中创建epoll句柄结构
struct eventpoll{......rdllist---双向链表,rbr---红黑树}
rbr:用于保存要监控的描述符节点
rdllist:用于保存就绪的描述符对应事件结构
2.向内核epoll句柄结构中添加要监控的描述符以及对应事件结构
3.传入一个事件结构数组,开始监控,监控是一个异步阻塞操作
a.告诉系统开始监控,而描述符的监控由系统完成
b.系统为每个描述符的就绪事件做了一个事件回调函数,一旦某个描述符就绪了指定的事件,则会调用事件回调函数,将这个描述符对应的事件结构添加到就绪事件双向链表
c.epoll_wait接口每隔一段时间查看epoll句柄结构的rdllist就绪双向链表是否为空,就可以判断有没有描述符就绪,超时则直接返回,如果有就绪,则将就绪的事件结构信息拷贝到传入的数组中
d.监控调用返回后,只需要遍历evs数组,逐个对节点中的描述符进行对应事件的处理即可
epoll的事件触发方式:
IO事件的就绪:
可写:描述符的发送缓冲区中剩余空间大小大于低水位标记
可读:描述符的接收缓冲区中数据大小大于低水位标记
低水位标记:类似于一个基准值---默认1个字节
IO就绪事件的触发方式:水平触发---默认,边缘触发---epoll特有
水平触发:EPOLLLT---默认
可读:只要缓冲区中有数据就会触发可读事件
可写:只要缓冲区中有剩余空间就会触发可写事件
边缘触发:EPOLLET
可读:只有新数据到来的时候才会触发可读事件
可写:只有缓冲区从没有剩余空间变为有剩余空间才会触发可写事件
边缘触发可以提高任务处理效率:假设有个描述符有数据来了,但是数据不完整,这种情况下使用水平触发,如果不取出数据就会一直触发事件,而使用边缘触发则可以实现在有新数据到来的时候触发事件,查看数据是否完整(防止一种事件不断被触发,但是不太想去处理的场景)
边缘触发会导致的问题:边缘触发只有在新数据到来的时候才会触发事件,意味着在一次事件触发中就必须将需要处理的数据完全取出处理,因为在没有新数据到来的情况下不会再次触发事件去处理剩余的数据。
如何将缓冲区中的所有数据全部取出?
因为不知道缓冲区中有多少数据,因此只能循环进行读取,直到取不出数据为止,但是这样会出现一种情况,没有数据则继续读就会阻塞。因此边缘触发的IO必须使用非阻塞操作,循环读取到缓冲区中没有数据的时候就会报错返回。 errno=EAGAIN
int fcntl(int fd, int cmd, .../* arg */)
cmd:F_GETFL---获取描述符属性通过返回值返回,arg被忽略
F_SETFL---设置描述符属性,arg=O_NONBLOCK
int flag = fcntl(fd, F_GETFL, 0)
fcntl(fd, F_SETFL, flag | O_NONBLOCK)
封装一个epoll类向外提供简单接口完成对大量描述符的监控:
#include <iostream>
#include <vector>
#include <cstdlib>
#include <sys/epoll.h>
#include "tcpsocket.hpp"
class Epoll{
public:
Epoll()
:_epfd(-1)
{
_epfd = epoll_create(1);
if(_epfd < 0)
{
perror("epoll_create error");
exit(-1);
}
}
bool Add(TcpSocket& sock)
{
int fd = sock.GetFd();
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN | EPOLLET;
int ret = epoll_ctl(_epfd, EPOLL_CTL_ADD, fd, &ev);
if(ret < 0)
{
perror("epoll ctl add error");
return false;
}
return true;
}
bool Del(TcpSocket& sock)
{
int fd = sock.GetFd();
int ret = epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, NULL);
if(ret < 0)
{
perror("epoll ctl del error");
return false;
}
return true;
}
bool Wait(std::vector<TcpSocket>* arry)
{
struct epoll_event evs[10];
int ret = epoll_wait(_epfd, evs, 10, 3000);
if(ret < 0)
{
perror("epoll_wait error");
return false;
}
else if(ret == 0)
{
arry->clear();
std::cout << "timeout\n";
return true;
}
for(int i = 0; i < ret; i++)
{
if(evs[i].events & EPOLLIN)
{
TcpSocket sock;
sock.SetFd(evs[i].data.fd);
arry->push_back(sock);
}
}
return true;
}
private:
int _epfd;
};
epoll优缺点:
缺点:跨平台移植性较差
优点:
1.所能监控的描述符没有数量上限
2.描述符以及事件结构只需要向内核拷贝一次
3.监控原理采用异步阻塞,监控由系统完成,进程只需要判断就绪链表是否为NULL即可,性能不会随着描述符的增多而下降
4.直接返回的都是就绪的描述符对应事件结构,减少空遍历
多路转接模型:
适用于有大量描述符需要监控,但是同一时间只有少量活跃的场景。poll / select适用于单个描述符的超时控制,单个描述符的临时超时控制这里不适用于epoll。在实际应用中,多路转接模型通常搭配线程池一起使用,对大量描述符进行监控,就绪事件后则抛入线程池进行处理。