IO 多路复用 即 用一个线程监视多个文件句柄,句柄没有就绪时会阻塞应用程序,从而释放 CPU 资源,否则当句柄就绪,能通知到对应程序进行读写操作
- IO:在操作系统中,数据在内核态和用户态之间的读写操作(大部分情况下指网络 IO
- 多路:一般指多个 TCP 连接
- 复用:一个或多个线程资源
- 整合 IO 多路复用:一个或多个线程处理多个 TCP 连接,无需创建和维护过多的进程或线程
常用的 IO 多路控制方法有 select
、poll
和 epoll
三种,三者对比如下,其中 epoll
性能最好。
-
select(轮询 + 遍历):调用 select 会阻塞进程,直到有 fd 就绪。优点:跨平台支持性好;缺点:效率低下,每次都需从用户空间到内核空间拷贝 fd 数组集合(一般单个进程最大 1024,可通过),就绪后仍需轮询
- 客户端操作服务器时会创建三种文件描述符,分别是 写描述符、读描述符 和 异常描述符,阻塞进程,等数据可读、可写或者出异常、超时的时候都会在内核空间返回,返回后需要在用户空间遍历 文件描述符集合 fdset,找到就绪的 fd,从而出发对应的 IO 操作
-
poll(轮询 + 遍历):同样阻塞,链表方式存储 fd,优点:无最大连接数限制;缺点:fd 越多效率越低
-
epoll:使用红黑树(平衡二叉树)维护 fd,每个 fd 从用户态拷贝到内核态仅需一次(epoll_ctl 时拷贝),优点:将轮询改成了回调,不会随 fd 数量增加导致效率下降;缺点:只能 linux 下环境使用
应用程序使用 poll 示例
-
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
-
参数说明:
-
fds:存放需要检测其状态的文件描述符集;
-
nfds:用于标记数组 fds 中的结构体元素的总数量;
-
timeout:是 poll 函数调用阻塞的时间,单位:毫秒;
- 如果 timeout==0,那么 poll() 立即返回而不阻塞;设置为负数,poll() 会一直阻塞下去,直到所检测的文件描述符上的感兴趣的事件发生时才返回。
-
-
返回值:
- >0:数组 fds 中准备好读、写或出错状态的那些文件描述符的总数量
- ==0:此时 poll 超时
- -1: poll 函数调用失败,同时会自动设置全局变量 errno
-
驱动中如何实现 poll 方法
应用程序调用 poll()时,内核中会调用每个设备驱动中的 poll 函数,这些底层函数都会调用 poll_wait(),将本设备驱动中的等待队列添加到一个等待队列表中(table),然后判断是否有数据发生,有的话返回一个非零值,没有返回 0.
核心:poll_wait 函数
图例
poll 系统调用在内核中的入口函数是 sys_poll();
EPOLL 原理剖析
为什么 epoll 高效
- 内部使用了红黑树结构管理 fd,查询和增删的时间复杂度 O(logn),实现增删改之后性能的优化和平衡;
- epoll 池添加 fd 的时候,设置 file_operations->poll,把这个 fd 就绪之后的回调路径安排好。通过事件通知的形式,做到最高效的运行;
- fd 就绪后其相关结构体(epitem)统一存放在就绪队列,epoll 池处理 fd 时只需遍历就绪链表即可
epoll 触发模式
epoll 支持的事件触发模式有:
- 水平触发 LT:当有可读事件发生时,服务器不断从
epoll_wait
中苏醒,直到内核缓冲区的数据被读完 - 边缘触发 ET:只在事件状态由不可用到可用时苏醒一次(必须搭配非阻塞式 socket 使用),程序需保证一次性将内核缓冲区的数据处理完
epoll 回调机制
poll 事件回调机制则是 epoll 池高效最核心原理。
结构体 struct file_operations
代表文件调用,文件最基本操作都是以这个框架为基础实现的。
struct file_operations {
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
__poll_t (*poll) (struct file *, struct poll_table_struct *);
int (*open) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
// ....
};
file_operations->poll
是定制监听事件的机制实现。通过 poll 机制让上层能直接告诉底层,我这个 fd 一旦读写就绪了,请底层硬件(比如网卡)回调的时候自动把这个 fd 相关的结构体(epitem)放到指定队列中,并且唤醒操作系统。
举个例子:网卡收发包其实走的异步流程,操作系统把数据丢到一个指定地点,网卡不断的从这个指定地点掏数据处理。请求响应通过中断回调来处理,中断一般拆分成两部分:硬中断和软中断。poll 函数就是把这个软中断回来的路上再加点料,只要读写事件触发的时候,就会立马通知到上层,采用这种事件通知的形式就能把浪费的时间窗就完全消失了。
因此 epoll 池管理的句柄只能是支持了 file_operations->poll
的文件 fd,如 socket fd,eventfd,timerfd 等。
使用方法
1、创建 epoll 池
epollcreate
负责创建一个池子,一个监控和管理句柄 fd 的池子;
原型
int epoll_create(int size); // 其中参数size已被抛弃,赋值为>=0的值即可
int epoll_create1 (int __flags) // 若flags为0,与上同;
// 否则当包含EPOLL_CLOEXEC等值时,在文件描述符上面设置执行时关闭(FD_CLOEXEC)标志描述符。
执行成功时返回非负文件描述符,失败返回-1,并且将 errno 设置为指示错误
示例
int epfd = epoll_create1(0);
errif(epfd == -1, "epoll create error"); // 定义如下
void errif(bool condition, const char *errmsg) {
if (condition) {
perror(errmsg); // 输出错误原因,errmsg先打印,后加上错误原因字符串
exit(EXIT_FAILURE);
}
}
2、管理 epoll 池
epollctl
负责管理这个池子里的 fd 增、删、改;
原型
int epoll_ctl (int __epfd, int __op, int __fd, struct epoll_event *__event);
op 参数说明操作类型:
- EPOLL_CTL_ADD:添加一个需要监视的描述符
- EPOLL_CTL_DEL:删除一个描述符
- EPOLL_CTL_MOD:修改一个描述符
使用
struct epoll_event events[MAX_EVENTS], ev;
bzero(&events, sizeof(events));
bzero(&ev, sizeof(ev));
ev.data.fd = sockfd; // sockfd 由socket创建而来
ev.events = EPOLLIN | EPOLLET; // 监听可读事件;边缘模式ET触发(fd需非阻塞)
setnonblocking(sockfd); // 设置不堵塞,函数定义如下
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev); // EPOLL_CTL_ADD表添加
// 设置fd为非阻塞模式
void setnonblocking(int fd) {
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK); // 先用fcntl(fd, F_GETFL)获取原先状态再设置
}
3、监听 epoll 池
epollwait
就是负责打盹的,让出 CPU 调度,但是只要有“事”,立马会从这里唤醒;
原型
int epoll_wait (int __epfd, struct epoll_event *__events, int __maxevents, int __timeout);
其中 events 是一个 epoll_event 结构体数组,maxevents 是可供返回的最大事件大小,一般是 events 的大小,timeout 表示最大等待时间,设置为-1 表示一直等待。
返回就绪 fd 的个数,无需像 select/poll 一样轮询扫描整个 socket 集合,大大提高检测效率
实现
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
errif(nfds == -1, "epoll wait error");
for (int i = 0; i < nfds; i++) {
// 对就绪句柄的处理
}
4、对句柄的处理
在边缘触发模式中,需配合非阻塞的读写函数,因此需对错误码进行处理
- socket 是阻塞模式时,继续调用 send/recv 函数,程序会阻塞在 send/recv 调用处。
- 当 socket 是非阻塞模式时,将立即出错并返回,会得到一个相关的错误码,在 Linux 上错误码为 EWOULDBLOCK 或 EAGAIN
Linux 中系统调用的错误都存储于 errno 中,其记录系统的最后一次错误代码。
下文代码是服务器对就绪 fd 集合的处理,他能连接新客户端并转发客户端发的内容。
// 使用了相关自定义类
while(true){
std::vector<epoll_event> events = ep->poll();
int nfds = events.size();
for(int i = 0; i < nfds; ++i){
if(events[i].data.fd == serv_sock->getFd()){ //新客户端连接
InetAddress *clnt_addr = new InetAddress();
Socket *clnt_sock = new Socket(serv_sock->accept(clnt_addr));
printf("new client fd %d! IP: %s Port: %d\n", clnt_sock->getFd(), inet_ntoa(clnt_addr->addr.sin_addr), ntohs(clnt_addr->addr.sin_port));
clnt_sock->setnonblocking();
ep->addFd(clnt_sock->getFd(), EPOLLIN | EPOLLET); // 将新客户端划入epoll池
} else if(events[i].events & EPOLLIN){ //可读事件
handleReadEvent(events[i].data.fd);
} else{ //其他事件
printf("something else happened\n");
}
}
}
对读事件的处理
void handleReadEvent(int sockfd){
char buf[READ_BUFFER];
while(true){ //由于使用非阻塞IO,读取客户端buffer,一次读取buf大小数据,直到全部读取完毕
bzero(&buf, sizeof(buf));
ssize_t bytes_read = read(sockfd, buf, sizeof(buf));
if(bytes_read > 0){
printf("message from client fd %d: %s\n", sockfd, buf);
write(sockfd, buf, sizeof(buf));
} else if(bytes_read == -1 && errno == EINTR){ //客户端正常中断、继续读取
printf("continue reading");
continue;
} else if(bytes_read == -1 && ((errno == EAGAIN) || (errno == EWOULDBLOCK))){//非阻塞IO,这个条件表示数据全部读取完毕
printf("finish reading once, errno: %d\n", errno);
break;
} else if(bytes_read == 0){ //EOF,客户端断开连接
printf("EOF, client fd %d disconnected\n", sockfd);
close(sockfd); //关闭socket会自动将文件描述符从epoll树上移除
break;
}
}
}
Reference
谈谈你对 IO 多路复用的理解,全面从 select,poll,epoll 来进行综合对比,让你 offer 拿到手软!【Java 面试】_哔哩哔哩_bilibili
FD_CLOEXEC 详解_bemf168 的博客-CSDN 博客
深入理解 Linux 的 epoll 机制 (qq.com)
作为 C++ 程序员,应该彻底搞懂 epoll 高效运行的原理 - 知乎 (zhihu.com)
网络编程:socket 的阻塞模式和非阻塞模式_socket 非阻塞模式__索伦的博客-CSDN 博客
epoll 的 LT 模式(水平触发)和 ET 模式(边沿触发)_epollet_AlbertS 的博客-CSDN 博客
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于