参考:
引言
最近在学习libhv
,学习到事件循环
和IO多路复用
,介于之前对于此方面了解不足,故深入学习一下。
简述
首先我们先清楚两个概念:
- 事件循环;
- IO多路复用;
事件循环
对于事件循环,我想学过Qt
的一定很熟悉,其为驱动Qt
运行的底层机制。
简单来说就是,对于长时间运行的程序,必然会有主循环的存在,此处以Windows
窗口消息机制来说明:
1 | MSG msg; |
此循环所在线程我们称为GUI线程(即窗口所在线程)
,Qt
的事件循环也是将其封装。
IO多路复用
GUI
线程有事件循环,那么网络IO
线程亦是如此。
我们都知道,IO
分为阻塞BIO
和非阻塞NIO
。
对于BIO
而言,伪代码如下:
1 | for(;;) |
因为独写都是阻塞的,所以一个IO
线程只能处理一个fd
;对于客户端而言尚可,但对于服务端来说,每一个accept
就需要创建一个IO
线程去读写这个套接字,当并发量变大如几千时,那么就需要创建几千个线程,线程上下文切换的开销都会把系统占满。
所以IO
多路复用应运而生,一句话解释就是:单线程或单进程同时监测若干个文件描述符是否可以执行IO
操作的能力。
详解
了解了上面的概念,那么就不难知道一个道理:非阻塞NIO搭配IO多路复用机制是高并发的钥匙
。
到这里,我们可以开始了解这三个东西了。
但select
,poll
,epoll
本质上都是同步IO
,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步IO
则无需自己负责进行读写,异步IO
的实现会负责把数据从内核拷贝到用户空间。
select
概述
首先,我们先了解他的机制,为线性扫描,即轮询,效率较低;仅知道有IO
事件发生,却不知发生位置,只会无差异轮询所有流,找出能读/写数据的流进行操作。同时处理的流越多,轮询的时间越长O(n)
;
并且,单个进程可监视的fd
数量是受限制的,即能监听的端口数量有限;单个进程能打开的最大连接数由FD_MAXSIZE
宏定义。
当socket
过多时,每次select
都需要通过遍历FD_MAXSIZE
个socket
,不管其是否活跃;这会浪费大量cpu时间。
分析
再来分析一下select
函数:
1 | int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout); |
- 参数解析
int maxfdp1
: 指定待测试的文件描述字个数,它的值是待测试的最大描述字加1;fd_set *readset , fd_set *writeset , fd_set *exceptset fd_set
: 可以理解为一个集合,这个集合中存放的是文件描述符(file descriptor)
,即文件句柄。中间的三个参数指定我们要让内核测试读、写和异常条件的文件描述符集合。如果对某一个的条件不感兴趣,就可以把它设为空指针;const struct timeval *timeout
:timeout
告知内核等待所指定文件描述符集合中的任何一个就绪可花多少时间。其timeval结构用于指定这段时间的秒数和微秒数。
- 返回值
- int: 若有就绪描述符返回其数目,若超时则为0,若出错则为-1;
运行机制
select
的机制中提供一种fd_set
的数据结构,实际上是一个long
类型的数组,每一个数组元素都能与一打开的文件句柄(不管是Socket句柄,还是其他文件或命名管道或设备句柄)
建立联系,建立联系的工作由程序员完成,当调用select
时,由内核根据IO状态修改fd_set
的内容,由此来通知执行了select
的进程哪一Socket
或文件可读。
从流程上来看,使用select
函数进行IO
请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket
,以及调用select
函数的额外操作,效率更差。但是,使用select
以后最大的优势是用户可以在一个线程内同时处理多个socket
的IO
请求。
问题
- 每次调用
select
,都需要把fd_set
集合从用户态拷贝到内核态,如果fd_set
集合很大时,那么这个开销也会很大; - 对
socket
扫描时是线性扫描,即采用轮询的方式,效率极低; - 为了减少数据拷贝带来的性能损坏,内核对被监控的
fd_set
集合大小做了限制,并且这个是通过宏控制的,大小不可改变(除非重新编译内核),即上面提到的FD_MAXSIZE
;
poll
概述
poll
的机制与select
类似,与select
在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll
没有最大文件描述符数量的限制。也就是说,poll
只解决了上面的问题3,并没有解决问题1,2的性能开销问题。
分析
poll
中描述fd
的集合从fd_set
转换为pollfd
:
1 | struct pollfd |
函数原型:
1 | int poll(pollfd *fds, nfds_t nfds, int timeout); |
- 参数解析
struct pollfd *fds
: 是一个struct pollfd
类型的数组,用于存放需要检测其状态的socket
描述符,并且调用poll
函数之后fds
数组不会被清空;一个pollfd
结构体表示一个被监视的文件描述符,通过传递fds
指示poll
监视多个文件描述符。其中,结构体的events
域是监视该文件描述符的事件掩码,由用户来设置这个域,结构体的revents
域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域;nfds_t nfds
: 记录数组fds中描述符的总数量;
- 返回值
- int: 函数返回fds集合中就绪的读、写,或出错的描述符数量,返回0表示超时,返回-1表示出错;
运行机制
本质上poll
与select
没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd
对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd
后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd
。这个过程经历了多次无谓的遍历。
问题
- 大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义;
- poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd;
epoll
概述
epoll模型修改主动轮询为被动通知,当有事件发生时,被动接收通知。
相对于select
来说,epoll
没有描述符个数限制,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy
只需一次。
分析
linux
中提供的epoll
函数如下:
1 | int epoll_create(int size); |
epoll_create
: 创建一个epoll
句柄,参数size
表明内核要监听的描述符数量。调用成功时返回一个epoll
句柄描述符,失败时返回-1;epoll_clt
: 注册要监听的事件类型。四个参数解释如下:int epfd
:epoll
句柄;int op
: 操作:EPOLL_CTL_ADD
: 注册新的fd
到epfd
中;EPOLL_CTL_MOD
: 修改已注册的fd
的监听事件;EPOLL_CTL_DEL
: 从epfd
中删除一个fd
;
int fd
: 监听的描述符;struct epoll_event *event
: 要监听的事件;
epoll_wait
: 等待事件的就绪,成功时返回就绪的事件数目,调用失败时返回 -1,等待超时返回 0。四个参数解释如下:int epfd
:epoll
句柄;struct epoll_event events
: 表示从内核得到的就绪事件集合;int maxevents
: 告诉内核events的大小;int timeout
: 表示等待的超时事件;
而对于上面的epoll_event
结构体,其定义如下;
1 | struct epoll_event |
运行机制
epoll
模型注册套接字后,主程序可做其他事情,当事件发生时,接收到通知后再去处理。
可理解为event poll
,epoll
会把哪个流发生哪种I/O
事件通知我们。所以epoll
是事件驱动(每个事件关联fd)
,此时我们对这些流的操作都是有意义的。复杂度也降到O(1)
。
此外,epoll
除了提供select/poll
那种IO事件的水平触发(Level Triggered, LT)
外,还提供了边缘触发(Edge Triggered, ET)
,这就使得用户空间程序有可能缓存IO
状态,减少epoll_wait
的调用,提高应用程序效率。
- 水平触发
LT
: 默认工作模式,即当epoll_wait
检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;下次调用epoll_wait
时,会再次通知此事件; - 边缘触发
ET
: 当epoll_wait
检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait
时,不会再次通知此事件。(直到你做了某些操作导致该描述符变成未就绪状态了,也就是说边缘触发只在状态由未就绪变为就绪时只通知一次)。
ET模式很大程度上减少了epoll事件的触发次数,因此效率比LT模式下高。
优点
- 没有最大并发连接的限制,能打开的
fd
的上限远大于1024(1G的内存上能监听约10万个端口); - 效率提升,不是轮询,不会随
fd
数目增加而效率下降。只有活跃可用的fd
才会调用callback
函数 即epoll
最大优点在于它只关心“活跃”连接,而跟连接总数无关;因此实际网络环境中,Epoll效率远高于select、poll; - 内存拷贝,利用
mmap
文件映射内存加速与内核空间的消息传递;即epoll
使用mmap
减少复制开销;
总结
select | poll | epoll | |
---|---|---|---|
操作方式 | 遍历 | 遍历 | 回调 |
底层实现 | 数组 | 链表 | 红黑树(RB-Tree) |
效率 | O(n) | O(n) | O(1) |
最大连接数 | 1024(x86)/2048(x86) | 无上限 | 无上限 |
fd拷贝 | 每次调用都要将fd 从用户态拷贝到内核态 |
每次调用都要将fd 从用户态拷贝到内核态 |
仅在调用epoll_clt 时将fd 拷贝进内核态并保存 |
epoll
是Linux
目前大规模网络并发程序开发的首选模型。在绝大多数情况下性能远超select
和poll
。但是,select
低效也是相对的,视情况而定,也可通过良好的设计改善。