高级IO介绍
五种典序的IO模型
-
1.阻塞IO
发起 IO 调用,若不具备 IO 条件,则一直阻塞等待;流程简单,顺序化,对资源利用不充分。 -
2.非阻塞IO
循环发起 IO 调用,若不具备 IO 条件,则立即返回;需要循环执行,流程稍微复杂,对资源利用稍有提高,但不够实时。 -
3.信号驱动IO
定义 IO 就绪信号处理,收到该信号后立即发起 IO 调用;IO 比较实时,对资源利用更加充分,但流程也更加复杂。 -
4.IO复用
多个的进程的IO可以注册到一个复用器(select/poll/epoll)上,然后用一个进程调用该select/poll/epoll, select/poll/epoll会监听所有注册进来的IO。能专一进程解决多个进程IO的阻塞问题,性能好但是实现、开发应用难度较大,适用高并发服务应用开发,能做到一个进程(线程)响应多个请求 -
5.异步IO
定义 IO 完成信号处理,发起异步操作,IO 的过程交给系统完成;效率最高,资源利用最为充分,流程最为复杂。
前四种 IO 都为同步 IO
基本概念解释
- 阻塞:为了完成某一功能,发起调用,若不具备完成条件则调用一直等待。
- 非阻塞:为了完成某一功能,发起调用,若不具备完成条件则立即返回。
- 同步:任务顺序化完成(一个任务完成后才能进行下一个),并且由进程自己完成。(相较异步,同步流程简单,同一时间资源占用的少)
- 异步:任务非顺序化完成,并且由系统完成(进程只是发起调用,具体什么时候开始和完成都是由系统决定的),处理效率高。(相较同步,异步流程复杂,同一时间资源占用的多)
多路转接/复用
概念
多路复用是指以同一传输媒质(线路)承载多路信号进行通信的方式。各路信号在送往传输媒质以前,需按一定的规则进行调制,以利于各路已调信号在媒质中传输,并不致混淆,从而在传到对方时使信号具有足够能量,且可用反调制的方法加以区分、恢复成原信号。
作用:
用于针对大量描述符进行 IO 就绪事件的集中监控,让我们的进程能够针对就绪的描述符进行操作,避免了对没有就绪的描述符进行操作而导致的阻塞,同时也避免了对大量没有就绪的描述符进行操作而带来的效率低下。
1.select (高并发服务器的实现模型)
具体流程
- 定义指定监控事件的描述符集合,方便监视集合中的描述符(有3个监控事件的描述符集合,并非每一个描述符都需要监控所有的事件;有可读事件集合,可写事件集合,异常事件集合)。
- 初始化描述符集合。
- 向描述符集合中添加要监视的描述符(监控哪一个事件就添加入哪一个事件的集合)。
- 发起调用,将集合中的内容拷贝到内核中,由内核进行轮循判断来实现对集合中各个描述符的监控,一旦有描述符就绪或者等待超时则返回。(select 在返回的时候,会把三个集合中所有未曾就绪的描述符从该集合中移除,目的是为了返回只有就绪事件的描述符集合,方便操作)
- 每次监控都需要重新添加描述符,超时返回后会移除未就绪的描述符,所以每一次都需要重新添加描述符并且设置超时时间。
函数介绍
1.定义指定事件集合/初始化并添加
fd_set 结构体,结构体中只有一个成员,就是一个数组,作为一个位图使用
void FD_ZERO(fd_set *set) //清空指定的描述符集合
void FD_SET(int fd,fd_set *set) //将fd添加到set中
2.发起调用
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表默认,为0表非阻塞,其他数据表指定等待的时间,超时则返回
返回值
> 0 表就绪的操作符个数
= 0 表没有操作符就绪,通常是超时返回
< 0 监控出错
select 的优点
- select 遵循 posix 标准,可以跨平台移植。
- select 的超时等待时间可以设置精细到微秒。
缺点
- select 能够监控的描述符数量有最大上限,取决于宏 _FD_SETSIZE,默认为1024
- select 监控的原理是在内核中轮询遍历判断,性能会随着描述符的增多而下降
- select 返回时会移除集合中未就绪的描述符,每次监控都需要重新添加描述符,并且重新把集合内容拷贝到内核。
- select 只能返回就绪的描述符集合,无法直接返回就绪的描述符,还需要用户去自行遍历集合才能确定哪一个描述符还在集合中才能确定该描述符已经就绪。
2. poll
接口及其流程介绍
1.定义描述符事件结构体数组,将需要监控的描述符以及相对于的事件填充到数组中
struct pollfd fds[10]; fds[0].fd = 0; fds[0].events = POLLIN | POLLOUT; //设置结构体数组大小为10,添加标准输入,且对标准输入监视可读事件和可写事件
2. 发起监控调用poll,将数组内容拷贝到内核中进行轮询遍历监控,有描述符就绪或者超时就返回,返回时将实际就绪的描述符添加到 revents 中。
int poll(struct pollfd *fds,nfds_t nfds,int timeout)
参数
fds 要监控的描述符事件结构体
nfds 实际上第一个参数描述符事件结构体数量
timeout 超时时间
struct pollfd
{
int fd; //要监控的描述符
short events; //描述符想要监控的事件,POLLIN 可读/ POLLOUT 可写
short revents;
}//当poll接口调用返回时,实际就绪的描述符会写入 revents 中
3.调用返回后,用户自行遍历数组中每一个节点的 revent ,确定当前描述符就绪了什么事件,进而进行对应的操作。
poll 的优点
- poll 通过描述符事件结构体简化了 select 需要三个描述符集合的操作流程。
- poll 能够监视的描述符没有了数量的限制。
- poll 每次监控不需要重新定义描述符结构体内容。
- 超时等待时间能精细到微秒。
缺点
- 无法跨平台使用。
- 每次都要在内核中轮询遍历判断,性能随着描述符的增多而下降。
- 每次调用结束后都需要用户自行遍历结构体每一个节点中的 revents 来确定描述符就绪的事件。
- 每次监控依然需要将数据拷贝到内核。
3. epoll
接口及其流程介绍
1. 在内核中创建 eventpoll 结构体,返回一个描述符作为代码中的操作句柄
int epoll_create(int size)
参数
size: 要监控的描述符的最大数量
2.对需要监控的描述符组织事件结构体,将描述符以及对应事件结构添加到添加到内核的 eventpoll 结构体中。
int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event)
参数
epfd epoll操作句柄
op EPOLL_CTL_ADD(注册新的fd到epfd中)/EPOLL_CTL_MOD(修改已经注册的fd的监听事件)/EPOLL_CTL_DEL(从epfd中删除一个fd)
fd 要监控的描述符
event 监控描述符对应的事件结构体信息
struct epoll_event
{
uint32_t events; //表示要监控的事件以及监控调用返回后实际就绪的事件 --- EOPLLIN/EPOLLOUT
union epoll_data{int fd;//描述符void *ptr;}data;
}
//每一个需要监控的描述符都有一个对应的事件结构,描述符就绪了监控的事件后,就会将这个事件结构体返回给用户
3.开始监控,当有描述符就绪或者等待超时后返回
int epoll_wait(int epfd,struct epoll_event *evs,int maxevents,int timeout)
参数
epfd epoll的操作句柄,通过这个句柄找到内核中指定的eventpoll结构体
evs epoll_event描述符的事件结构体数组的首地址,用于获取就绪的描述符对应的事件结构体
maxevent evs数组的节点数量,主要为防止就绪描述符过多,向evs中存放是越界
timeout 超时等待时间
返回值 > 0 就绪描述符个数 = 0 等待超时 < 0 监控出错
图示流程
epoll 的优点
- 监控的描述符数量不限
- 所有的描述符事件信息只需要向内核拷贝一次
- 同步 IO,但是监控描述符这个过程交由内核处理,自身只负责处理就绪的描述符所对应的就绪事件,从而保证性能不会随着描述符的增多而下降(但是站在消息处理上来看epoll又是一个异步操作)
- 直接返回就绪的事件信息,无需遍历即可直接操作就绪的描述符
缺点
- 不能跨平台使用
- 超时等待时间只能精细到毫秒
epoll 的水平触发和边缘触发
IO 事件就绪
- 可读事件
接收缓冲区中的数据大小大于低水位标记 - 可写事件
发送缓冲区中的剩余空间大于低水位标记 - 低水位标记
一个基准值,默认为 1 字节
水平触发
默认的触发模式 (EPOLLIT,select 和 poll 只有这一种触发模式),一旦发生 IO 就绪事件(可读事件或者可写事件)就立即触发。即如果事件来了,不管来了几个,只要仍然有未处理的事件,epoll 都会通知你。
读:缓冲内容不为空返回读就绪
写:缓冲区还不满返回写就绪
边缘触发
如果事件来了,不管来了几个,你若不处理或者没有处理完,除非下一个事件到来,否则 epoll 将不会再通知你。
可读: 不关注接收缓冲区中是否还有数据,当且仅当有新数据到来时,才会触发一次,每次触发若是读不完全部数据则不再读取,即每次只读取最新到来的数据,但用户也不知道接收缓冲区中还有多少数据,所以需要循环读取,但 recv 在无数据时会造成阻塞,所以需要使用非阻塞 IO (将 recv 的flag 设置为 MSG_DONTWAIT 或者直接把描述符的属性设置成非阻塞 ftntl(int fd,int cmd, O_NONBLOCK)) (缓冲区由不可读变为可读时触发)
可写:当缓冲区由不可写变为可写时才会触发。即一旦有可写空间就触发。
为什么要有边缘触发?边缘触发的应用常见
假设现在要接收一条新的数据,但是发现接收缓冲区中的数据不完整,若是现在就读取出来,就需要额外维护该数据,等下条数据到来(下一次触发)时进行读取补全上一条数据。若是因为数据不完整,不把数据读出来的,则水平触发会一直触发事件(但是又读取不到完整的数据还需要额外维护),这时使用边缘触发就能解决。
边缘触发常用于一种一直触发事件,但又不是每一次都需要进行操作的情景。
epoll 的惊群
一个执行流中,若添加了过多的描述符进行监控,则轮询处理的速度过慢,因此会在多个执行流中创建 epoll,让每一个 epoll 监控一部分描述符来分摊压力,但这种方式无法确定描述符的活跃状态,又可能存在均衡,也可能失衡。可能每一个 epoll 都监控所有的描述符,当一个描述符有事件到来,所有的 epoll 被同时唤醒,进行抢占。可以想见,效率很低下,许多 epoll 被内核重新调度唤醒,同时去响应这一个事件,当然只有一个能处理事件成功,其他的进程在处理该事件失败后重新休眠(也有其他选择)。这种性能浪费现象就是惊群。
多路转接模型进行服务器并发处理与多线程/多进程并发并行处理的区别
- 多路转接模型进行服务器并发处理指的是在单执行流中进行轮询处理就绪的描述符。若描述符过多,则很难做到负载均衡(最后一个描述符要等待前面的描述符处理完成才能够被处理),做法就是作出规定,每一个描述符只能读取指定数量的数据,达标后就换下一个描述符(用户态完成的负载均衡)。因此,多路转接仅适用于,有大量描述符进行监控,但同一时间只有少量描述符活跃的场景。
- 多线程/多进程并发并行处理指的是操作系统轮询调度执行流实现每一个执行流对其中的描述符的处理。由系统内核进行负载均衡处理,不需要用户态操作。
- 基于两种方式的特点,通常二者可以搭配使用。多路转接负载监控大量描述符,哪个描述符就绪事件了,再去创建执行流进行处理,防止直接为描述符创建好了执行流,但描述符没有事件就绪导致的资源浪费。