Skip to content

Linux IO 多路复用:select 到 epoll

Linux IO 多路复用:select 到 epoll

在构建高并发网络服务器(如 Nginx、Redis、Netty、Envoy)时,如何高效处理成千上万个并发客户端连接(C10K / C1000K 问题)是系统架构的核心挑战。

Linux 操作系统经历了从阻塞 IO、多进程/多线程模型,到 IO 多路复用 (IO Multiplexing) selectpoll 直至 epoll 的演进。本文将从 Linux 内核数据结构视角深入解析这一演进轨迹。


一、从 select / poll 到 epoll 的演进图谱

核心特性 select poll epoll (Linux 2.6+)
最大连接数限制 默认 FD_SETSIZE (固定 1024) 无上限(基于链表),受限于系统最大句柄数 无上限(仅受内存物理限制)
内核数据结构 线性位图 (Bit Array) 结构体数组 (struct pollfd[]) 红黑树 (RB-Tree) + 就绪双向链表 (Ready List)
FD 传递开销 每次调用均需从用户态全量复制到内核态 每次调用均需全量复制数组 epoll_ctl 仅增量注册一次,零重复拷贝
时间复杂度 \(O(N)\) 遍历全量 FD 检查就绪状态 \(O(N)\) 线性遍历 \(O(1)\) 直接返回就绪就绪链表
工作触发模式 仅支持水平触发 (LT) 仅支持水平触发 (LT) 支持 LT (水平触发) 与 ET (边缘触发)
graph TD
    subgraph epoll_Kernel_Space [Linux 内核 epoll 实例结构 (struct eventpoll)]
        RBRoot["红黑树 rbr: 快速 O(log N) 增删查监听的 Socket FD"]
        RDLst["就绪双向链表 rdllist: 仅存放当前有事件发生的 FD"]
        WQSock[Socket 等待队列与中断回调函数 ep_poll_callback]
    end

    NIC[网卡收到数据包] --> HardIRQ[硬件中断]
    HardIRQ --> SoftIRQ[协议栈处理并触发 ep_poll_callback]
    SoftIRQ --> InsertReady[将该 Socket 节点直接插入 rdllist 就绪链表]
    InsertReady --> WakeUser["唤醒 epoll_wait() 用户态线程,时间复杂度 O(1)"]

二、epoll 三大核心系统调用

  1. epoll_create1(int flags):在内核中创建 eventpoll 实例,分配红黑树根节点与就绪链表。
  2. epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
  3. 往红黑树中增删改监听目标(EPOLL_CTL_ADD / MOD / DEL);
  4. 为该 Socket 文件描述符注册内核等待队列回调 ep_poll_callback
  5. epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
  6. 阻塞等待就绪链表非空;
  7. 仅将就绪的双向链表节点复制到用户态传入的 events 缓冲区中返回。

三、生产级非阻塞 ET 模式网络服务器 C 语言实战

边缘触发(Edge Triggered, ET)仅在状态发生跳变(如从未就绪变为就绪)时通知一次,能大幅减少事件被反复唤醒的系统调用损耗。但必须搭配 Non-blocking Socket (非阻塞)循环 read() 直至 EAGAIN

// epoll_et_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/epoll.h>

#define MAX_EVENTS 1024
#define BUFFER_SIZE 4096

// 设置文件描述符为非阻塞模式
int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblocking(listen_fd);

    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(8080);

    bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
    listen(listen_fd, SOMAXCONN);

    int epoll_fd = epoll_create1(0);
    struct epoll_event ev, events[MAX_EVENTS];

    // 监听 listen_fd 的读事件 (使用水平触发 LT 便于处理 accept)
    ev.events = EPOLLIN;
    ev.data.fd = listen_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);

    printf("📡 High Performance epoll server listening on port 8080...\n");

    while (1) {
        int n_fds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        for (int i = 0; i < n_fds; i++) {
            if (events[i].data.fd == listen_fd) {
                // 处理新接入客户端连接
                struct sockaddr_in client_addr;
                socklen_t client_len = sizeof(client_addr);
                int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
                set_nonblocking(client_fd);

                // 注册客户端为 边缘触发 (EPOLLET) + 读事件
                ev.events = EPOLLIN | EPOLLET;
                ev.data.fd = client_fd;
                epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);
            } else if (events[i].events & EPOLLIN) {
                // 处理已连接 Socket 的数据读取 (必须循环读取直到返回 EAGAIN)
                int client_fd = events[i].data.fd;
                char buffer[BUFFER_SIZE];

                while (1) {
                    ssize_t bytes_read = read(client_fd, buffer, sizeof(buffer));
                    if (bytes_read > 0) {
                        // 业务处理并回写数据
                        write(client_fd, buffer, bytes_read);
                    } else if (bytes_read == 0) {
                        // 客户端正常关闭连接
                        close(client_fd);
                        epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL);
                        break;
                    } else {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            // 数据已全量读完,退出循环等待下次边缘触发
                            break;
                        }
                        // 读取发生异常
                        close(client_fd);
                        epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL);
                        break;
                    }
                }
            }
        }
    }
    return 0;
}

四、ET 模式生产避坑要点

  1. 死锁式数据截断:如果在 ET 模式下只调用了一次 read(),未读完的数据将停留在内核缓冲区中,且由于没有新的数据包到达触发边缘跳变,该连接将永久陷入饥饿等待。
  2. 惊群效应 (Thundering Herd):多工作进程共同监听同一个 epoll_fd 时,新连接到达可能唤醒所有进程。Linux 3.9+ 提供了 SO_REUSEPORT 套接字选项,由内核层面进行高效的四层负载均衡分发。