关于select、poll、epoll的区别,之前在另外一篇文章做了总结,本篇主要对原理上做出更详细的阐述,参考文献将在文末附上。
1. 从阻塞IO到IO多路复用
-
阻塞IO方案,当进程使用系统调用,该系统调用涉及到IO操作,而此时数据如果没准备好,该进程就会进入等待状态(更多关于阻塞IO、非阻塞IO、同步IO、异步IO可以参考这篇文章)。这样,为了能够处理多个socket的请求,需要使用多线程来处理。缺点:线程切换开销很大
- 假设有以下程序
//创建socket int s = socket(AF_INET, SOCK_STREAM, 0); //绑定 bind(s, ...) //监听 listen(s, ...) //接受客户端连接 int c = accept(s, ...) //接收客户端数据 recv(c, ...); //将数据打印出来 printf(...)
- 工作队列:A,B,C目前都处于运行状态,其中A负责执行上段程序
- 等待队列:进程A创建socket,其中包含了、发送队列,接收缓冲区,等待列表
- 当进程A执行到recv()时,没有数据,操作系统就会将A放到socket的等待列表,此时工作队列只有B、C,进程A陷入等待状态,即阻塞
- 唤醒进程:当socket接收缓冲区有数据时,将A放回工作队列,成为运行态,继续执行代码,recv()读到数据。
- 假设有以下程序
-
非阻塞轮询方案,非阻塞IO不会让进程等待,调用立即返回,这样就可以在一个线程里轮询多个fd是否就绪。缺点:每次检查fd都会调用系统函数,用户态与内核态间反复更换,开销很大。
//用户态
sockfd_list;
for(sfd : sockfd_list)
{
if(readale(sfd))//非阻塞的
{
//处理sfd
}
}
- IO多路复用方案:一次系统调用,检查多个fd的状态。在其内部,使用的就是非阻塞IO。相当于把非阻塞系统调用放到内核态进行,减少了用户态与内核态的切换。
//内核态
sockfd_list;
for(sfd : sockfd_list)
{
if(readale(sfd))//非阻塞的
{
count++
FDSET(fd, &res_rset) // 将 fd 添加到就绪集合中
}
}
return count;
select、poll、epoll都是同步阻塞的,有fd事件触发,返回;触发,阻塞,等待fd事件触发或超时。
2. select
int select(int nfds,
fd_set *restrict readfds,
fd_set *restrict writefds,
fd_set *restrict errorfds,
struct timeval *restrict timeout);
readfds:传入:需要监听读事件fd的集合,传出:满足读事件的fd集合
writefds:传入:需要监听写事件fd的集合,传出:满足写事件的fd集合
errorfds:传入:需要监听错误事件fd的集合,传出:满足错误事件的fd集合
timeout:超时时间
select遍历前nfds个描述符,找到触发事件的fd,放到对应的集合,返回触发事件的总数。
-
执行流程
- 用户线程调用select,将fd_set从用户空间拷贝到内核空间。
- 内核对fd_set遍历一遍,检查是否有就绪的fd,如果没有的话,就会进入休眠,直到有就绪的fd。
- 内核返回select的结果给用户线程,即就绪的文件描述符数量。
- 用户拿到就绪文件描述符数量后,再次对fd_set进行遍历,找出就绪的文件描述符。
- 用户线程对就绪的文件描述符进行读写操作。
-
示例
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int fds[] = 存放需要监听的socket
while(1){
int n = select(..., fds, ...)
for(int i=0; i < fds.count; i++){
if(FD_ISSET(fds[i], ...)){
//fds[i]的数据处理
}
}
}
select原理
-
优点
- 跨平台
- 时间精度高,ns级别
-
缺点
- 最大限制:依赖于FD_SIZE,32位默认1024,64位默认2048
- 时间复杂度:线性扫描fd,轮询,时间复杂度O(n)
- 只返回就绪fd的数量,无法具体知道哪个fd是就绪的,当内核返回就绪的文件描述符数量后,还需要遍历一次找出就绪的fd,如果有大量连接,但很少的请求,这种操作就很耗时。
- 内存拷贝:fd_set从用户态到内核态拷贝
3. poll
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
-
执行流程
- 用户调用poll,将fd数组从用户态拷贝到内核态,然后转换为fd链表
- 内核对fd链表遍历一遍,检查是否有就绪的fd,如果没有的话,就会进入休眠,直到有就绪的fd。
- 返回给用户线程就绪的文件描述符数量
- 用户线程再遍历一次fd数组,找出就绪的文件描述符。
- 用户线程对就绪的fd进程读写操作。
-
与select的区别
- select是fd数组,有长度限制;poll是fd链表,无长度限制
-
示例
struct pollfd fds[POLL_LEN];
unsigned int nfds=0;
fds[0].fd=server_sockfd;
fds[0].events=POLLIN|POLLPRI;
nfds++;
while {
res=poll(fds,nfds,-1);
if(fds[0].revents&(POLLIN|POLLPRI)) {
//执行accept并加入fds中,nfds++
if(--res<=0) continue
}
//循环之后的fds
if(fds[i].revents&(POLLIN|POLLERR )) {
//读操作或处理异常等
if(--res<=0) continue
}
}
- 优点
- 没有最大连接数限制
- 缺点
- 时间复杂度:内核采用线性扫描,时间复杂度O(n)。
- 只返回就绪fd的数量,无法具体知道哪个fd是就绪的,当内核返回就绪的文件描述符数量后,还需要遍历一次找出就绪的fd,如果有大量连接,但很少的请求,这种操作就很耗时。
- 内存拷贝:fd数组从用户态到内核态拷贝。
- LT触发:报告fd后没被处理,下次poll还会再次报告该fd。
4. epoll
#include <sys/epoll.h>
// 数据结构
// 每一个epoll对象都有一个独立的eventpoll结构体
// 红黑树用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
// epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
struct eventpoll {
...
/*红黑树的根节点,这颗树存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表存储所有就绪的文件描述符*/
struct list_head rdlist;
...
};
// API
int epoll_create(int size); // 内核中间加一个 eventpoll 对象,把所有需要监听的 socket 都放到 eventpoll 对象中
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // epoll_ctl 负责把 socket 增加、删除到内核红黑树
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);// epoll_wait 检测双链表中是否有就绪的文件描述符,如果有,则返回
-
特点
- 功能分离:epoll_ctl负责维护等待队列,epoll_wait负责阻塞监听
- 使用红黑树存储fd集合。
- 使用双链表存储就绪的fd,只需要判断该链表有无元素就可以知道有无fd就绪。
- 每个fd只需在添加时传入一次,通过事件 callback 更改fd状态。
-
epoll_create
int epoll_create(int size);
- 创建epoll实例,返回一个fd引用该实例
- epoll实例包含监听列表(红黑树)、就绪列表(双向链表)
-
epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- 将fd添加到监听列表,同时为fd设置一个回调函数,并监听事件event。当fd触发事件时,调用回调函数,将fd添加到就绪队列。
-
epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- 查看就绪队列有无fd,有就返回就绪fd的个数;无就等待timeout毫秒。
- events会保存所有就绪fd。
-
示例
int s = socket(AF_INET, SOCK_STREAM, 0); bind(s, ...) listen(s, ...) int epfd = epoll_create(...); epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中 while(1){ int n = epoll_wait(...) for(接收到数据的socket){ //处理 } }
epoll原理
-
优点
- 没有最大连接数限制
- 时间复杂度低:ET触发+事件驱动,监听回调,O(1)。就绪的fd调用回调函数,加入就绪队列。
- 内存拷贝:利用mmap()文件映射内存加速与内核空间的消息传递,减少拷贝开销。
-
缺点
- 只适用于Linux
-
应用场景
-
适合用epoll的应用场景:
- 对于连接特别多,活跃的连接特别少
- 典型的应用场景为一个需要处理上万的连接服务器,例如各种app的入口服务器,例如qq。
-
不适合epoll的场景:
- 连接比较少,数据量比较大,例如ssh
- epoll 的惊群问题:因为epoll 多用于多个连接,只有少数活跃的场景,但是万一某一时刻,epoll 等的上千个文件描述符都就绪了,这时候epoll 要进行大量的I/O,此时压力太大。
-
-
epoll的两种模式
-
LT水平触发:缓冲区有数据就触发,所以如果当前事件没有被处理,epoll_wait还会再触发。
-
ET边缘触发:缓冲区有新数据才触发,所以当事件没被处理,epoll_wait不会再次触发。
- ET模式下,应当将fd的数据读完(非阻塞轮询),直到errno返回EAGAIN,否则下次epoll_wait会丢掉事件,不会返回剩余的数据。
- ET模式很大程度减少了epoll事件被重复触发的次数,效率比LT模式高。
- epoll工作在ET模式的时候,必须使用非阻塞,以避免由于一个fd的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
-
5. 总结
参考连接:
https://www.cnblogs.com/Hijack-you/p/13057792.html
https://zhuanlan.zhihu.com/p/554348972
https://www.cnblogs.com/yungyu16/p/13066744.html
https://zhuanlan.zhihu.com/p/367591714