IO多路复用模型:高效并发处理的实现机制深度剖析
本文从基本概念入手,系统剖析了主流IO多路复用模型(select、poll、epoll等)的实现原理,并通过代码示例和图解,帮助读者更直观地理解、掌握高效并发处理的核心技术。
目录
1. 背景与挑战
在网络编程或高并发服务器设计中,经常会遇到“用一个线程/进程如何同时处理成百上千个网络连接”这样的问题。传统的阻塞式IO(Blocking IO)需要为每个连接都分配一个独立线程或进程,这在连接数骤增时会导致:
- 线程/进程上下文切换开销巨大
- 系统资源(内存、文件描述符表)耗尽
- 性能瓶颈明显
IO多路复用(I/O Multiplexing)的提出,正是为了在单个线程/进程中“同时监控多个文件描述符(socket、管道、文件等)的可读可写状态”,一旦某个或多个文件描述符就绪,才去执行对应的读/写操作。它大幅降低了线程/进程数量,极大提高了系统并发处理能力与资源利用率。
2. 什么是IO多路复用
IO多路复用通俗地说,就是“一个线程(或进程)监视多个IO句柄(文件描述符),等待它们中任何一个或多个变为可读/可写状态,然后一次或多次地去处理它们”。从API角度来看,最常见的几种模型是在不同操作系统上以不同名称出现,但核心思想一致:事件驱动。
- 事件驱动:只在IO事件(可读、可写、异常)发生时才去调用对应的操作,从而避免了无谓的阻塞与轮询。
- 单/少量线程:往往只需一个主循环(或少数线程/进程)即可处理海量并发连接,避免了线程/进程切换开销。
常见的IO多路复用模型:
- select:POSIX 标准,跨平台,但在大并发场景下效率低;
- poll:也是 POSIX 标准,对比- select,避免了最大文件描述符数限制(- FD_SETSIZE),但仍需遍历阻塞;
- epoll:Linux 专有,基于内核事件通知机制设计,性能优异;
- kqueue:FreeBSD/macOS 专有,类似于- epoll;
- IOCP:Windows 平台专用,基于完成端口。
本文将重点讲解 Linux 下的 select、poll、epoll 三种模型(由于它们演进关系最为典型),并结合图解与代码示例,帮助读者深入理解工作原理与性能差异。
3. select模型详解
3.1 工作流程
- 建立监听:首先,服务器创建一个或多个套接字(socket),绑定地址、监听端口;
- 初始化fd\_set:在每一次循环中,使用 FD_ZERO清空读/写/异常集合,然后通过FD_SET将所有需要监视的描述符(如监听socket、客户端socket)加入集合;
- 调用 select:调用 - select(maxfd+1, &read_set, &write_set, &except_set, timeout);- 内核会将这些集合从用户空间复制到内核空间,然后进行阻塞等待;
- 当任一文件描述符就绪(可读、可写或异常)时返回,并将对应集合(read\_set,write\_set)中“可用”的描述符位置标记;
 
