跳转至

14 - I/O 多路复用面试专题

本章核心:深入理解 select/poll/epoll 的区别,这是后端面试必考题


📖 章节导航

前序章节01-网络基础.md10-现代网络协议.md 应用场景:高并发服务器( Nginx 、 Redis )、网络编程面试


🎯 学习目标

  • 理解同步/异步、阻塞/非阻塞的区别
  • 掌握 select/poll/epoll 的原理和区别
  • 理解 epoll 的水平触发( LT )和边缘触发( ET )
  • 能够回答常见面试变体问题

1. I/O 模型基础

1.1 五种 I/O 模型

Text Only
┌─────────────────────────────────────────────────────────────────────┐
│                      Linux 五种 I/O 模型                              │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  1. 阻塞 I/O(Blocking I/O)                                        │
│     ├── 应用调用 read() 后阻塞,直到数据就绪并拷贝完成               │
│     └── 最简单,但效率最低                                          │
│                                                                     │
│  2. 非阻塞 I/O(Non-blocking I/O)                                  │
│     ├── 应用调用 read() 立即返回(无数据时返回 EAGAIN)             │
│     └── 需要轮询检查,CPU 空转浪费                                  │
│                                                                     │
│  3. I/O 多路复用(I/O Multiplexing)  ★★★★★ 本章重点              │
│     ├── 一个线程监听多个文件描述符                                  │
│     ├── select/poll/epoll                                          │
│     └── 高并发服务器的基础(Nginx、Redis)                          │
│                                                                     │
│  4. 信号驱动 I/O(Signal-driven I/O)                               │
│     ├── 注册 SIGIO 信号处理函数                                     │
│     └── 数据就绪时内核发送信号通知                                  │
│                                                                     │
│  5. 异步 I/O(Asynchronous I/O)                                    │
│     ├── 应用发起请求后立即返回                                      │
│     ├── 内核完成数据拷贝后通知应用                                  │
│     └── Linux 上的 io_uring(5.1+ 内核)                            │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

1.2 同步 vs 异步、阻塞 vs 非阻塞

Text Only
┌─────────────────────────────────────────────────────────────────────┐
│                    同步/异步 vs 阻塞/非阻塞                           │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  同步(Synchronous)                                                │
│  └── 调用者需要主动等待或查询结果                                   │
│      阻塞I/O、非阻塞I/O、I/O多路复用都是同步I/O                     │
│                                                                     │
│  异步(Asynchronous)                                               │
│  └── 调用者发起请求后不等待,内核完成后通知                         │
│      真正的异步I/O(Linux io_uring、Windows IOCP)                  │
│                                                                     │
│  阻塞(Blocking)                                                   │
│  └── 调用期间线程挂起,不能做其他事                                 │
│                                                                     │
│  非阻塞(Non-blocking)                                             │
│  └── 调用立即返回,线程可以做其他事                                 │
│                                                                     │
│  ⚠️ 易混淆点:                                                      │
│  I/O多路复用是"同步非阻塞" —— select/epoll_wait 本身是阻塞的,     │
│  但它可以同时监听多个fd,避免了每个fd独立阻塞                       │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

2. select

2.1 select 原理

C
// select函数原型
#include <sys/select.h>

int select(int nfds,                    // 最大文件描述符+1
           fd_set *readfds,             // 监听可读的fd集合
           fd_set *writefds,            // 监听可写的fd集合
           fd_set *exceptfds,           // 监听异常的fd集合
           struct timeval *timeout);    // 超时时间

// fd_set 操作宏
FD_ZERO(&set);       // 清空集合
FD_SET(fd, &set);    // 添加fd到集合
FD_CLR(fd, &set);    // 从集合移除fd
FD_ISSET(fd, &set);  // 检查fd是否在集合中

2.2 select 工作流程

Text Only
┌─────────────────────────────────────────────────────────────────────┐
│                     select 工作流程                                   │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  用户空间                         内核空间                           │
│  ┌──────────┐                    ┌──────────────────────┐           │
│  │ fd_set   │ ─── 拷贝 ─────────→│ 遍历所有fd检查状态    │           │
│  │ [1,3,5,7]│                    │                      │           │
│  └──────────┘                    │ fd 1: 无数据          │           │
│       ↑                          │ fd 3: 有数据 ✓        │           │
│       │                          │ fd 5: 无数据          │           │
│       │                          │ fd 7: 有数据 ✓        │           │
│       │                          │                      │           │
│  ┌──────────┐                    │ 就绪数: 2             │           │
│  │ fd_set   │ ←── 拷贝回 ────────│ 修改 fd_set          │           │
│  │ [3,7]    │  (只剩就绪的)       └──────────────────────┘           │
│  └──────────┘                                                       │
│       │                                                             │
│       ↓                                                             │
│  应用需要遍历原始fd集合,用FD_ISSET检查哪些就绪                       │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

