IO多路复用

Linux平台下的三种多路复用技术,结合AI进行整理

IO多路复用

IO 复用(IO Multiplexing) 是一种高效处理多 IO 事件的核心技术,其核心思想是通过单个进程 / 线程同时监视多个文件描述符(File Descriptor,如 Socket、文件、管道等),当某个描述符就绪(可读 / 可写 / 异常)时,主动通知程序进行处理。这种模式避免了传统 “一连接一线程” 模型的资源浪费(线程创建销毁开销、上下文切换成本),特别适合高并发场景(如百万级 TCP 连接的服务器)。

一、IO 复用的核心价值

在网络编程中,传统处理多客户端连接的方式是 “每连接创建一个线程”,但存在明显缺陷:

  • 线程数量有限(受系统资源限制,通常上限为几万),无法支撑高并发;

  • 线程上下文切换开销大,CPU 利用率低;

  • 内存消耗高(每个线程默认栈空间通常为 1MB)。

IO 复用技术通过单线程 / 少量线程监视大量 IO 描述符,仅在描述符就绪时才进行处理,大幅降低资源消耗,可轻松支撑十万甚至百万级并发连接。

二、常用 IO 复用函数及用法

Linux 平台下常用的 IO 复用函数有selectpollepoll(性能最优,最常用),Windows 平台对应有WSAEventSelectIOCP(IO 完成端口)。以下以 Linux 为主展开讲解:

1. select:最经典的 IO 复用函数

select是 POSIX 标准定义的 IO 复用函数,通过轮询方式监视多个文件描述符的可读、可写和异常事件。

函数原型
1
2
3
4
5
6
7
8
#include <sys/select.h>
#include <sys/time.h>

int select(int nfds,
fd_set *readfds, // 关注“可读”事件的描述符集合
fd_set *writefds, // 关注“可写”事件的描述符集合
fd_set *exceptfds, // 关注“异常”事件的描述符集合
struct timeval *timeout); // 超时时间(NULL表示阻塞等待)
核心参数说明
  • nfds:需要监视的最大文件描述符值 + 1(因为描述符从 0 开始编号);

  • fd_set:描述符集合(本质是位图),需通过宏操作:

    • FD_ZERO(fd_set *):清空集合;
    • FD_SET(int fd, fd_set *):将 fd 加入集合;
    • FD_CLR(int fd, fd_set *):从集合中移除 fd;
    • FD_ISSET(int fd, fd_set *):检查 fd 是否在就绪集合中;
  • timeout:超时设置(struct timeval { long tv_sec; long tv_usec; }),NULL表示永久阻塞,tv_sec=0且tv_usec=0表示非阻塞。

返回值
  • 成功:就绪的描述符总数;

  • 失败:-1(需通过errno判断错误,如EINTR表示被信号中断);

  • 超时:0。

示例代码(用 select 处理多客户端连接)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <iostream>
#include <vector>
#include <cstring>

int main() {
// 1. 创建监听Socket
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1) { perror("socket"); return -1; }

// 绑定地址和端口
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = htons(8080);
if (bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1) {
perror("bind"); return -1;
}

// 开始监听
listen(listen_fd, 1024);
std::cout << "Server listening on port 8080...\n";

fd_set read_fds; // 关注的可读事件集合
int max_fd = listen_fd; // 当前最大描述符
std::vector<int> client_fds; // 保存客户端连接

while (true) {
// 每次调用select前需重置集合(因为select会修改集合)
FD_ZERO(&read_fds);
FD_SET(listen_fd, &read_fds); // 监听Socket加入可读集合

// 将所有客户端Socket加入可读集合
for (int fd : client_fds) {
FD_SET(fd, &read_fds);
if (fd > max_fd) max_fd = fd;
}

// 调用select,阻塞等待事件(超时设为NULL表示永久阻塞)
int ready = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
if (ready == -1) {
perror("select");
continue;
} else if (ready == 0) {
continue; // 超时,继续等待
}

// 处理监听Socket的可读事件(新客户端连接)
if (FD_ISSET(listen_fd, &read_fds)) {
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len);
if (client_fd == -1) {
perror("accept");
continue;
}
client_fds.push_back(client_fd);
std::cout << "New client connected, fd: " << client_fd << "\n";
ready--; // 减少就绪计数,避免重复处理
}