- 遍历检测:用户线程遍历 FD_ISSET判断到底哪个 FD 又变得可读/可写,针对不同事件进行accept/read/write等操作;
- 循环执行:处理完所有就绪事件后,返回步骤 2,重新设置集合,继续监听。
由于每次 select 调用都需要将整个 fd 集合在用户态和内核态之间复制,并且在内核态内部进行一次线性遍历,且每次返回后还要进行一次线性遍历检查,就绪状态,这导致 select 的效率在文件描述符数量较大时急剧下降。
3.2 数据结构与调用方式
#include <sys/select.h>
#include <sys/time.h>
#include <unistd.h>
#include <stdio.h>
int select_example(int listen_fd) {
    fd_set read_fds;
    struct timeval timeout;
    int maxfd, nready;
    // 假设只监听一个listen_fd,或再加若干client_fd
    while (1) {
        FD_ZERO(&read_fds);                       // 清空集合
        FD_SET(listen_fd, &read_fds);             // 将listen_fd加入监视
        maxfd = listen_fd;                        // maxfd 初始为监听套接字
        // 如果有多个client_fd,此处需要将它们一并加入read_fds,并更新maxfd为最大fd值
        // 设置超时时间,比如1秒
        timeout.tv_sec = 1;
        timeout.tv_usec = 0;
        // 监视read事件(可以同时监视write_events/except_events)
        nready = select(maxfd + 1, &read_fds, NULL, NULL, &timeout);
        if (nready < 0) {
            perror("select error");
            return -1;
        } else if (nready == 0) {
            // 超时无事件
            continue;
        }
        // 判断监听FD是否就绪:表示有新连接
        if (FD_ISSET(listen_fd, &read_fds)) {
            int conn_fd = accept(listen_fd, NULL, NULL);
            printf("新客户端连接,fd=%d\n", conn_fd);
            // 将conn_fd添加到client集合,后续循环要监视conn_fd
        }
        // 遍历所有已注册的client_fd
        // if (FD_ISSET(client_fd, &read_fds)) {
        //     // 可读:处理客户端请求
        // }
    }
    return 0;
}主要函数及宏:
- FD_ZERO(fd_set *set):清空集合;
- FD_SET(int fd, fd_set *set):将- fd添加到集合;
- FD_CLR(int fd, fd_set *set):将- fd从集合中删除;
- FD_ISSET(int fd, fd_set *set):测试- fd是否在集合中;
- select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
其中,nfds 要传 max_fd + 1,表示要监视的文件描述符范围是 [0, nfds)。
3.3 示意图说明
┌──────────────────────────────────────────────────────────┐
│            用户空间:主线程(或进程)循环                  │
│  ┌──────────┐        ┌──────────┐       ┌───────────┐   │
│  │ FD_ZERO  │        │ FD_SET   │       │ select()  │   │
│  └──────────┘        └──────────┘       └─────┬─────┘   │
│                                               │         │
│                                               ▼         │
│            内核空间:                               │
│  ┌────────────────────────────────────────────────────┐│
│  │ 内核复制read_fds集合 & write_fds集合到内存           ││
│  │ 并遍历这些文件描述符,等待任何一个就绪                ││
│  │ 若超时或FD就绪,则select() 返回                         ││
│  └────────────────────────────────────────────────────┘│
│                                               │         │
│                                               ▼         │
│  ┌──────────┐        ┌──────────┐       ┌───────────┐   │
│  │ FD_ISSET │        │ 处理事件  │       │ 继续循环   │   │
│  └──────────┘        └──────────┘       └───────────┘   │
└──────────────────────────────────────────────────────────┘- 用户态将关心的 FD 放入 fd_set;
- 调用 select,内核会复制集合并阻塞;
- 内核检查每个 FD 的状态,若有就绪则返回;
- 用户态遍历判断哪个 FD 就绪,执行相应的 IO 操作;
- 处理完成后,用户态再次构造新的 fd_set循环往复。
3.4 代码示例:服务器端多路复用(select)
以下示例演示了一个简单的 TCP 服务器,使用 select 监听多个客户端连接。
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
#define SERV_PORT 8888
#define MAX_CLIENT 1024
int main() {
    int listen_fd, conn_fd, sock_fd, max_fd, i, nready;
    int client_fds[MAX_CLIENT];
    struct sockaddr_in serv_addr, cli_addr;
    socklen_t cli_len;
    fd_set all_set, read_set;
    char buf[1024];
    // 创建监听套接字
    if ((listen_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        perror("socket error");
        exit(1);
    }
    // 端口重用
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    // 绑定
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(SERV_PORT);
    if (bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
        perror("bind error");
        exit(1);
    }
    // 监听
    if (listen(listen_fd, 10) < 0) {
        perror("listen error");
        exit(1);
    }
    // 初始化客户端fd数组为 -1
    for (i = 0; i < MAX_CLIENT; i++) {
        client_fds[i] = -1;
    }
    max_fd = listen_fd;
    FD_ZERO(&all_set);
    FD_SET(listen_fd, &all_set);
    printf("服务器启动,监听端口 %d...\n", SERV_PORT);
    while (1) {
        read_set = all_set;  // 每次循环都要重新赋值
        nready = select(max_fd + 1, &read_set, NULL, NULL, NULL);
        if (nready < 0) {
            perror("select error");
            break;
        }
        // 如果监听套接字可读,表示有新客户端连接
        if (FD_ISSET(listen_fd, &read_set)) {
            cli_len = sizeof(cli_addr);
            conn_fd = accept(listen_fd, (struct sockaddr *)&cli_addr, &cli_len);
            if (conn_fd < 0) {
                perror("accept error");
                continue;
            }
            printf("新连接:%s:%d, fd=%d\n",
                   inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), conn_fd);
            // 将该conn_fd加入数组
            for (i = 0; i < MAX_CLIENT; i++) {
                if (client_fds[i] < 0) {
                    client_fds[i] = conn_fd;
                    break;
                }
            }
            if (i == MAX_CLIENT) {
                printf("太多客户端,无法处理\n");
                close(conn_fd);
                continue;
            }
            FD_SET(conn_fd, &all_set);
            max_fd = (conn_fd > max_fd) ? conn_fd : max_fd;
            if (--nready <= 0)
                continue;  // 如果没有剩余事件,跳过后续client轮询
        }
        // 检查每个已连接的客户端
        for (i = 0; i < MAX_CLIENT; i++) {
            sock_fd = client_fds[i];
            if (sock_fd < 0)
                continue;
            if (FD_ISSET(sock_fd, &read_set)) {
                int n = read(sock_fd, buf, sizeof(buf));
                if (n <= 0) {
                    // 客户端关闭
                    printf("客户端fd=%d断开\n", sock_fd);
                    close(sock_fd);
                    FD_CLR(sock_fd, &all_set);
                    client_fds[i] = -1;
                } else {
                    // 回显收到的数据
                    buf[n] = '\0';
                    printf("收到来自fd=%d的数据:%s\n", sock_fd, buf);
                    write(sock_fd, buf, n);
                }
                if (--nready <= 0)
                    break;  // 本次select只关注到这么多事件,跳出
            }
        }
    }
    close(listen_fd);
    return 0;
}代码说明
- client\_fds 数组:记录所有已连接客户端的 fd,初始化为-1;
- all\_set:表示当前需要监视的所有 FD;
- read\_set:在每次调用 select前,将all_set复制到read_set,因为select会修改read_set;
- max\_fd:传给 select的第一个参数为max_fd + 1,其中max_fd是当前监视的最大 FD;
- 循环逻辑:先判断监听 listen_fd是否可读(新连接),再遍历所有客户端fd,处理就绪的读事件;
3.5 优缺点分析
- 优点 - 跨平台:几乎所有类 Unix 系统都支持;
- 使用简单,学习成本低;
 