2.3 select 代码示例

C
// select 多路复用服务器示例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/select.h>

#define PORT 8080
#define MAX_CLIENTS 1024

int main() {
    int server_fd, new_socket, client_sockets[MAX_CLIENTS];
    fd_set readfds;
    int max_fd, activity, i;
    char buffer[1024];
    struct sockaddr_in address;  // struct结构体:自定义复合数据类型

    // 初始化客户端socket数组
    for (i = 0; i < MAX_CLIENTS; i++) {
        client_sockets[i] = 0;
    }

    // 创建服务器socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);

    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    listen(server_fd, 5);

    printf("Server listening on port %d\n", PORT);

    while (1) {
        // 每次循环都要重新设置fd_set(因为select会修改它)
        FD_ZERO(&readfds);
        FD_SET(server_fd, &readfds);
        max_fd = server_fd;

        // 添加所有客户端socket到集合
        for (i = 0; i < MAX_CLIENTS; i++) {
            if (client_sockets[i] > 0) {
                FD_SET(client_sockets[i], &readfds);
                if (client_sockets[i] > max_fd) {
                    max_fd = client_sockets[i];
                }
            }
        }

        // 调用select,阻塞等待事件
        activity = select(max_fd + 1, &readfds, NULL, NULL, NULL);

        // 检查服务器socket是否有新连接
        if (FD_ISSET(server_fd, &readfds)) {
            new_socket = accept(server_fd, NULL, NULL);
            printf("New connection: socket fd %d\n", new_socket);

            // 添加到客户端数组
            for (i = 0; i < MAX_CLIENTS; i++) {
                if (client_sockets[i] == 0) {
                    client_sockets[i] = new_socket;
                    break;
                }
            }
        }

        // 检查客户端socket的数据
        for (i = 0; i < MAX_CLIENTS; i++) {
            int sd = client_sockets[i];
            if (sd > 0 && FD_ISSET(sd, &readfds)) {
                int valread = read(sd, buffer, 1024);
                if (valread == 0) {
                    // 客户端断开
                    close(sd);
                    client_sockets[i] = 0;
                } else {
                    // 回显数据
                    write(sd, buffer, valread);
                }
            }
        }
    }

    return 0;
}

2.4 select 的缺点

缺点 说明 影响
fd 数量限制 默认 FD_SETSIZE = 1024 无法支持大量连接
每次调用需拷贝 fd_set 从用户空间拷贝到内核 O(n) 时间复杂度
每次需重新设置 select 会修改 fd_set 代码繁琐
返回后需遍历 不知道具体哪些 fd 就绪 O(n) 遍历检查

3. poll

3.1 poll 原理

C
// poll函数原型
#include <poll.h>

int poll(struct pollfd *fds,    // pollfd数组
         nfds_t nfds,           // 数组元素个数
         int timeout);          // 超时(毫秒)

struct pollfd {
    int   fd;         // 文件描述符
    short events;     // 请求监听的事件
    short revents;    // 实际发生的事件(内核填充)
};

// 常用事件
POLLIN    // 可读
POLLOUT   // 可写
POLLERR   // 错误
POLLHUP   // 挂起

3.2 poll vs select

特性 select poll
fd 数量 限制 1024 无硬性限制(数组方式)
数据结构 位图 fd_set pollfd 结构体数组
每次是否重置 是(破坏性修改) 否( events/revents 分离)
内核实现 相同(都要遍历) 相同
效率 O(n) O(n)

3.3 poll 代码示例

C
// poll 多路复用示例
#include <poll.h>

#define MAX_CLIENTS 10000

struct pollfd fds[MAX_CLIENTS];
int nfds = 0;

// 添加服务器socket
fds[0].fd = server_fd;
fds[0].events = POLLIN;
nfds = 1;