// 处理客户端Socket的可读事件(数据到来)
if (ready > 0) {
for (auto it = client_fds.begin(); it != client_fds.end();) {
int fd = *it;
if (FD_ISSET(fd, &read_fds)) {
char buf[1024] = {0};
ssize_t n = recv(fd, buf, sizeof(buf)-1, 0);
if (n <= 0) { // 连接关闭或错误
std::cout << "Client disconnected, fd: " << fd << "\n";
close(fd);
it = client_fds.erase(it);
} else { // 接收数据
std::cout << "Received from fd " << fd << ": " << buf << "\n";
send(fd, "Received", 8, 0); // 简单回显
it++;
}
ready--;
if (ready == 0) break; // 所有就绪事件处理完毕
} else {
it++;
}
}
}
}

close(listen_fd);
return 0;
}
优缺点
  • 优点:跨平台(Linux/Windows/BSD 均支持),实现简单;

  • 缺点

    • 描述符数量限制(默认上限为 1024,可通过修改内核参数FD_SETSIZE调整,但不推荐);
    • 每次调用需重置fd_set,效率低;
    • 需遍历所有描述符检查就绪状态,随描述符增多性能下降。

2. poll:select 的改进版

poll解决了select的描述符数量限制问题,用结构体数组替代位图存储描述符,支持动态扩展。

函数原型
1
2
3
#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
核心参数说明
  • struct pollfd:描述符及事件的结构体:

    1
    2
    3
    4
    5
    struct pollfd {
    int fd; // 要监视的文件描述符(-1表示忽略)
    short events; // 关注的事件(输入参数)
    short revents; // 实际发生的事件(输出参数)
    };
    • 常用eventsPOLLIN(可读)、POLLOUT(可写)、POLLERR(错误)、POLLHUP(挂断);

  • nfdsfds数组的长度;

  • timeout:超时时间(毫秒,-1 表示永久阻塞,0 表示非阻塞)。

返回值
  • 成功:就绪的描述符总数;

  • 失败:-1;

  • 超时:0。

示例代码(用 poll 处理多客户端)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <poll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <iostream>
#include <vector>
#include <cstring>

int main() {
// 1. 创建监听Socket(同上,省略绑定/监听代码)
int listen_fd = ...;

std::vector<struct pollfd> fds;
// 添加监听Socket到poll集合
fds.push_back({listen_fd, POLLIN, 0});

while (true) {
// 调用poll,阻塞等待事件
int ready = poll(fds.data(), fds.size(), -1);
if (ready == -1) { perror("poll"); continue; }

// 遍历所有描述符检查就绪事件
for (size_t i = 0; i < fds.size() && ready > 0; i++) {
if (fds[i].revents == 0) continue;

// 处理监听Socket的可读事件(新连接)
if (fds[i].fd == listen_fd) {
int client_fd = accept(listen_fd, NULL, NULL);
if (client_fd != -1) {
fds.push_back({client_fd, POLLIN, 0});
std::cout << "New client, fd: " << client_fd << "\n";
}
ready--;
}
// 处理客户端Socket的可读事件
else if (fds[i].revents & POLLIN) {
char buf[1024] = {0};
ssize_t n = recv(fds[i].fd, buf, sizeof(buf)-1, 0);
if (n <= 0) { // 连接关闭
close(fds[i].fd);
// 用最后一个元素覆盖当前位置,减少vector移动开销
fds[i] = fds.back();
fds.pop_back();
} else { // 接收数据
std::cout << "Received: " << buf << "\n";
send(fds[i].fd, "OK", 2, 0);
}
ready--;
}
}
}

close(listen_fd);
return 0;
}
优缺点
  • 优点

    • 无描述符数量限制(仅受系统内存限制);
    • 无需每次重置事件集合(revents由内核填充,events保持不变);
  • 缺点

    • 仍需遍历所有描述符检查就绪状态(大数量时效率低);
    • 跨平台性略差(Windows 需模拟实现)。