- 缺点 - 文件描述符数量限制:通常受限于 FD_SETSIZE(1024),可以编译期调整,但并不灵活;
- 整体遍历开销大:每次调用都需要将整个 fd_set从用户空间拷贝到内核空间,且内核要遍历一遍;
- 不支持高效的“就绪集合”访问:用户态获知哪些 FD 就绪后,仍要线性遍历检查。
 
- 文件描述符数量限制:通常受限于 
4. poll模型详解
4.1 工作流程与区别
poll 与 select 思想相似,都是阻塞等待内核返回哪些 FD 可读/可写。不同点:
- 数据结构不同:select使用固定长度的fd_set,而poll使用一个struct pollfd[]数组;
- 文件描述符数量限制:poll不受固定大小限制,只要系统支持即可;
- 调用方式:通过 poll(struct pollfd *fds, nfds_t nfds, int timeout);
- 就绪检测:poll的返回结果将直接写回到revents字段,无需用户二次FD_ISSET检测;
struct pollfd 定义:
struct pollfd {
    int   fd;         // 要监视的文件描述符
    short events;     // 关注的事件,例如 POLLIN、POLLOUT
    short revents;    // 返回时就绪的事件
};- events可以是:- POLLIN:表示可读;
- POLLOUT:表示可写;
- POLLPRI:表示高优先级数据可读(如带外数据);
- POLLERR、- POLLHUP、- POLLNVAL:表示错误、挂起、无效 FD;
 
- 返回值: - 返回就绪 FD 数量(>0);
- 0表示超时;
- <0表示出错。
 
