Select到Epoll的底层实现(涵盖红黑树,事件驱动,mmap,kqueue)

epoll与select的区别?

从效率上讲,上层调用select,内核会执行do_select,轮询遍历所有文件描述符,时间复杂度为o(n),随着描述符的增大,效率会越慢,而epoll的文件描述符由红黑树管理,监控描述符状态变化,就绪时通过ep_callback回调函数将描述符状态写进就绪队列,时间复杂度为o(1);

从资源消耗上讲,每次调用select,内核都会执行copy_to_user和copy_from_user在内核态和用户态来回拷贝,且没有缓存机制,每次调用都要来回拷贝一次,资源消耗很大,而调用epoll时,内核会通过mmap映射的方式把就绪队列映射在用户态,避免了用户态和内核态的重复交互,资源消耗上大大提供;

从局限性来讲,select采用数组管理文件描述符,有一定的数量上限,epoll采用红黑树管理,理论上没有上限,但是epoll仅限用于linux,而select可以跨平台使用,

还有就是触发模式不同,select仅可以水平触发,epoll具有水平触发和边缘触发双模式,但默认是水平触发。

如果你认为我的回答还不错,那么接下来这篇文章会带你全面认识IO多路复用,和底层实现机制

1. I/O 多路复用的核心目标

I/O 多路复用的核心目标是:在单线程中同时监控多个文件描述符(fd)的状态变化(如可读、可写、异常),避免为每个文件描述符创建独立线程,降低系统的资源消耗,减少上下文切换带来的开销。

2. Select 的机制与局限性

(1) 核心原理

Select 的工作机制通过一个固定大小的 fd_set(位数组)来标记和存储待监控的文件描述符。当调用 select 时,用户空间会将 fd_set 数据结构全量拷贝到内核,内核通过轮询遍历这些 fd 来检查其状态(如是否可读、可写或发生异常)。在检查完成后,内核会修改 fd_set 来标记就绪的 fd,并通过 copy_to_user 将结果拷贝回用户态。

(2) 性能瓶颈
  • 时间复杂度:由于 select 需要对每一个文件描述符进行逐一检查,时间复杂度为 O(n),当监控的文件描述符数量增加时,效率急剧下降。
  • 资源消耗:每次调用 select 时都需要进行两次全量数据拷贝(用户态与内核态之间),这带来了较大的内存和 CPU 开销。
  • 数量限制fd_set 的大小通常由 FD_SETSIZE 限制(一般为 1024),无法灵活扩展,限制了其在高并发场景下的应用。
 (3)select相关 API 总结
API作用
select监控文件描述符的状态变化,检查哪些文件描述符就绪
FD_ZERO清空一个文件描述符集合
FD_SET将一个文件描述符加入到集合中
FD_CLR从文件描述符集合中删除一个文件描述符
FD_ISSET检查某个文件描述符是否就绪
FD_COPY复制文件描述符集合
struct timeval用于指定 select 函数的超时时间
(4) TCP 套接字的select示例
#define PORT 12345

