IO多路复用epoll

114 阅读5分钟

IO多路复用 即 用一个线程监视多个文件句柄

  • 句柄没有就绪时会阻塞应用程序,从而释放CPU资源
  • 否则当句柄就绪,能通知到对应程序进行读写操作

常用的IO多路控制方法有selectpollepoll三种,三者对比如下

image.png

其中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_tloff_t *);  
    ssize_t (*write) (struct file *, const char __user *, size_tloff_t *);  
    __poll_t (*poll) (struct file *, struct poll_table_struct *);  
    int (*open) (struct inode *, struct file *);  
    int (*fsync) (struct file *, loff_tloff_tint 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)

day03-高并发还得用epoll | csblog

网络编程:socket的阻塞模式和非阻塞模式_socket非阻塞模式__索伦的博客-CSDN博客

epoll的LT模式(水平触发)和ET模式(边沿触发)_epollet_AlbertS的博客-CSDN博客