IO多路复用

246 阅读17分钟

IO多路复用

IO多路复用是什么

理解IO多路复用之前需要先了解几个基本概念:

  1. IO:IO是 Input/Output 的缩写,表示输入和输出,指计算机系统与外部环境进行数据交换的过程。计算机系统通过IO操作与外部设备(键盘、鼠标、显示器、硬盘、网络等)进行数据的输入和输出。
  2. 多路:多路是指一个线程可以同时监视和处理多个输入输出事件,无需为每个IO操作创建独立的线程或进程,提高系统的并发性能。
  3. 复用:复用指的是一个线程或进程同时监听和处理的多个IO事件,这些IO事件共享同一个资源进行IO操作的复用机制。

通过上面概念可以得出一个结论:

IO多路复用是指一种机制,通过这种机制使得一个线程监听多个IO操作,而不是每个IO操作都创建一个线程来监听,这些IO事件共享相同资源,可以大大节省系统资源。

IO读写过程

在计算机系统中,IO读写需要用户态和内核态之间的切换,因为IO操作涉及到硬件资源(硬盘、键盘和终端等设备),这些设备是不能被用户态直接访问的。所以当程序需要进行IO读写或其他类似的硬件资源操作时,不能直接执行,需要通过系统调用的过程转入内核态运行。

  • 用户空间:只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问
  • 内核空间:可以执行特权命令(Ring0),可以调用一切系统资源

IO读写过程

  • 写数据:先把用户缓冲数据拷贝到内核缓冲区,然后写入设备
  • 读数据:先从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区

读数据时需要先等待内核缓冲区数据就绪,然后将内核缓冲区数据拷贝到用户缓冲区

image.png

参考资料:juejin.cn/post/712907…

IO多路复用的核心实现

IO多路复用有三个核心组件:缓冲区Buffer、通道Channel、选择器Selector实现,下面一一介绍这些相关概念

IO多路复用核心实现.png

通道Channel

通道Channel:代表数据传输的路径,可以是文件、套接字、管道等,通道提供读写操作的接口,使得应用程序可以和输入源、输出目标进行交互,实现数据在缓冲区和输入输出设别之间的传输。

通道的实现类

  • FileChannel:提供对文件的读写操作,可以通过FileChannel读取、写入、映射和操作文件的内容
  • SocketChannel:提供用于网络Socket通信的通道,SocketChannel封装了底层的套接字,提供对TCP协议的非阻塞式读写操作。
  • ServerSocketChannel:提供用于网络Socket通信的通道,ServerSocketChannel类是对ServerChannel类进行了封装,提供对TCP协议的非阻塞式连接监听操作。

套接字:套接字是网络编程中用于实现网络通信的软件 接口,提供了一种在网络上进行数据传输的机制。套接字可以通过网络中的IP地址和端口号来标识和定位网络中的应用程序。

缓存区Buffer

缓冲区Buffer:缓冲区本质上就是可以暂存数据的内存,数据从输入源读取到缓冲区中,或者从缓冲区中输出到目标中,缓冲区的作用是提供读写操作的临时存储空间,可以减少频繁的IO操作,提高IO操作的性能。

Java提供Buffer的JDK使用 java.nio.Buffer,根据操作不同数据类型,Buffer的实现类如下:

  • IntBuffer
  • FloatBuffer
  • CharBuffer
  • DoubleBuffer
  • ShortBuffer
  • LongBuffer
  • ByteBuffer

从IO设备读取数据的流程:

  1. 应用程序调用通道的 read() 方法
  2. 通道往 缓冲区 Buffer 中填入IO设备中的数据,填充完成之后返回
  3. 应用程序从 缓冲区 Buffer 中获取数据

从IO设备写数据的流程:

  1. 应用程序往 缓冲区 Buffer 中填入要写到IO设备中的数据
  2. 调用通道的 write() 方法,通道将数据传输至IO设备

选择器Selector

选择器Selector:选择器用于管理多个通道的IO事件,选择器可以同时监听多个通道上的事件,并在事件就绪时进行相应的处理。多个通道会注册到一个选择器中,通过选择器的轮询或者事件驱动方式,可以对多个通道的IO事件进行监控,提高系统的并发性能。

image.png

IO多路复用的函数

IO多路复用使得一个线程可以同时监听多个IO操作,进程如何知道哪些IO操作数据就绪?通过一种机制可以监视多个描述符,一旦某个描述数据准备就绪,就会通知程序进行相应的读写操作,可以通过以下函数实现该机制:

  1. select
  2. poll
  3. epoll

select函数

select函数是最常用的IO多路复用实现之一

select函数原型

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