4.2 代码示例:使用 poll 的并发服务器
#include <arpa/inet.h>
#include <netinet/in.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#define SERV_PORT 8888
#define MAX_CLIENT 1024
int main() {
    int listen_fd, conn_fd, i, nready;
    struct sockaddr_in serv_addr, cli_addr;
    socklen_t cli_len;
    char buf[1024];
    struct pollfd clients[MAX_CLIENT + 1]; // clients[0] 用于监听套接字
    // 创建监听套接字
    if ((listen_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        perror("socket error");
        exit(1);
    }
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(SERV_PORT);
    if (bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
        perror("bind error");
        exit(1);
    }
    if (listen(listen_fd, 10) < 0) {
        perror("listen error");
        exit(1);
    }
    // 初始化 pollfd 数组
    for (i = 0; i < MAX_CLIENT + 1; i++) {
        clients[i].fd = -1;
        clients[i].events = 0;
        clients[i].revents = 0;
    }
    clients[0].fd = listen_fd;
    clients[0].events = POLLIN; // 只关注可读事件(新连接)
    printf("服务器启动,监听端口 %d...\n", SERV_PORT);
    while (1) {
        // nfds 是数组长度,这里固定为 MAX_CLIENT+1
        nready = poll(clients, MAX_CLIENT + 1, -1);
        if (nready < 0) {
            perror("poll error");
            break;
        }
        // 检查监听套接字
        if (clients[0].revents & POLLIN) {
            cli_len = sizeof(cli_addr);
            conn_fd = accept(listen_fd, (struct sockaddr *)&cli_addr, &cli_len);
            if (conn_fd < 0) {
                perror("accept error");
            } else {
                printf("新连接:%s:%d, fd=%d\n",
                       inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), conn_fd);
                // 将新 conn_fd 加入数组
                for (i = 1; i < MAX_CLIENT + 1; i++) {
                    if (clients[i].fd < 0) {
                        clients[i].fd = conn_fd;
                        clients[i].events = POLLIN; // 只关注可读
                        break;
                    }
                }
                if (i == MAX_CLIENT + 1) {
                    printf("太多客户端,无法处理\n");
                    close(conn_fd);
                }
            }
            if (--nready <= 0)
                continue;
        }
        // 检查每个客户端
        for (i = 1; i < MAX_CLIENT + 1; i++) {
            int sock_fd = clients[i].fd;
            if (sock_fd < 0)
                continue;
            if (clients[i].revents & (POLLIN | POLLERR | POLLHUP)) {
                int n = read(sock_fd, buf, sizeof(buf));
                if (n <= 0) {
                    printf("客户端fd=%d断开\n", sock_fd);
                    close(sock_fd);
                    clients[i].fd = -1;
                } else {
                    buf[n] = '\0';
                    printf("收到来自fd=%d的数据:%s\n", sock_fd, buf);
                    write(sock_fd, buf, n);
                }
                if (--nready <= 0)
                    break;
            }
        }
    }
    close(listen_fd);
    return 0;
}代码说明
- 使用 struct pollfd clients[MAX_CLIENT+1]数组存放监听套接字和所有客户端套接字;
- 每个元素的 events指定关注的事件,例如POLLIN;
- 调用 poll(clients, MAX_CLIENT + 1, -1)阻塞等待事件;
- 检查 revents字段即可直接知道相应 FD 的就绪类型,无需再调用FD_ISSET;
4.3 优缺点分析
- 优点 - 不受固定 FD_SETSIZE限制,可监视更多 FD;
- 代码逻辑上稍比 select简单:返回时直接通过revents判断就绪;
 
- 不受固定 
- 缺点 - 每次调用都需要将整个 clients[]数组拷贝到内核,并在内核内进行线性遍历;
- 当连接数巨大时,仍存在“O(n)”的开销;
- 并未从根本上解决轮询与拷贝带来的性能瓶颈。
 
- 每次调用都需要将整个 
5. epoll模型详解(Linux 专有)
5.1 设计思路与优势
Linux 内核在 2.6 版本加入了 epoll,其核心思想是采用事件驱动并结合回调/通知机制,让内核在 FD 就绪时将事件直接通知到用户态。相对于 select/poll,“O(1)” 的就绪检测和更少的用户<->内核数据拷贝成为 epoll 最大优势:
- 注册-就绪分离:在 epoll_ctl时,只需将关注的 FD 数据添加到内核维护的红黑树/链表中;
- 就绪时通知:当某个 FD 状态就绪时,内核将对应事件放入一个“就绪队列”,等待 epoll_wait提取;
- 减少拷贝:不需要每次调用都将整个 FD 集合从用户态拷贝到内核态;
- O(1) 性能:无论关注的 FD 数量多大,在没有大量就绪事件时,内核只需维持数据结构即可;
5.2 核心数据结构与系统调用
- epoll_create1(int flags):创建一个 epoll 实例,返回- epfd;
- epoll_ctl(int epfd, int op, int fd, struct epoll_event *event):用来向- epfd实例中添加/修改/删除要监听的 FD;- op:- EPOLL_CTL_ADD、- EPOLL_CTL_MOD、- EPOLL_CTL_DEL;
- struct epoll_event:- typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; // 关注或就绪的事件掩码 epoll_data_t data; // 用户数据,通常存放 fd 或者指针 };
- events:可位或关注类型,如- EPOLLIN、- EPOLLOUT、- EPOLLET、- EPOLLONESHOT等;
 
- epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout):阻塞等待内核把就绪事件写入指定- events数组,- maxevents指定数组长度,返回就绪事件个数;
- 内核维护两大数据结构: - 红黑树(RB-Tree):存储所有被 EPOLL_CTL_ADD注册的 FD;
- 就绪链表(Ready List):当某 FD 就绪时,内核将其加入链表;
 
- 红黑树(RB-Tree):存储所有被 
epoll 典型数据流程
- 注册阶段(epoll\_ctl) - 用户调用 epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
- 内核将 fd和对应关注事件ev.events、ev.data存入红黑树节点;
 
- 用户调用 
- 等待阶段(epoll\_wait) - 用户调用 epoll_wait(epfd, events, maxevents, timeout);
- 若此时没有任何已就绪节点,则阻塞等待;
- 当外部事件(如网络到达数据)导致 fd可读/可写,内核会在中断处理或协议栈回调中将对应节点添加到就绪链表;
- epoll_wait被唤醒后,直接将尽量多的就绪节点写入用户- events[]数组;
 
- 用户调用 
- 处理阶段(用户态) - 用户遍历 events[i].data.fd或者events[i].data.ptr,对就绪 FD 进行read/write操作;
- 若需要继续关注该 FD,可保留在 epoll 实例中,否则可通过 EPOLL_CTL_DEL删除;
 