while (1) {
    int activity = poll(fds, nfds, -1);  // -1 表示无限等待

    // 检查服务器socket
    if (fds[0].revents & POLLIN) {
        int new_socket = accept(server_fd, NULL, NULL);
        fds[nfds].fd = new_socket;
        fds[nfds].events = POLLIN;
        nfds++;
    }

    // 检查客户端socket
    for (int i = 1; i < nfds; i++) {
        if (fds[i].revents & POLLIN) {
            // 处理数据...
        }
    }
}

4. epoll (★★★★★ 面试重点)

4.1 epoll 原理

C
// epoll 三个核心函数
#include <sys/epoll.h>

// 1. 创建 epoll 实例
int epoll_create(int size);           // size 被忽略,但必须 > 0
int epoll_create1(int flags);         // 推荐使用

// 2. 添加/修改/删除监听的fd
int epoll_ctl(int epfd,               // epoll实例句柄
              int op,                 // 操作类型
              int fd,                 // 目标fd
              struct epoll_event *event);

// op 操作类型
EPOLL_CTL_ADD   // 添加
EPOLL_CTL_MOD   // 修改
EPOLL_CTL_DEL   // 删除

// 3. 等待事件
int epoll_wait(int epfd,
               struct epoll_event *events,  // 输出:就绪事件数组
               int maxevents,               // 数组大小
               int timeout);                // 超时(毫秒)

struct epoll_event {
    uint32_t events;    // 事件类型(EPOLLIN/EPOLLOUT/EPOLLET等)
    epoll_data_t data;  // 用户数据(通常存放fd)
};

4.2 epoll 内核实现

Text Only
┌─────────────────────────────────────────────────────────────────────┐
│                      epoll 内核数据结构                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  epoll 实例(eventpoll 结构体)                                      │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │                                                             │   │
│  │  ┌─────────────────────────────────────────┐               │   │
│  │  │          红黑树(rbr)                   │               │   │
│  │  │   存储所有被监控的 fd(epitem)          │               │   │
│  │  │   增删改查:O(log n)                    │               │   │
│  │  │                                         │               │   │
│  │  │       [fd=3]     [fd=7]                │               │   │
│  │  │          \       /                      │               │   │
│  │  │          [fd=5]                         │               │   │
│  │  │          /     \                        │               │   │
│  │  │      [fd=1]   [fd=9]                   │               │   │
│  │  └─────────────────────────────────────────┘               │   │
│  │                                                             │   │
│  │  ┌─────────────────────────────────────────┐               │   │
│  │  │          就绪链表(rdllist)             │               │   │
│  │  │   存储已就绪的 fd(双向链表)             │               │   │
│  │  │   O(1) 获取就绪事件                     │               │   │
│  │  │                                         │               │   │
│  │  │   head → [fd=3] → [fd=7] → tail        │               │   │
│  │  └─────────────────────────────────────────┘               │   │
│  │                                                             │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                                                                     │
│  工作流程:                                                         │
│  1. epoll_ctl ADD:将 fd 加入红黑树                                 │
│  2. 内核为每个 fd 注册回调函数                                      │
│  3. 当 fd 就绪时,回调函数将其加入就绪链表                          │
│  4. epoll_wait 只需检查就绪链表,直接返回就绪 fd                    │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

4.3 epoll 代码示例

C
// epoll 高性能服务器示例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/epoll.h>

#define PORT 8080
#define MAX_EVENTS 10000

// 设置非阻塞
void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    int server_fd, epoll_fd;
    struct epoll_event ev, events[MAX_EVENTS];
    struct sockaddr_in address;

    // 创建服务器socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblocking(server_fd);

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);

    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    listen(server_fd, SOMAXCONN);

    // 创建 epoll 实例
    epoll_fd = epoll_create1(0);

    // 添加服务器socket到epoll
    ev.events = EPOLLIN;
    ev.data.fd = server_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev);

    printf("Server listening on port %d with epoll\n", PORT);

    while (1) {
        // 等待事件(只返回就绪的fd,无需遍历所有)
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);

        for (int i = 0; i < nfds; i++) {
            if (events[i].data.fd == server_fd) {
                // 新连接
                int conn_fd = accept(server_fd, NULL, NULL);
                set_nonblocking(conn_fd);

                ev.events = EPOLLIN | EPOLLET;  // 边缘触发
                ev.data.fd = conn_fd;
                epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev);

            } else {
                // 客户端数据
                char buffer[1024];
                int fd = events[i].data.fd;

                // 边缘触发模式需要循环读取直到EAGAIN
                while (1) {
                    int n = read(fd, buffer, sizeof(buffer));
                    if (n > 0) {
                        write(fd, buffer, n);  // 回显
                    } else if (n == 0) {
                        // 客户端关闭
                        epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
                        close(fd);
                        break;
                    } else {
                        // EAGAIN,数据读完
                        break;
                    }
                }
            }
        }
    }

    close(epoll_fd);
    return 0;
}