3. epoll:Linux 高性能 IO 复用(推荐)

epoll是 Linux 2.6 内核后引入的高性能 IO 复用机制,采用内核事件表回调通知模式,避免了select/poll的轮询开销,是高并发服务器的首选。

核心函数
  1. epoll_create:创建 epoll 实例(内核事件表)

    1
    2
    #include <sys/epoll.h>
    int epoll_create(int size); // size:早期内核用于指定事件表大小,现在已忽略(传>0即可)
  2. epoll_ctl:操作事件表(添加 / 修改 / 删除描述符及事件)

    1
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    • epfdepoll_create返回的实例句柄;

    • op:操作类型:EPOLL_CTL_ADD(添加)、EPOLL_CTL_MOD(修改)、EPOLL_CTL_DEL(删除);

    • fd:要操作的文件描述符;

    • struct epoll_event:事件结构:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      struct epoll_event {
      uint32_t events; // 关注的事件
      epoll_data_t data; // 用户数据(通常存fd或自定义指针)
      };
      typedef union epoll_data {
      void *ptr;
      int fd;
      uint32_t u32;
      uint64_t u64;
      } epoll_data_t;

      常用events:EPOLLIN(可读)、EPOLLOUT(可写)、EPOLLET(边缘触发)、EPOLLONESHOT(只触发一次)。

  3. epoll_wait:等待事件就绪

    1
    int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
    • events:输出参数,保存就绪的事件;

    • maxevents:最多处理的事件数(需≤epoll_create的 size);

    • timeout:超时时间(毫秒,-1 表示阻塞,0 表示非阻塞)。

    • 返回值:就绪的事件数(0 表示超时,-1 表示错误)。

  4. epoll_create1 - 创建 epoll 实例

    1
    int epoll_create1(int flags);
    • flags:可以是 0(与epoll_create相同)或EPOLL_CLOEXEC(进程执行 exec 时自动关闭 epoll 实例)

    • 返回值:成功返回 epoll 文件描述符,失败返回 - 1

关键特性:边缘触发(ET)与水平触发(LT)
  • 水平触发(LT,默认):只要描述符有未处理的数据(如可读),epoll_wait就会持续通知;

  • 边缘触发(ET):仅在描述符状态从 “未就绪” 变为 “就绪” 时通知一次(需一次性处理完所有数据,否则可能遗漏事件)。

ET 模式效率更高(减少通知次数),但编程复杂度高(需确保数据读完)。

示例代码(epoll + ET 模式处理多客户端)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <iostream>
#include <vector>
#include <cstring>

// 一次性读取所有数据(ET模式必需)
ssize_t read_all(int fd, char *buf, size_t len) {
ssize_t total = 0;
while (total < len) {
ssize_t n = recv(fd, buf + total, len - total, 0);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break; // 数据已读完
return -1; // 错误
} else if (n == 0) return total; // 连接关闭
total += n;
}
return total;
}

int main() {
// 1. 创建监听Socket(同上,省略绑定/监听)
int listen_fd = ...;

// 2. 创建epoll实例
int epfd = epoll_create(1024); // 参数>0即可
if (epfd == -1) { perror("epoll_create"); return -1; }

// 3. 向epoll添加监听Socket(LT模式,默认)
struct epoll_event ev;
ev.events = EPOLLIN; // 关注可读事件
ev.data.fd = listen_fd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) {
perror("epoll_ctl"); return -1;
}

const int MAX_EVENTS = 1024;
struct epoll_event events[MAX_EVENTS]; // 存放就绪事件