参数说明

  • nfds:设置需要监听的文件描述符的最大值加1
  • readfds:设置需要监听读事件的文件描述符集合
  • writefds:设置需要监听写事件的文件描述符集合
  • exceptfds:设置需要监听异常事件的文件描述符集合
  • timeout:设置select函数的超时事件,如果为NUll,则表示一直阻塞等待

返回值说明

  • 成功时返回就绪文件描述符个数
  • 超时时返回0
  • 出错时返回负值

select函数的使用步骤

  1. 初始化文件描述符集合,将需要监听的文件描述符添加到对应的集合中
  2. 调用select函数,传入文件描述符集合和超时时间
  3. 检查返回值,如果返回值大于0,表明有文件描述符就绪,可以对其进行读写操作;如果返回值为0,表示超时;如果返回值为负值,表示出错
  4. 重复步骤1-3,直到所有文件描述符都处理完毕

select函数执行流程

  1. 用户态程序调用select函数,传入需要监听的文件描述符集合、超时时间等信息
  2. select函数将文件描述符集合从用户态拷贝到内核态,并设置一个等待队列
  3. select函数进入内核态,阻塞等待文件描述符状态发生变化或者超时事件发生
  4. 当某个文件描述符就绪时,内核会唤醒select函数
  5. select函数返回就绪的文件描述符个数
  6. 用户态程序根据返回值,遍历文件描述符集合,对就绪的文件描述符进行相应的读写操作
  7. 读写完成后,用户态程序再次调用select函数,继续监听文件描述符状态发生变化

select函数使用举例

#include <stdio.h> // 引入标准输入输出头文件
#include <sys/select.h> // 引入select函数所需的头文件
#include <unistd.h> // 引入unistd.h头文件,用于close函数

int main() {
    fd_set readfds; // 定义一个文件描述符集合变量readfds
    struct timeval timeout; // 定义一个时间结构体变量timeout
    int ret; // 定义一个整型变量ret,用于存储select函数的返回值

    // 创建一个套接字文件描述符
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) { // 如果创建失败,打印错误信息并返回1
        perror("socket");
        return 1;
    }

    // 设置超时时间
    timeout.tv_sec = 5; // 设置超时时间为5秒
    timeout.tv_usec = 0; // 设置超时时间为0毫秒

    // 将套接字文件描述符添加到readfds集合中
    FD_SET(sockfd, &readfds);

    // 调用select函数,等待文件描述符状态变化或超时
    ret = select(sockfd + 1, &readfds, NULL, NULL, &timeout);
    if (ret == -1) { // 如果select函数调用失败,打印错误信息并返回1
        perror("select");
        close(sockfd); // 关闭套接字文件描述符
        return 1;
    } else if (ret == 0) { // 如果select函数超时,打印提示信息
        printf("Timeout!\n");
    } else { // 如果select函数返回大于0的值,表示有文件描述符就绪
        // 在这里处理就绪的文件描述符,例如读取数据等
        printf("File descriptor is ready!\n");
    }

    // 关闭套接字文件描述符
    close(sockfd);

    return 0; // 程序正常结束
}

select函数优缺点

select函数优点

  • 支持多路IO操作,能同时监听多个文件描述符的状态变化
  • 具有良好的移植性,跨平台使用无障碍
  • 对于简单的网络编程场景,如单进程单线程处理少量连接时,select比epoll更加简单易用
  • 在连接数量较少的情况下,select的性能会更好

select函数缺点

  • select能够监听的文件描述符有最大数量上限,这个上限默认等于1024
  • 每次调用select函数都需要把文件描述符集合从用户态拷贝到内核态进行监听,如果文件描述符数量较多,这个开销会较大
  • select在内核中使用轮询遍历进行监听,当文件描述符较多时,其监听性能会较低

poll函数

poll函数实现机制和select函数非常类似,poll函数优化了select最大文件描述符数量的限制

poll函数原型

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

poll函数参数说明

  • fds:结构体数组的指针,每个结构体包含文件描述符、请求的事件类型以及事件处理方式等信息
  • nfds:需要监听的文件描述符的数量
  • timeout:超时事件,如果在这段时间内没有事件发生,poll就会返回

poll函数返回值说明

  • 成功时返回就绪文件描述符个数
  • 超时时返回0
  • 出错时返回负值

poll函数执行流程

  1. 用户态程序调用poll函数,传入需要监听的文件描述符集合、文件描述符数量以及超时时间等信息
  2. poll函数将文件描述符集合从用户态拷贝到内核态,并设置一个等待队列
  3. poll函数进入内核态,阻塞等待文件描述状态变化或超时时间发生
  4. 当某个文件描述符就绪时,内核会唤醒poll函数
  5. poll函数返回就绪的文件描述符个数
  6. 用户态程序根据返回值,遍历文件描述符集合,对就绪的文件描述进行相应的读写操作
  7. 读写完成之后,用户态程序再次调用poll函数,继续监听文件描述符状态变化

