高级IO:五种典型的IO模型
常见概念
IO的过程
:1. 发起IO调用,等待IO条件就绪 2. 数据拷贝,IO过程中花费时间最多的是等待过程IO事件
:可读事件,可写事件,异常事件阻塞
:为了完成一个功能,发起调用,若当前不具备完成条件,则一直等待非阻塞
:为了完成一个功能,发起调用,若当前不具备完成条件,则立即报错返回同步
:处理流程中,顺序处理,一个完成之后再完成下一个,因为所有功能都由进程自身完成异步
:处理流程中,顺序不定,因为功能都由操作系统完成异步阻塞
:功能由别人完成,调用中等待别人完成异步非阻塞
:功能由别人完成,调用是立即返回的
问到同步的时候,一定要根据场景回答,到底是同步互斥的同步还是同步异步的同步
四种IO模型
阻塞IO
为了完成IO发起调用,若当前不具备IO条件,则一直等待
- 流程简单并且清晰明了,一个IO完毕后才能进行下一个
- 对于CPU没有充分利用,大部分事件都在等待
非阻塞IO
为了完成IO,发起调用,若当前不具备IO条件,则立即返回(通常干点其他事情,其他事情处理完后再循环重新发起IO)
- 流程相对于阻塞操作来说,相对复杂一点
- 对于资源的利用更加充分
- IO操作不够实时
信号驱动IO
定义一个IO信号处理方式,在处理方式中进行IO操作,IO就绪时信号通知进程,进程在IO就绪后就去进行IO
-
流程更加复杂
-
IO更加实时
-
对资源的利用更加充分
异步IO
通过异步IO调用告诉操作系统,IO哪些数据拷贝到哪里去,IO的等待以及拷贝过程都由操作系统完成,IO过程完成之后,通过定义的IO完成信号通知用户IO已完成,然后对数据进行处理
-
流程更加复杂
-
对资源利用更加充分
多路转接模型
对大量的描述符集中进行IO事件监控,可以告诉进程现在有哪些描述符就绪了哪些事件,然后程序员就可以直接只针对就绪了对应事件的描述符进行相应操作即可,避免了对没有就绪的描述符进行IO操作所导致的效率降低或者流程阻塞
最典型的就是基本的TCP服务器程序,一个执行流中,既有accept,也有recv/send,每种操作都可能在不满足条件的时候阻塞,若在大量的描述符中对一个没有就绪的描述符进行操作(对没有新连接的监听套接字调用accept或者对没有数据到来的新的通信套接字recv)都会导致流程阻塞,其他的描述符就算就绪了,也无法操作
select
操作流程
:
-
程序员定义某个事件(可读/可写/异常)的描述符集合,初始化清空集合,对哪个描述符关心什么事件,就把这个描述符添加到相应事件的描述符集合中
-
发起监控调用,将集合拷贝到内核中进行监控,监控的原理是轮询遍历判断,可读事件就绪:接收缓冲区中数据的大小大于低水位标记(通常默认为1个字节),可写事件就绪:发送缓冲区中剩余空间的大小大于低水位标记(通常默认为1个字节),异常事件就绪:描述符是否产生某个异常
-
监控调用返回,表示监控出错或者有描述符就绪或者监控等待超时了,并且调用返回的时候将事件监控的描述符集合中的未就绪的描述符从集合中移除,集合中仅保留就绪的描述符—所以下一次监听的时候需要重新往集合中添加关心描述符,然后拷贝给内核
-
select返回的是描述符的集合并不是直接返回就绪描述符集合,所以程序员还需要轮询判断哪个描述符在哪个集合中,就知道了哪个描述符就绪了哪个事件,然后进行对应事件的处理
代码操作
:
-
定义集合 – struct fd_set 成员只有一个数组,当作二进制位图使用,添加描述符就是将描述符的值对应的比特位置1,因此select能够监控的描述符数量取决于二进制位图的比特位的个数 – 由一个宏定义 _FD_SETSIZE,默认等于1024,
void FD_ZERO(fd_set *set) 初始化清空集合set
void FD_SET(int fd,fd_set *set)将fd描述符添加到set集合中
-
开启监控调用
int select(int nfds,fd_set * readfds,fd_set* writefds,fd_set* exceptfds,struct timeval* timeout)
nfds:当前监控的集合中最大的描述符+1,减少内核遍历次数
readfds:读事件描述符集合
writefds:写事件描述符集合
exceptfds:异常事件描述符集合
timeout:时间结构体,包含两个成员,一个是秒,一个是微秒,通过这个时间决定select阻塞或者非阻塞或者限制超时的时间的阻塞,若timeout为NULL,则表示阻塞监控,直到有描述符就绪才会返回,若timeout中的成员数据为0,则表示非阻塞,监控的时候若没有描述符就绪则立即超时返回,若timeout中的成员不全为0,则表示超过该时间后超时返回
select返回值大于0表示就绪描述符的个数,返回值等于0表示没有描述符就绪,超时返回,返回值小于0表示监控出错
-
调用返回,返回给程序员就绪的描述符集合,程序员遍历判断哪个描述符还在哪个集合中,就是哪个描述符就绪了哪个事件
int FD_ISSET(int fd,fd_set *set),判断fd描述符是否在集合set中
因为select返回时都会修改集合,因此每次监控的时候都要重新给集合添加描述符
-
若对描述符不想进行监控了,则从集合中移除描述符
void FD_CLR(int fd,fd_set *set),把fd描述符从集合set中删除
select优缺点
缺点
- select对描述符进行监控有最大数量上限,上限取决于一个宏定义 - FD_SETSIZE,默认大小为1024
- 在内核中进行监控是通过轮询遍历判断实现的,性能会随着描述符的增多下降
- 只能返回就绪的集合,需要进程进行轮询遍历判断才能知道哪个描述符就绪了哪个事件
- 每次监控都需要重新添加描述符到集合中,每次监控都需要将集合重新拷贝到内核中
优点
- 遵循posix标准,跨平台移植性比较好
poll
操作流程
:
- 定义监控的描述符事件结构体数组,将需要监控的描述符以及事件标识信息,添加到数组的各个节点中
- 发起调用开始监控,将描述符事件结构体数组拷贝到内核中进行轮询遍历,若有序/超时则调用返回,并且在每个描述符对应的事件结构体中,标识当前就绪的事件
- 进程轮询遍历数组,判断数组中的每个节点中的就绪事件是哪个事件,决定关心事件是否就绪了以及如何对描述符进行操作
接口认识
:
int poll(struct pollfd* arry_fds,int max_evevts,int timeout)
arry_fds:事件结构体数组,填充要监控的描述符以及事件信息
nfds:数组中的有效节点个数(数组可能会很大,但是只用监控前nfds个)
timeout:监控的超时等待时间–单位为毫秒
返回值:返回值大于0表示就绪的描述符事件个数;返回值等于0表示等待超时;返回值小于0表示监控出错
poll监控采用事件结构体的形式
struct pollfd{
int fd; 监控的描述符
short events; 监控的事件,POLLIN/POLLOUT
short revents; 调用返回时填充的就绪事件
};
poll优缺点
优点
- 使用事件结构体进行监控,简化了select中的三种事件集合的操作
- 监控的描述符数量不做最大数量限制
- 不需要每次重新定义事件节点
缺点
- 跨平台移植性比较差
- 每次监控依然要向内核中拷贝数据
- 在内核中监控依然采用轮询遍历,性能会随着描述符的增多而下降
epoll
LINUX下最好用的性能最高的多路转接模型
操作流程
:
- 在内核中创建epoll句柄epollevent结构体(这个结构体中包含很多信息,红黑树+双向链表…)
- 对内核中的epollevent结构添加/删除/修改所监控的描述符监控信息
- 发起调用开始监控,在内核中采用异步阻塞操作实现监控,等待超时/有描述符就绪了调用就返回,直接返回给用户就绪描述符的事件集合
- 进程直接对就绪的事件结构体中的描述符成员操作即可
接口认识
:
-
int epoll_create(int size) – 创建epoll句柄,size在LINUX2.6.2之后被忽略,只要大于0就行
返回值:文件描述符–epoll的操作句柄
-
int epoll_ctl(int epfd,int cmd,int fd,struct epoll_event* ev);
struct epoll_event{
uint32_t events;//对fd描述符要监控的事件 - EPOLLIN / EPOLLOUT
union{
int fd;
void *ptr;
}data; //保存要填充的描述符信息
}
epfd:epoll_create返回的操作句柄
cmd:针对fd描述符的监控信息要进行的操作–添加/删除/修改 EPOLL_CTL_ADD / EPOLL_CTL_DEL / EPOLL_CTL_MOD
fd:要监控操作的描述符
ev:fd描述符对应的事件结构体信息
-
int epoll_wait(int epfd,struct epoll_event* evs,int max_event,int timeout)
epfd:epoll操作句柄
evs:是一个数组的首地址,用于接收就绪的描述符对应的事件结构体信息
max_event:本次监控想要获取的就绪事件的最大数量,不大于evs数组的节点个数–用于防止监控10个描述符,但是evs只是一个5个大小的数组,防止访问越界
timeout:等待超时时间,单位为毫秒
返回值:返回值大于0表示就绪的事件个数,返回值等于0表示等待超时,返回值小于0表示监控出错
epoll的监控原理:异步阻塞操作
监控由系统完成,用户添加监控的描述符以及对应事件结构体会被添加到内核的eventpoll结构体中的红黑树中,一旦发起调用开始监控,则进程操作系统为每个描述符就绪的事件做了一个回调函数,这个回调函数的功能是当描述符就绪了关心的事件,则将描述符对应的事件结构体添加到双向链表中
进程自身只是每隔一段时间判断双向链表是否为空决定是否有就绪描述符产生
select与poll只支持水平触发方式,而epoll还支持边缘触发方式
水平触发方式:EPOLLLT
可读事件
:接收缓冲区中数据大小大于低水位标记,就会触发可读事件
可写事件
:发送缓冲区中剩余空间大小大于低水位标记,就会触发可写事件
低水位标记
:基准衡量值,默认为1个字节
边缘触发方式:EPOLLET
可读事件
:只有新数据到来的时候才会触发一次事件
可写事件
:发送缓冲区中剩余空间,从无到有的时候才会触发一次事件
边缘触发,因为触发方式的不同,因此要求进程中事件触发进行数据接收的时候,要求最好能够一次性将全部的数据都读取(因为剩余的数据不会触发事件,只有新数据到来的时候才会触发)
然而循环读取能够保证读完缓冲区中的所有数据,但在没有数据的时候就会造成阻塞,因此边缘触发方式中,描述符的操作都采用非阻塞操作
非阻塞的描述符操作在没有数据or超时的情况下会报错返回:EAGAIN or EWOULDBLOCK
如何将描述符设置为非阻塞?
int fcntl(int fd,int cmd,…/* arg */); – 获取或者设置一个描述符的属性信息
fd:指定描述符
cmd:F_GETFL / F_SETFL – 获取/设置一个描述符的属性信息,
arg:要设置的属性信息 / 获取的属性信息 F_GETFL使用的时候,arg被忽略,默认设置0即可,O_NONBLOCK(非阻塞属性)
边缘触发主要是为了防止一些事件不断触发(接收数据后,缓冲区中还有半条)
epoll优缺点:
优点
- 没有描述符监控数量上限
- 监控信息只需要向内核添加一次
- 监控使用异步阻塞操作完成,性能不会随着描述符的增多而下降
- 直接向用户返回就绪的事件信息
缺点
- 跨平台移植性比较差
epoll的适用场景:需要对大量描述符进行监控,但是同一时间只有少量描述符活跃
多路转接使用场景:只要对描述符有事件(可读/可写/异常)监控的需求就可以使用多路转接模型
理解多路转接模型的并发与多线程多进程的并发两者的不同之处,一个是用户层面的并发,一个是进程方面并发
多路转接模型与线程池搭配使用就可以在进程层面也实现并发