while (true) {
// 4. 等待事件就绪(阻塞)
int ready = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (ready == -1) { perror("epoll_wait"); continue; }

// 5. 处理就绪事件
for (int i = 0; i < ready; i++) {
int fd = events[i].data.fd;

// 新客户端连接
if (fd == listen_fd) {
int client_fd = accept(listen_fd, NULL, NULL);
if (client_fd == -1) { perror("accept"); continue; }

// 设为非阻塞(ET模式必须,避免读数据时阻塞)
int flags = fcntl(client_fd, F_GETFL);
fcntl(client_fd, F_SETFL, flags | O_NONBLOCK);

// 向epoll添加客户端Socket(ET模式)
ev.events = EPOLLIN | EPOLLET; // 可读 + 边缘触发
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
std::cout << "New client, fd: " << client_fd << "\n";
}
// 客户端数据可读
else if (events[i].events & EPOLLIN) {
char buf[1024] = {0};
ssize_t n = read_all(fd, buf, sizeof(buf)-1); // 一次性读完
if (n <= 0) { // 连接关闭或错误
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); // 从epoll移除
std::cout << "Client disconnected, fd: " << fd << "\n";
} else {
std::cout << "Received: " << buf << "\n";
send(fd, "OK", 2, 0);
}
}
}
}

close(epfd);
close(listen_fd);
return 0;
}
优缺点
  • 优点

    • 高效:内核通过回调直接通知就绪事件,无需轮询所有描述符;
    • 支持海量描述符(十万 / 百万级,仅受内存限制);
    • 边缘触发模式减少冗余通知,适合高并发;
  • 缺点

    • 仅支持 Linux 平台(不跨平台);
    • ET 模式编程复杂(需处理非阻塞 IO 和数据完整性)。

三、注意事项

  1. 描述符管理

    • select/poll需手动维护描述符集合,epoll通过epoll_ctl动态管理;
    • 关闭描述符前需从 IO 复用集合中移除(避免 “僵尸描述符” 导致错误)。
  2. 非阻塞 IO 配合

    • 边缘触发(ET)模式必须结合非阻塞 IO(O_NONBLOCK),否则可能因一次读 / 写未完成而阻塞;
    • 水平触发(LT)可配合阻塞 IO,但非阻塞 IO 更灵活。
  3. 事件类型选择

    • 避免过度关注EPOLLOUT(可写事件):Socket 默认可写,会导致epoll_wait频繁触发,通常仅在发送数据时临时添加;
    • EPOLLONESHOT确保一个事件仅被一个线程处理(多线程场景)。
  4. 跨平台兼容

    • select是唯一跨所有平台的 IO 复用函数;
    • 跨平台开发可使用封装库(如 Boost.Asio、libuv),统一抽象不同系统的 IO 复用机制。
  5. 性能误区

    • 小并发场景(<1000 连接),select/pollepoll性能差异不大;
    • 避免在epoll中频繁添加 / 删除描述符(开销较高,可用EPOLLONESHOT替代)。

四、典型使用场景

  1. 高并发 TCP 服务器(如 Web 服务器、即时通讯服务器):需同时处理数万至百万级客户端连接,epoll(Linux)或kqueue(BSD)是最优选择。

  2. 多 IO 设备监控(如同时处理 Socket、管道、文件):pollepoll可统一管理不同类型的文件描述符。

  3. 低资源环境(如嵌入式设备):select实现简单,资源占用少,适合轻量级场景。

  4. 非阻塞 IO 配合:与非阻塞 Socket 结合,实现 “非阻塞 + IO 复用” 的高效模型(如 Nginx 的事件驱动模型)。

总结

IO 复用是 C++ 高性能网络编程的基石,其核心是通过单线程 / 少量线程高效处理多 IO 事件。选择哪种函数需结合平台、并发量和复杂度:

  • 跨平台、轻量级场景→select

  • 中等并发、需突破描述符限制→poll

  • 高并发(Linux)、追求极致性能→epoll(推荐)。

实际开发中,建议使用成熟的网络库(如 Boost.Asio、muduo),它们已封装 IO 复用细节,同时提供跨平台支持和更高层次的抽象,降低开发难度。