IO多路复用

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读写过程

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

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

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

IO多路复用的核心实现

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

通道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事件进行监控,提高系统的并发性能。

IO多路复用的函数

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

  1. select
  2. poll
  3. epoll

select函数

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

select函数原型

java 复制代码
#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函数使用举例

c 复制代码
#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函数原型

c 复制代码
#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函数使用举例

c 复制代码
#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函数原型

c 复制代码
#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函数使用举例

c 复制代码
#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对比

select poll epoll
获取就绪fd的方式 遍历 遍历 回调
底层数据结构 bitmap 链表 双向链表
获取就绪fd的事件复杂度 On On O1
最大文件描述符 有限制 65535 65535
最大连接数 1024 无限制 无限制
FD数据拷贝方式 将fd数据从用户空间拷贝到内核空间 将fd数据从用户空间拷贝到内核空间 使用内存映射,不需要将fd数据频繁拷贝到内核空间
相关推荐
魔道不误砍柴功2 分钟前
Java 中如何巧妙应用 Function 让方法复用性更强
java·开发语言·python
NiNg_1_2342 分钟前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
闲晨5 分钟前
C++ 继承:代码传承的魔法棒,开启奇幻编程之旅
java·c语言·开发语言·c++·经验分享
测开小菜鸟2 小时前
使用python向钉钉群聊发送消息
java·python·钉钉
P.H. Infinity3 小时前
【RabbitMQ】04-发送者可靠性
java·rabbitmq·java-rabbitmq
生命几十年3万天3 小时前
java的threadlocal为何内存泄漏
java
caridle3 小时前
教程:使用 InterBase Express 访问数据库(五):TIBTransaction
java·数据库·express
^velpro^3 小时前
数据库连接池的创建
java·开发语言·数据库
苹果醋33 小时前
Java8->Java19的初步探索
java·运维·spring boot·mysql·nginx
秋の花3 小时前
【JAVA基础】Java集合基础
java·开发语言·windows