poll函数使用举例

#include <stdio.h> // 引入标准输入输出头文件
#include <sys/poll.h> // 引入poll函数所需的头文件
#include <unistd.h> // 引入unistd.h头文件,用于close函数

int main() {
    fd_set readfds; // 定义一个文件描述符集合变量readfds
    struct pollfd fds[2]; // 定义一个pollfd结构体数组fds,用于存储文件描述符和事件类型等信息
    int ret; // 定义一个整型变量ret,用于存储poll函数的返回值

    // 创建一个套接字文件描述符
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) { // 如果创建失败,打印错误信息并返回1
        perror("socket");
        return 1;
    }

    // 设置超时时间
    struct timeval timeout;
    timeout.tv_sec = 5; // 设置超时时间为5秒
    timeout.tv_usec = 0; // 设置超时时间为0毫秒

    // 将套接字文件描述符添加到readfds集合中
    FD_ZERO(&readfds); // 清空readfds集合
    FD_SET(sockfd, &readfds); // 将sockfd添加到readfds集合中

    // 调用poll函数,等待文件描述符状态变化或超时
    ret = poll(fds, 2, timeout.tv_sec * 1000 + timeout.tv_usec / 1000); // 将timeout转换为毫秒
    if (ret == -1) { // 如果调用失败,打印错误信息并关闭套接字文件描述符,然后返回1
        perror("poll");
        close(sockfd);
        return 1;
    } else if (ret == 0) { // 如果超时,打印提示信息
        printf("Timeout!\n");
    } else { // 如果发生事件,处理就绪的文件描述符
        // 在这里处理就绪的文件描述符,例如读取数据等
        printf("File descriptor is ready!\n");
    }

    // 关闭套接字文件描述符
    close(sockfd);

    return 0; // 程序正常结束
}

poll函数优缺点

poll函数优点

  • 可自行设置需要监听的文件描述符个数
  • 通过参数为1的结构体实现请求和返回,因此不需要保存一个母本
  • 提供了对文件描述符事件的边缘触发支持

poll函数缺点

  • 仅能在linux系统中使用,跨平台兼容性较差
  • 和select函数一样,文件描述符数量较多时,将文件描述符的数组从用户空间复制到内核空间时,其复制开销较大
  • 同时处理的文件描述符数量存在一定限制,虽然可以通过修改宏定义来增加同时处理的文件描述符数量,但是会降低处理效率

epoll函数

epoll函数是linux系统中的一种IO多路复用技术,提供了高效的IO事件处理机制。相比于select和poll函数,epoll函数具有更高的性能和更好的可扩展性。

epoll函数原型

#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll函数介绍

  • epoll_create:创建一个epoll实例,返回一个文件描述符。size参数表示监听的文件描述符数量的最大值加1
  • epoll_ctl:控制epoll实例,可以添加、修改或删除需要监听的文件描述符。op参数表示操作类型,fd参数表示文件描述符。event参数表示需要监听的事件类型和事件处理方式等信息
  • epoll_wait:等待epoll实例中的文件描述符就绪,返回就绪的文件描述符个数。

注意:使用完 epoll 函数后,必须调用 close 关闭 epoll 实例的文件描述符,否则可能会导致资源泄露。调用 epoll_wait 函数如果设置为非阻塞模式,则需要检查返回值是否为0或错误码 EAGAIN/EWOULDBLOCK,判断是否有事件发生。

epoll函数执行流程

  1. 用户态程序调用 epoll_create 创建一个epoll实例,并获取一个文件描述符
  2. 用户态程序调用 epoll_ctl 将需要监听的文件描述符添加到 epoll 实例中,并设置相应的事件类型和事件处理方式等信息。此外,这些信息会被拷贝到内核空间的红黑树中
  3. epoll 实例在内核空间维护了一个就绪列表,当某个文件描述符就绪时,内核会将其加入到就绪列表中
  4. 用户态程序调用 epoll_wait 等待 epoll 实例中的文件描述符就绪,返回就绪的文件描述符个数。此时,进程会被阻塞,直到有事件发生或超时
  5. 如果发生了事件,内核会将就绪列表中的文件描述符拷贝到用户态空间的 events 数组中,并唤醒等待的进程
  6. 用户态程序根据返回的就绪文件描述符个数,遍历events数组,对就绪的文件描述符进行相应的读写操作
  7. 重复步骤2和步骤3.直到所有文件描述符都处理完毕
  8. 调用 close 关闭 epoll 实例的文件描述符

epoll函数使用举例

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <sys/epoll.h>

