IO多路复用 即 用一个线程监视多个文件句柄
- 句柄没有就绪时会阻塞应用程序,从而释放CPU资源
- 否则当句柄就绪,能通知到对应程序进行读写操作
常用的IO多路控制方法有select、poll和epoll三种,三者对比如下
其中epoll性能最好,本文主要介绍epoll。
使用方法
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;
}
}
}
原理剖析
为什么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等。
Reference
谈谈你对IO多路复用的理解,全面从select,poll,epoll来进行综合对比,让你offer拿到手软!【Java面试】_哔哩哔哩_bilibili
FD_CLOEXEC 详解_bemf168的博客-CSDN博客
深入理解 Linux 的 epoll 机制 (qq.com)
作为C++程序员,应该彻底搞懂epoll高效运行的原理 - 知乎 (zhihu.com)