IO 多路复用

本贴最后更新于 300 天前,其中的信息可能已经时移俗易

IO 多路复用 即 用一个线程监视多个文件句柄,句柄没有就绪时会阻塞应用程序,从而释放 CPU 资源,否则当句柄就绪,能通知到对应程序进行读写操作

  • IO:在操作系统中,数据在内核态和用户态之间的读写操作(大部分情况下指网络 IO
  • 多路:一般指多个 TCP 连接
  • 复用:一个或多个线程资源
  • 整合 IO 多路复用:一个或多个线程处理多个 TCP 连接,无需创建和维护过多的进程或线程

常用的 IO 多路控制方法有 select​、poll​和 epoll​三种,三者对比如下,其中 epoll​性能最好。

image

  • select(轮询 + 遍历):调用 select 会阻塞进程,直到有 fd 就绪。优点:跨平台支持性好;缺点:效率低下,每次都需从用户空间到内核空间拷贝 fd 数组集合(一般单个进程最大 1024,可通过),就绪后仍需轮询

    • 客户端操作服务器时会创建三种文件描述符,分别是 写描述符、读描述符 和 异常描述符,阻塞进程,等数据可读、可写或者出异常、超时的时候都会在内核空间返回,返回后需要在用户空间遍历 文件描述符集合 fdset,找到就绪的 fd,从而出发对应的 IO 操作
  • poll(轮询 + 遍历):同样阻塞,链表方式存储 fd,优点:无最大连接数限制;缺点:fd 越多效率越低

  • epoll:使用红黑树(平衡二叉树)维护 fd,每个 fd 从用户态拷贝到内核态仅需一次(epoll_ctl 时拷贝),优点:将轮询改成了回调,不会随 fd 数量增加导致效率下降;缺点:只能 linux 下环境使用

应用程序使用 poll 示例

image

  • 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 函数

图例

image

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)

day03-高并发还得用 epoll | csblog

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

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

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...