- 用户遍历 
这样实现后,与 select/poll 相比的关键区别在于:
- 用户<->内核拷贝少:只在注册阶段一次、就绪阶段一次,而不是每次循环;
- 就绪直接链表产出:无需对所有注册的 FD 做线性扫描;
5.3 示意图说明
┌──────────────────────────────────────────────┐
│                用户空间                    │
│  ┌──────────────┐   epfd         ┌─────────┐ │
│  │ epoll_ctl()  │ ─────────────► │   内核   │ │
│  │ (注册/删除/修改)│               │         │ │
│  └──────────────┘               │   数据结构  │ │
│               ▲                  │(红黑树+链表)│ │
│               │ epfd             └─────────┘ │
│  ┌──────────────┐   epfd & events  ┌─────────┐ │
│  │ epoll_wait() │ ─────────────────► │ 就绪链表 │ │
│  │  阻塞等待     │                 └────┬────┘ │
│  └──────────────┘                      │      │
│                                         ▼      │
│                                    ┌────────┐  │
│                                    │ 返回就绪│  │
│                                    └────────┘  │
│                                          ▲     │
│                                          │     │
│                                    ┌────────┐  │
│                                    │ 处理就绪│  │
│                                    └────────┘  │
└──────────────────────────────────────────────┘- 首次调用 epoll_ctl时,内核将 FD 添加到红黑树(只拷贝一次);
- epoll_wait阻塞等待,被唤醒时直接从就绪链表中批量取出事件;
- 用户根据 events数组进行处理。
5.4 代码示例:使用 epoll 的服务器
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#define SERV_PORT 8888
#define MAX_EVENTS 1024
int main() {
    int listen_fd, conn_fd, epfd, nfds, i;
    struct sockaddr_in serv_addr, cli_addr;
    socklen_t cli_len;
    char buf[1024];
    struct epoll_event ev, events[MAX_EVENTS];
    // 1. 创建监听套接字
    if ((listen_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        perror("socket error");
        exit(1);
    }
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(SERV_PORT);
    if (bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
        perror("bind error");
        exit(1);
    }
    if (listen(listen_fd, 10) < 0) {
        perror("listen error");
        exit(1);
    }
    printf("服务器启动,监听端口 %d...\n", SERV_PORT);
    // 2. 创建 epoll 实例
    epfd = epoll_create1(0);
    if (epfd < 0) {
        perror("epoll_create1 error");
        exit(1);
    }
    // 3. 将 listen_fd 加入 epoll 监听(关注可读事件)
    ev.events = EPOLLIN;       // 只关注可读
    ev.data.fd = listen_fd;
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) < 0) {
        perror("epoll_ctl: listen_fd");
        exit(1);
    }
    // 4. 事件循环
    while (1) {
        // 阻塞等待事件,nfds 返回就绪事件数
        nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (nfds < 0) {
            perror("epoll_wait error");
            break;
        }
        // 遍历所有就绪事件
        for (i = 0; i < nfds; i++) {
            int cur_fd = events[i].data.fd;
            // 监听套接字可读:表示新客户端连接
            if (cur_fd == listen_fd) {
                cli_len = sizeof(cli_addr);
                conn_fd = accept(listen_fd, (struct sockaddr *)&cli_addr, &cli_len);
                if (conn_fd < 0) {
                    perror("accept error");
                    continue;
                }
                printf("新连接:%s:%d, fd=%d\n",
                       inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), conn_fd);
                // 将 conn_fd 加入 epoll,继续关注可读事件
                ev.events = EPOLLIN | EPOLLET;  // 使用边缘触发示例
                ev.data.fd = conn_fd;
                if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev) < 0) {
                    perror("epoll_ctl: conn_fd");
                    close(conn_fd);
                }
            }
            // 客户端套接字可读
            else if (events[i].events & EPOLLIN) {
                int n = read(cur_fd, buf, sizeof(buf));
                if (n <= 0) {
                    // 客户端关闭或错误
                    printf("客户端fd=%d断开 或 读错误: %s\n", cur_fd, strerror(errno));
                    close(cur_fd);
                    epoll_ctl(epfd, EPOLL_CTL_DEL, cur_fd, NULL);
                } else {
                    buf[n] = '\0';
                    printf("收到来自fd=%d的数据:%s\n", cur_fd, buf);
                    write(cur_fd, buf, n);  // 简单回显
                }
            }
            // 关注的其他事件(例如 EPOLLOUT、错误等)可在此处理
        }
    }
    close(listen_fd);
    close(epfd);
    return 0;
}代码说明
- 创建 epoll 实例:epfd = epoll_create1(0);;
- 注册监听套接字:ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);;
- 边缘触发示例:将新连接的 conn_fd加入时使用EPOLLIN | EPOLLET,表示边缘触发模式(详见 5.5 节);
- epoll\_wait 阻塞:nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);,events数组将返回最多MAX_EVENTS个就绪事件;
- 分发处理:遍历 events[i],通过events[i].data.fd取出就绪 FD,针对可读/可写/错误分别处理;
- 注销 FD:当客户端关闭时,需要调用 epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL)从 epoll 实例中清除。
5.5 边缘触发(Edge-Triggered)与水平触发(Level-Triggered)
| 模式 | 缩写 | 触发机制 | 使用场景 | 
|---|---|---|---|
| 水平触发(默认) | LT | 只要 FD 可读/可写,一直会触发事件 | 简单易用,但可能重复触发,需要循环读写直到 EAGAIN | 
| 边缘触发 | ET | 只有状态由不可用→可用时触发一次事件 | 性能更高,但需要一次性读写完所有数据,否则容易丢失事件 | 
- Level-Triggered (LT) - 只要套接字缓冲区还有数据,就会持续触发;
- 用户在收到可读事件后,如果一次性未将缓冲区数据全部读完,下次 epoll_wait仍会再次报告该 FD 可读;
- 易于使用,但会产生较多重复通知。
 
- Edge-Triggered (ET) - 只有当缓冲区状态从“无数据→有数据”才触发一次通知;
- 用户必须在事件回调中循环读(read)或写(write)直到返回EAGAIN/EWOULDBLOCK;
- 性能最好,减少不必要的唤醒,但编程模型更复杂,需要处理非阻塞 IO。
 