4.4 水平触发( LT ) vs 边缘触发( ET )

Text Only
┌─────────────────────────────────────────────────────────────────────┐
│                   LT(Level Triggered)vs ET(Edge Triggered)        │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  水平触发(LT)—— 默认模式                                           │
│  ├── 只要 fd 处于就绪状态,每次 epoll_wait 都会通知                 │
│  ├── 可以不一次读完,下次还会通知                                   │
│  ├── 编程简单,不易出错                                             │
│  └── select/poll 都是 LT 模式                                       │
│                                                                     │
│  边缘触发(ET)—— 高性能模式                                         │
│  ├── 只在状态变化时通知一次(从无数据变为有数据)                   │
│  ├── 必须一次读完所有数据(循环读到 EAGAIN)                        │
│  ├── 必须使用非阻塞 I/O                                             │
│  ├── 编程复杂,但效率更高(减少 epoll_wait 调用次数)               │
│  └── Nginx 使用 ET 模式                                             │
│                                                                     │
│  示例场景:缓冲区有 1000 字节数据                                   │
│  ┌────────────────────────────────────────────────────────────┐    │
│  │ LT 模式:                                                  │    │
│  │ 第1次 epoll_wait → 返回可读 → read(500) → 还剩 500        │    │
│  │ 第2次 epoll_wait → 返回可读 → read(500) → 读完            │    │
│  │ 第3次 epoll_wait → 无就绪事件                              │    │
│  ├────────────────────────────────────────────────────────────┤    │
│  │ ET 模式:                                                  │    │
│  │ 第1次 epoll_wait → 返回可读                                │    │
│  │ 必须循环 read 直到 EAGAIN,否则数据可能丢失                │    │
│  │ 即使还有 500 字节未读,下次 epoll_wait 也不会再通知        │    │
│  └────────────────────────────────────────────────────────────┘    │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

5. 三者对比(★★★★★ 面试必背)

5.1 核心对比表

特性 select poll epoll
最大连接数 1024 ( FD_SETSIZE ) 无限制 无限制(取决于系统资源)
数据结构 位图 fd_set 数组 pollfd 红黑树 + 就绪链表
fd 拷贝 每次调用都要拷贝 每次调用都要拷贝 只在 epoll_ctl 时拷贝一次
事件检测 内核遍历 O(n) 内核遍历 O(n) 回调机制 O(1)
返回后处理 需遍历所有 fd 需遍历所有 fd 只返回就绪 fd
触发模式 LT LT LT / ET
适用场景 连接数少且活跃 连接数中等 高并发长连接
跨平台 是( POSIX ) 是( POSIX ) 仅 Linux

5.2 性能对比

Text Only
假设 10000 个连接,其中 100 个活跃:

select/poll:
- 每次调用:拷贝 10000 个 fd 到内核
- 内核遍历:检查 10000 个 fd
- 返回后:应用遍历 10000 个 fd 找出就绪的
- 时间复杂度:O(n),n=10000

epoll:
- epoll_ctl:只在连接建立/关闭时调用
- epoll_wait:直接返回 100 个就绪 fd
- 时间复杂度:O(m),m=100(活跃连接数)

5.3 面试答题模板

Text Only
面试官:请说说 select/poll/epoll 的区别

答题框架:
1. 先说三者的共同点(都是 I/O 多路复用)
2. 再分别说各自的特点和限制
3. 重点讲 epoll 的优势(红黑树、就绪链表、回调机制)
4. 讲 LT 和 ET 的区别
5. 最后说适用场景

示例回答:
"三者都是 Linux 下的 I/O 多路复用机制,用于一个线程同时监听多个文件描述符。

select 是最早的实现,有 1024 个 fd 的限制,每次调用需要将 fd_set 从用户空间
拷贝到内核空间,内核通过遍历检查哪些 fd 就绪,时间复杂度 O(n)。

poll 解决了 1024 的限制,使用 pollfd 数组代替位图,但本质上还是遍历,效率
和 select 一样是 O(n)。