int main() {
    int server_fd, new_fd;
    struct sockaddr_in server_addr;
    char buffer[1024];
    fd_set read_fds;
    struct timeval timeout;

    // 创建服务器套接字
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    // 设置服务器地址
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);
    // 绑定套接字
    if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) 
    // 监听套接字
    if (listen(server_fd, 3) == -1) 
    // 设置超时时间
    timeout.tv_sec = 5;
    timeout.tv_usec = 0;
    // 初始化文件描述符集
    FD_ZERO(&read_fds);
    FD_SET(server_fd, &read_fds); // 监控 server_fd

    while (1) {
        // 使用 select 监听文件描述符
        fd_set temp_fds = read_fds; // 创建一个临时的文件描述符集合
        select(server_fd + 1, &temp_fds, NULL, NULL, &timeout);
        // 检查是否有新的连接请求
        if (FD_ISSET(server_fd, &temp_fds)) {
            new_fd = accept(server_fd, NULL, NULL);
            // 将新的文件描述符添加到监控集合中
            FD_SET(new_fd, &read_fds);
        }

        // 检查已连接的客户端是否有数据可读
        for (int i = 0; i <= server_fd; i++) {
            if (FD_ISSET(i, &temp_fds) && i != server_fd) {
                int bytes_read = read(i, buffer, sizeof(buffer) - 1);
                if (bytes_read == -1) {
                    perror("read failed");
                } else if (bytes_read == 0) {
                    printf("Client disconnected.\n");
                    close(i);  // 客户端断开连接
                    FD_CLR(i, &read_fds);  // 从文件描述符集合中移除
                } else {
                    buffer[bytes_read] = '\0';  // 确保字符串正确结束
                    printf("Received: %s\n", buffer);
                }
            }
(5)select 的流程可以详细总结为以下步骤:
  1. 初始化文件描述符集合:首先,通过 FD_ZERO 清空文件描述符集合,接着使用 FD_SET 将需要监控的文件描述符添加到集合中。这些文件描述符可以是套接字、管道等。

  2. 设置超时时间:通过 struct timeval 设置超时时间(tv_sec 为秒数,tv_usec 为微秒数),如果不想设置超时,可以传递 NULL 使 select 一直阻塞直到有事件发生。

  3. 调用 select 函数:在调用 select 时,传递文件描述符集合、指定监控的类型(可读、可写、异常)以及超时时间。select 会阻塞直到有文件描述符的状态发生变化(例如可读、可写或发生异常)。

  4. 检查 select 返回结果select 返回后,检查返回值来确认哪些文件描述符的状态已经发生变化。返回值为 0 表示超时,负值表示错误,正值表示就绪的文件描述符个数。

  5. 通过 FD_ISSET 检查文件描述符是否就绪:对于每个监控的文件描述符,通过 FD_ISSET 检查该文件描述符是否已经就绪。如果就绪,则可以执行相应的 I/O 操作。

  6. 处理就绪事件:一旦发现某个文件描述符就绪,进行相应的读写操作,或者处理异常。

3. Epoll 的优化与底层实现

(1) 核心组件

Epoll 引入了多个重要的优化点来解决 select 的性能瓶颈:

  • 红黑树:用于管理所有待监控的文件描述符。红黑树的插入和删除操作的时间复杂度为 O(log n),支持高效地管理大量的文件描述符。
  • 就绪队列(双向链表):用于存储已经就绪的文件描述符。事件触发时,内核会通过回调函数 ep_poll_callback 动态地将就绪的 fd 添加到就绪队列中。
  • mmap 内存映射:通过用户态和内核态共享内存来避免数据的拷贝,从而实现零拷贝。
(2) 工作流程

Epoll 的工作流程如下:

  1. epoll_create:创建一个 epoll 实例,并初始化红黑树和就绪队列。
  2. epoll_ctl:通过 epoll_ctl 向红黑树中添加、删除或修改监控的 fd,同时注册回调函数。
  3. epoll_wait:用来检查就绪队列:
    • 如果队列非空,直接返回已就绪的 fd(O(1) 时间复杂度)。
    • 如果队列为空,则阻塞等待事件的触发。
(3) 触发模式

Epoll 提供两种触发模式:

  • 水平触发(LT,默认模式):只要文件描述符处于就绪状态,每次 epoll_wait 调用都会返回该 fd。
  • 边缘触发(ET):仅在文件描述符状态变化时触发一次,需要配合非阻塞 I/O 和循环读写来避免数据遗漏。
(4) 性能优势
  • 时间复杂度:就绪事件的处理时间为 O(1),监控 fd 的管理为 O(log n)。
  • 零拷贝机制:通过 mmap 共享内存来消除用户态与内核态之间的数据拷贝,提高了性能。
  • 无数量限制:仅受限于系统的最大文件描述符数量(可通过 ulimit 调整),相比 select 的固定限制,Epoll 在高并发下更具优势。

int main() {
    int server_fd, new_fd, epoll_fd;
    struct sockaddr_in server_addr;
    char buffer[1024];
    struct epoll_event ev, events[10];

    // 创建服务器套接字
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    // 设置服务器地址
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);
    // 绑定套接字
    bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
    // 监听套接字
    listen(server_fd, 10);

    // 创建 epoll 实例
    epoll_fd = epoll_create1(0);
    ev.events = EPOLLIN;
    ev.data.fd = server_fd;
    // 将 server_fd 添加到 epoll 实例
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev);

    while (1) {
        // 等待事件发生
        int n = epoll_wait(epoll_fd, events, 10, -1);
        for (int i = 0; i < n; i++) {
            if (events[i].data.fd == server_fd) {
                // 新连接到来
                new_fd = accept(server_fd, NULL, NULL);
                ev.events = EPOLLIN;
                ev.data.fd = new_fd;
                epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_fd, &ev);
            } else {
                // 读取客户端数据
                int bytes_read = read(events[i].data.fd, buffer, sizeof(buffer) - 1);
                if (bytes_read == -1) {
                    perror("read failed");
                } else if (bytes_read == 0) {
                    // 客户端断开连接
                    close(events[i].data.fd);
                } else {
                    buffer[bytes_read] = '\0';
                    printf("Received: %s\n", buffer);
                }
            }
  
    close(server_fd);
    return 0;
}
  1. 创建套接字和绑定

    1. 创建一个 TCP 套接字 (server_fd)。
    2. 设置服务器地址,绑定套接字到指定端口(PORT 12345),并开始监听。
  2. 创建 epoll 实例

    1. 使用 epoll_create1(0) 创建一个 epoll 实例,返回一个 epoll_fd,用于监控多个文件描述符的事件。
  3. 将服务器套接字添加到 epoll 实例

    1. 使用 epoll_ctl() 将服务器套接字(server_fd)添加到 epoll 实例中,并设置其监控事件为 EPOLLIN(表示该文件描述符可读)。
  4. 事件循环

    1. 进入一个 while 循环,不断等待和处理事件。
    2. 使用 epoll_wait() 阻塞等待事件的发生,并返回就绪事件列表 events
  5. 处理新连接

    1. 如果事件的文件描述符是 server_fd(即有新连接到来),则调用 accept() 接受连接,返回新连接的文件描述符(new_fd)。
    2. 将新的文件描述符 new_fd 添加到 epoll 实例中,设置其事件为 EPOLLIN(表示可读)。
  6. 处理客户端数据

    1. 对于其他就绪的文件描述符,表示已经连接的客户端有数据可读。
    2. 使用 read() 读取客户端发送的数据,处理后打印出来。如果客户端断开连接,关闭该文件描述符。
  7. 关闭套接字

    1. 在程序结束时关闭 server_fd

4. Select 与 Epoll 的对比

特性SelectEpoll
数据结构固定大小位数组(fd_set)红黑树 + 就绪队列
时间复杂度O(n) 轮询O(1) 就绪事件处理 + O(log n) 管理
内存拷贝每次调用需要全量拷贝 fd_set通过 mmap 共享内存,零拷贝
最大 fd 数量有限(通常为 1024)理论无上限
触发模式仅支持水平触发(LT)支持水平触发(LT)和边缘触发(ET)
跨平台性支持(POSIX 标准)Linux 独占

5. 其他多路复用技术

(1) Poll
  • 改进点:使用链表而非位数组,突破了 fd_set 的数量限制。
  • 局限性:仍然需要 O(n) 的轮询和全量数据拷贝,因此其性能与 select 相似。
(2) Kqueue(FreeBSD/macOS)
  • 设计思想:类似于 Epoll,采用事件驱动和回调机制来实现高效的文件描述符监控。
  • 数据结构:使用二叉堆(如果量级较大,可能用红黑树进行优化)来管理文件描述符。
  • 优势:支持更复杂的事件类型(如文件修改、信号通知等),比 Epoll 更适合一些特定场景。

6. Epoll 的底层实现细节

(1) 红黑树的作用
核心目标:高效管理海量文件描述符(fd)
  • 动态增删的高效性
    红黑树是内核中用于管理所有通过 epoll_ctl 注册的 fd 的核心数据结构。每个 fd 对应一个 epitem 结构体(存储 fd、事件类型、回调函数指针等信息)。

    • 插入/删除:epoll_ctl(EPOLL_CTL_ADD/DEL) 操作时,内核以 fd 为键值,在红黑树中快速定位节点,时间复杂度为 O(log N),适合高并发场景(如百万连接)。
    • 查找:当需要修改监听事件(如 EPOLLIN → EPOLLOUT)时,红黑树能快速找到对应节点。
  • 自平衡特性
    红黑树通过颜色标记和旋转规则,始终保持近似平衡,避免退化为链表,确保操作效率稳定。

(2) 回调机制(ep_poll_callback)
核心目标:事件驱动的就绪队列更新
  • 回调注册
    当通过 epoll_ctl 添加 fd 时,内核会为该 fd 绑定一个回调函数 ep_poll_callback,并将其挂载到设备的等待队列(如 socket 的 sk_wq)。这一过程由内核协议栈或驱动完成。

  • 触发条件
    当 fd 的状态变化(如 socket 收到数据、TCP 连接完成),底层硬件(网卡)会触发中断,内核协议栈处理完数据后,调用该 fd 的等待队列中的回调函数。

红黑树与回调的协作流程

示例:TCP 数据到达
  1. 网卡收到数据包,通过 DMA 写入内核内存,触发中断。
  2. 内核协议栈解析数据,将数据存入 socket 的接收缓冲区。
  3. 协议栈检查 socket 的等待队列,调用 ep_poll_callback
  4. 回调函数将对应的 epitem 添加到 rdllist,并唤醒 epoll_wait
  5. epoll_wait 从内核返回,用户程序直接从 rdllist 获取就绪的 fd 列表。
(3) mmap 内存映射
核心目标:零拷贝(Zero-Copy)传递事件
  • 传统问题
    select/poll 需要将整个 fd 集合从用户态拷贝到内核态,返回时再将就绪状态拷贝回用户态,存在两次拷贝开销。

  • 共享内存:通过 mmap 将内核的「就绪队列」映射到用户空间,用户程序可直接读取就绪事件,无需数据拷贝。

    • 事件传递epoll_wait 返回时,用户程序遍历 events 数组(映射到内核的 rdllist),直接处理就绪 fd。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值