// EPOLL Edge-Triggered 示例(读数据)
int handle_read(int fd) {
    char buf[1024];
    while (1) {
        ssize_t n = read(fd, buf, sizeof(buf));
        if (n < 0) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // 数据已全部读完,退出循环
                break;
            } else {
                // 错误或对端关闭
                perror("read error");
                return -1;
            }
        } else if (n == 0) {
            // 对端关闭
            return -1;
        }
        // 处理数据(如回显等)
        write(fd, buf, n);
    }
    return 0;
}注意:在 ET 模式下,必须将 socket 设置为 非阻塞,否则当缓冲区数据不足一次 read 完时会阻塞,导致事件丢失。5.6 优缺点分析
- 优点 - 高性能:关注 FD 注册后,只在真正就绪时才触发回调,且避免了全量遍历,是真正的 “O(1)”;
- 内核与用户空间拷贝少:注册时一次拷贝,唤醒时只将就绪 FD 数组传回;
- 针对大并发更友好:适合长连接、高并发场景(如高性能 Web 服务器、Nginx、Redis 等多用 epoll);
 
- 缺点 - 编程复杂度较高,尤其是使用 ET 模式时要谨慎处理非阻塞循环读写;
- 仅限 Linux,跨平台性较差;
- 同样存在最大监听数量限制(由内核参数 fs.epoll.max_user_watches决定,可调整)。
 
6. 其他系统IO多路复用模型简述
6.1 kqueue(BSD/macOS)
- 设计思路:与 epoll类似,基于事件驱动,使用 红黑树 存放监视过滤器(filters),并通过 变化列表(change list) 与 事件列表(event list) 实现高效通知;
- 主要系统调用: - int kqueue(void);:创建 kqueue 实例;
- int kevent(int kq, const struct kevent *changelist, int nchanges, struct kevent *eventlist, int nevents, const struct timespec *timeout);:注册/触发/获取事件;
 
- 使用示例简要: - int kq = kqueue(); struct kevent change; // 关注 fd 可读事件 EV_SET(&change, fd, EVFILT_READ, EV_ADD | EV_ENABLE, 0, 0, NULL); kevent(kq, &change, 1, NULL, 0, NULL); struct kevent events[10]; int nev = kevent(kq, NULL, 0, events, 10, NULL); for (int i = 0; i < nev; i++) { int ready_fd = events[i].ident; // 处理 ready_fd 可读 }
6.2 IOCP(Windows)
- 设计思路:Windows 平台下的“完成端口”(I/O Completion Port)模型,通过操作系统提供的 异步 IO 与 线程池 结合,实现高性能并发;
- 主要流程: - 创建 CreateIoCompletionPort;
- 将 socket 或文件句柄关联到完成端口;
- 调用 WSARecv/WSASend等异步 IO 函数;
- Worker 线程调用 GetQueuedCompletionStatus阻塞等待完成事件;
- 事件完成后,处理对应数据;
 
- 创建 
- 优缺点: - 优点:Windows 平台最优推荐方案;结合异步 IO、线程池,性能优秀;
- 缺点:与 Linux 的 epoll/kqueue 不兼容,API 复杂度较高;
 
7. 真实场景中的应用与性能对比
在真实生产环境中,不同型号的服务器或不同平台常用的 IO 多路复用如下:
- Linux:epoll为首选,Nginx、Redis、Node.js 等都基于 epoll;
- FreeBSD/macOS:kqueue最佳,Nginx 等在 BSD 平台也会切换为 kqueue;
- 跨平台网络库:如 libuv、Boost.Asio,会在不同操作系统自动选择对应模型(Linux 用 epoll,BSD 用 kqueue,Windows 用 IOCP);
7.1 性能对比(理论与实践)
| 模型 | 平均延迟 | 吞吐(连接/秒) | CPU 利用 | 优化空间 | 
|---|---|---|---|---|
| select | 较高 | 低 | 随 FD 数量线性增高 | 几乎无拓展 | 
| poll | 类似 select | 略高于 select | 仍随 FD 数线性 | 无根本改进,需要 epoll | 
| epoll LT | 较低 | 高 | 仅就绪 FD 有开销 | ET 模式、non-blocking | 
| epoll ET | 最低 | 最高 | 大幅降低,最优 | 需 careful 编程 | 
| kqueue | 较低 | 高 | 类似 epoll | 调参/内存分配 | 
| IOCP | 最低(Windows) | 最高(Windows) | 高度并行最优 | 线程池调优 | 
注:以上指标仅供参考,实际性能与硬件、内核版本、内存、网络条件、业务逻辑等多因素相关,需要在实际环境中对比测试。
8. 总结与实践建议
- 在 Linux 平台优先选用 epoll - 对于并发连接数较大的场景,epoll 无疑是最优方案;
- 如果只是中小规模并发,且代码对跨平台兼容要求高,也可使用 libuv/Boost.Asio 等库,让其自动选择底层模型;
 