epoll 是 Linux 2.6 引入的高性能方案。它使用红黑树存储监控的 fd,增删改都是
O(log n);使用就绪链表存储活跃 fd,内核通过回调机制在 fd 就绪时将其加入链表,
epoll_wait 只需检查链表,时间复杂度 O(1)。另外 epoll 只在 epoll_ctl 时拷贝
fd,避免了重复拷贝的开销。

epoll 还支持 ET 边缘触发模式,只在状态变化时通知,减少了事件触发次数,但编程
时需要一次读完所有数据。Nginx 和 Redis 都使用 epoll 的 ET 模式实现高并发。

选择建议:连接数少于 1000 且都很活跃时三者差别不大;高并发场景(如 C10K 问题)
必须使用 epoll。"

6. 面试高频题

6.1 为什么 epoll 比 select/poll 快

三个关键优化

  1. 红黑树存储 fd: epoll_ctl 添加 fd 时复杂度 O(log n),比 select/poll 每次重建集合高效
  2. 回调机制通知就绪: fd 就绪时通过回调函数加入就绪链表,无需遍历所有 fd
  3. 只拷贝一次 fd: select/poll 每次调用都拷贝, epoll 只在 epoll_ctl 时拷贝

6.2 epoll 的 LT 和 ET 有什么区别?什么场景用哪个

  • LT (水平触发):只要有数据就一直通知,编程简单,适合一般场景
  • ET (边缘触发):只在状态变化时通知一次,效率高但必须一次读完,适合高性能服务器( Nginx )

6.3 epoll 的惊群问题是什么?怎么解决

问题:多个进程/线程都在 epoll_wait 同一个 epoll 实例,当有事件时所有都被唤醒,但只有一个能处理,其他白白唤醒浪费 CPU 。

解决: - Linux 4.5+ 内核引入 EPOLLEXCLUSIVE 标志,只唤醒一个等待者 - 应用层:每个线程使用独立的 epoll 实例 + SO_REUSEPORT

6.4 select 为什么有 1024 的限制

fd_set 是一个位图数组, FD_SETSIZE 宏定义了其大小,默认是 1024 位。虽然可以重新编译内核修改这个值,但不推荐,应该使用 epoll 。

6.5 什么是 C10K 问题

C10K 指单机同时处理 1 万个并发连接的问题。传统的多进程/多线程模型无法扩展到这个规模(进程/线程切换开销太大),解决方案是使用 epoll 等 I/O 多路复用 + 非阻塞 I/O + 事件驱动编程模型。


7. 其他平台的实现

系统 I/O 多路复用 说明
Linux epoll 最高效,生产首选
macOS/BSD kqueue 类似 epoll ,跨 BSD 可用
Windows IOCP 完成端口,真正异步 I/O
Solaris /dev/poll 类似 epoll

跨平台库: libevent 、 libuv 、 Boost.Asio 封装了不同平台的实现,提供统一 API 。


8. Python 中的 I/O 多路复用

Python
import selectors
import socket

# Python 的 selectors 模块会自动选择最佳实现
# Linux 上是 epoll,macOS 上是 kqueue,Windows 上是 select

sel = selectors.DefaultSelector()

def accept(sock, mask):
    conn, addr = sock.accept()
    print(f'Connected: {addr}')
    conn.setblocking(False)
    sel.register(conn, selectors.EVENT_READ, read)

def read(conn, mask):
    data = conn.recv(1024)
    if data:
        conn.send(data)  # 回显
    else:
        sel.unregister(conn)
        conn.close()

# 创建服务器
sock = socket.socket()
sock.bind(('0.0.0.0', 8080))
sock.listen(100)
sock.setblocking(False)
sel.register(sock, selectors.EVENT_READ, accept)

print('Server started with selectors (auto-select best I/O)')

while True:
    events = sel.select()  # 底层使用 epoll/kqueue/select
    for key, mask in events:
        callback = key.data
        callback(key.fileobj, mask)

参考资料: - 《 UNIX 网络编程》- W. Richard Stevens - Linux man pages: select(2), poll(2), epoll(7) - Nginx 源码: ngx_epoll_module.c

⚠️ 核验说明(2026-04-04):本页保留面试专题定位,但已按当前目录完成时效性复核,并统一更新了核验说明。若文中涉及内核版本、系统调用行为或第三方资料,请以 man page、内核文档和实际系统环境为准。


最后更新日期: 2026-04-04