int main() {
    // 创建套接字
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket");
        exit(1);
    }

    // 绑定地址和端口
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8888);
    server_addr.sin_addr.s_addr = INADDR_ANY;

    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        exit(1);
    }

    // 监听连接
    if (listen(server_fd, 5) == -1) {
        perror("listen");
        exit(1);
    }

    // 创建epoll实例
    int epoll_fd = epoll_create1(0);
    if (epoll_fd == -1) {
        perror("epoll_create1");
        exit(1);
    }

    // 将套接字添加到epoll实例中,并设置事件为可读
    struct epoll_event event;
    memset(&event, 0, sizeof(event));
    event.events = EPOLLIN; // 可读事件
    event.data.fd = server_fd;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event) == -1) {
        perror("epoll_ctl");
        exit(1);
    }

    // 主循环,等待事件发生
    while (1) {
        struct epoll_event events[10]; // 存储发生的事件
        int num_events = epoll_wait(epoll_fd, events, 10, -1); // 等待事件发生,超时时间为-1,表示无限等待
        if (num_events == -1) {
            perror("epoll_wait");
            exit(1);
        }

        // 处理发生的事件
        for (int i = 0; i < num_events; i++) {
            if (events[i].data.fd == server_fd) { // 如果是新的客户端连接
                struct sockaddr_in client_addr;
                socklen_t client_addr_len = sizeof(client_addr);
                int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_addr_len);
                if (client_fd == -1) {
                    perror("accept");
                    exit(1);
                }

                // 将新连接的客户端套接字添加到epoll实例中,并设置事件为可读
                event.events = EPOLLIN; // 可读事件
                event.data.fd = client_fd;
                if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event) == -1) {
                    perror("epoll_ctl");
                    exit(1);
                }
            } else { // 如果是已连接的客户端发送数据
                char buffer[1024];
                int bytes_read = read(events[i].data.fd, buffer, sizeof(buffer));
                if (bytes_read > 0) {
                    printf("Received data from client: %s", buffer);
                    write(events[i].data.fd, buffer, bytes_read); // 将数据回显给客户端
                } else if (bytes_read == 0) {
                    printf("Client disconnected");
                    close(events[i].data.fd); // 关闭客户端套接字
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, events[i].data.fd, NULL); // 从epoll实例中移除客户端套接字
                } else {
                    perror("read");
                    exit(1);
                }
            }
        }
    }

    // 关闭套接字和epoll实例
    close(server_fd);
    close(epoll_fd);

    return 0;
}

epoll函数的工作模式

epoll的工作模式主要有LT(水平触发)和ET(边沿触发)两种

  • LT模式:只要文件描述符的监听事件状态为真,每次调用 epoll_wait 都会返回这个文件描述符。也就是说只要有数据可读,则会一直通知,直到读取了所有的数据。
  • ET模式:只有在文件描述符的状态从非激活态变为激活态,才会触发通知。也就是说有部分数据可读,只会通知一次。

LT和ET的对比

  • ET模式仅在文件描述符状态发生变化时触发一次事件通知。LT模式只要文件描述符处于可读或可写状态,就会持续触发事件通知。
  • ET模式适合非阻塞的、有效利用CPU的高性能IO模式。LT模式对于阻塞IO或者不需要高性能的应用,可以选择LT模式,不需要循环读写,可以直接进行操作。

总体来说,ET模式适合高性能的网络服务器应用,而LT模式适合一般性的应用场景。需要根据实际情况选择适合的模式来处理事件。

epoll函数的优缺点

epoll函数的优点

  • 当检查大量的文件描述符时,epoll的性能扩展性比select和poll高很多
  • epoll api支持水平触发和边缘触发两种模式,给编程人员提供更大的灵活性
  • epoll使用一组函数完成,并且把用户关心的文件描述符上的事件放在内核里的一个事件表中,无需每次调用都重复传入文件描述符集合事件集

epoll函数的缺点

  • epoll在处理大量并发连接时表现出色,但其多线程扩展性上存在一定问题,无法很好的满足需求
  • epoll支持的最大链接数是进程最大可打开的文件的数目。对于fd数量较少并且fd IO都非常繁忙的情况,epoll的性能较低
  • 在一些简单的网络编程场景中,如单进程单线程处理少量连接时,select可能会比epoll更加简单易用。

select、poll、epoll对比

selectpollepoll
获取就绪fd的方式遍历遍历回调
底层数据结构bitmap链表双向链表
获取就绪fd的事件复杂度OnOnO1
最大文件描述符有限制6553565535
最大连接数1024无限制无限制
FD数据拷贝方式将fd数据从用户空间拷贝到内核空间将fd数据从用户空间拷贝到内核空间使用内存映射,不需要将fd数据频繁拷贝到内核空间