- 谨慎选择触发模式(LT vs ET) - LT(水平触发):编程模型与 select/poll 类似,易于上手;但在高并发、海量就绪时会产生大量重复唤醒;
- ET(边缘触发):性能最优,但编程需保证非阻塞IO + 循环读写直到 EAGAIN,一旦遗漏读写会导致该 FD 永远不再触发事件;
 
- 合理设置内核参数与资源限制 - 针对 epoll,Linux 内核存在 fs.epoll.max_user_watches限制,需要根据业务并发连接数进行调整(通过/proc/sys/fs/epoll/max_user_watches);
- 配合 ulimit -n提升单进程可打开文件描述符上限;
 
- 针对 epoll,Linux 内核存在 
- 关注 TCP 参数与非阻塞配置 - 若在 ET 模式下,一定要将套接字设置为非阻塞(fcntl(fd, F_SETFL, O_NONBLOCK)),否则读到一半会导致阻塞而丢失后续就绪;
- 合理设置 TCP SO_REUSEADDR、SO_KEEPALIVE等选项,避免 TIME\_WAIT 堆积;
 
- 若在 ET 模式下,一定要将套接字设置为非阻塞(
- 考虑业务逻辑与协议栈影响 - IO 多路复用只解决“监视多个 FD 就绪并分发”的问题,具体业务逻辑(如如何解析 HTTP、如何维护连接状态、如何做超时回收)需要额外实现;
- 对小请求短连接场景,过度追求 epoll ET 并不一定带来明显收益,尤其是连接数低时,select/poll 也能满足需求;
 
- 在跨平台项目中使用封装库 - 如果项目需要在 Linux、BSD、Windows 上都能运行,建议使用成熟的跨平台网络库,如 libuv、Boost.Asio;它们内部会针对不同平台自动切换到 epoll/kqueue/IOCP;
- 如果是纯 Linux 项目,直接使用 epoll 能获得最新且最可控的性能优势。
 
9. 参考资料
- Linux man select、man poll、man epoll手册;
- 《Linux高性能服务器编程》(游双 等著),对 epoll 原理、TCP 协议相关机制有深入剖析;
- 《UNIX网络编程 卷一》(W. Richard Stevens),对 select/poll 等基本模型有详尽介绍;
- LWN 文章:“replacing poll with epoll”,对 epoll 内部实现与性能优势分析;
- 内核源码:fs/eventpoll.c,可深入了解 epoll 在 Linux 内核中的具体实现。
致学有余力者
- 可尝试将上述示例改造为 多线程 + epoll 甚至 分布式多进程 架构,测试不同并发量下的性能表现;
- 结合 TCP keep-alive、心跳机制、超时回收 等机制,设计真正在线上可用的高并发服务器框架。
希望本文通过代码示例与图解的方式,能帮助你全面理解 IO 多路复用模型的底层实现与使用要点,让你在设计高并发网络服务时,更加游刃有余!
示意图注解
- select/poll 模型图:展示了用户态将 FD 复制到内核、内核遍历检查、返回后用户态再次遍历的流程。
- epoll 模型图:展示了注册阶段与就绪通知阶段的分离,以及就绪链表直接产生就绪事件,无需全量遍历。
代码示例
- select 示例:通过
fd_set维护客户端列表,每次循环都重新构建集合,模拟服务器接收/回显;- poll 示例:使用
struct pollfd数组一次注册所有 FD,就绪后通过revents判断,减少了FD_ISSET检查;- epoll 示例:演示 epoll 实例创建、注册 FD 以及边缘触发(ET)模式下的读操作,展示了真正的“事件驱动”风格。
深入思考
- 若在 epoll ET 模式下,只调用一次
read读取少于缓冲区数据,会导致剩余数据后续不再触发事件;- 生产环境中建议对可读/可写事件同时注册,配合EPOLLONESHOT模式实现更灵活的线程池+epoll 架构;
- 可以尝试模拟大并发压力(如使用
ab、wrk等压测工具),对比 select/poll/epoll 在不同并发量下的 CPU、延迟、吞吐。
