什么是 I/O 多路复用?
在传统的网络编程中,如果服务器需要同时处理多个客户端连接,通常有以下几种模式:

(图片来源网络,侵删)
- 阻塞 I/O 模型:为每个客户端创建一个线程,如果一个线程在
read时阻塞,不会影响其他线程,但这种方法会创建大量线程,消耗大量内存和 CPU 上下文切换开销,扩展性差。 - 非阻塞 I/O 模型:将所有 socket 都设置为非阻塞,然后在一个循环中依次对每个 socket 进行
read或write,这种方法虽然避免了线程开销,但会浪费大量 CPU 时间在无效的系统调用上(当一个 socket 还没数据时,read会立即返回错误)。
I/O 多路复用就是为了解决上述问题而生的,它的核心思想是:用一个线程来监视多个文件描述符(File Descriptor,简称 FD,在 socket 编程中通常指 socket),当任何一个 FD 准备好进行 I/O 操作(有数据可读、可写、或发生异常)时,select 函数才会返回,告诉应用程序哪些 FD 已经就绪。
这样,应用程序就无需盲目地轮询,而是可以高效地处理已经准备好的 I/O 事件。
select 函数详解
select 函数是 POSIX 标准定义的,其原型如下:
#include <sys/select.h> int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数解析
-
nfds:
(图片来源网络,侵删)- 类型:
int - 含义:需要监视的 文件描述符的最大值 + 1。
select内部会遍历 0 到nfds-1的所有 FD,你不需要传递一个 FD 集合,而是传递一个上限值。 - 如果你要监视的 FD 是 3, 5, 10,
nfds就应该是10 + 1 = 11。
- 类型:
-
readfds,writefds,exceptfds:- 类型:
fd_set * - 含义:指向文件描述符集合的指针,它们分别用于监视:
readfds: 可读 的 FD,当这些 FD 中有数据可读时,select会返回。writefds: 可写 的 FD,当这些 FD 的发送缓冲区有空间可以写入数据时,select会返回。exceptfds: 异常 的 FD,当这些 FD 上发生“带外数据”(Out-of-Band data)等错误条件时,select会返回。
- 你可以只关心其中一种或几种,将不关心的参数设为
NULL。
- 类型:
-
timeout:- 类型:
struct timeval * - 含义:
select函数的阻塞超时时间。timeout为NULL,select将永远阻塞,直到有 FD 就绪。timeout指向一个timeval结构体,并且其成员tv_sec(秒)和tv_usec(微秒)都为 0,select将立即返回,不阻塞。timeout指向一个有效的timeval结构体,select将最多阻塞指定的时间,如果超时,即使没有 FD 就绪,select也会返回 0。
- 结构体定义:
struct timeval { long tv_sec; // 秒 long tv_usec; // 微秒 };
- 类型:
返回值
- 成功:返回就绪 FD 的总数(0, 1, 2, ...)。
- 超时:返回 0,表示在指定的
timeout时间内,没有任何 FD 就绪。 - 出错:返回 -1,并设置
errno,当nfds无效或被信号中断时。
fd_set 操作宏
fd_set 是一个内部结构,我们不应该直接操作它,标准库提供了一组宏来方便地操作这个集合:
FD_ZERO(fd_set *set): 清空一个文件描述符集合,在每次调用select之前,都必须先调用它来初始化集合。FD_SET(int fd, fd_set *set): 将一个文件描述符fd添加到集合set中。FD_CLR(int fd, fd_set *set): 将一个文件描述符fd从集合set中移除。FD_ISSET(int fd, fd_set *set): 检查文件描述符fd是否在集合set中,当select返回后,使用此宏来检查某个 FD 是否就绪。
select 的完整工作流程(以服务器为例)
这是一个使用 select 实现的简单回显服务器的完整流程:

(图片来源网络,侵删)
- 创建监听 socket (
socket,bind,listen)。 - 初始化
fd_set:使用FD_ZERO清空readfds。 - 将监听 socket 添加到
readfds:使用FD_SET。 - 进入主循环:
a. 备份
readfds:select函数在返回时会修改readfds集合,只保留就绪的 FD,每次循环开始前,都需要将原始的readfds(包含监听 socket 和所有客户端 socket)重新设置到readfds中。 b. 调用select:传入备份后的readfds、nfds(监听 FD + 1)和timeout。 c. 处理返回值:- 如果返回值 > 0,表示有 FD 就绪。
- 如果返回值 == 0,表示超时,可以执行一些其他任务(如定时器),然后继续循环。
- 如果返回值 < 0,检查
errno,如果是EINTR(被信号中断),可以继续循环;否则是严重错误,应退出。 d. 遍历所有可能的 FD:从 0 到nfds-1,使用FD_ISSET检查每个 FD 是否在readfds中。 e. 处理就绪的 FD: - 如果就绪的 FD 是监听 socket:说明有新的客户端连接,调用
accept接受连接,将返回的新客户端 socket 也添加到readfds集合中(别忘了更新nfds)。 - 如果就绪的 FD 是客户端 socket:说明该客户端有数据可读,调用
read从该 socket 读取数据。read返回 0,表示客户端已断开连接,需要关闭该 socket,并将其从readfds集合中移除(FD_CLR)。read返回正常数据,将其回写给客户端。
- 循环直到程序结束。
完整代码示例
下面是一个简单的回显服务器,它使用 select 来处理一个监听 socket 和多个客户端 socket。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <errno.h>
#define PORT 8080
#define MAX_CLIENTS 30
#define BUFFER_SIZE 1024
int main() {
int server_fd, new_socket;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
// 1. 创建 socket
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置 socket 选项,允许地址重用
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
perror("setsockopt");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 2. 绑定
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 3. 监听
if (listen(server_fd, 3) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
printf("Server listening on port %d...\n", PORT);
// 4. 初始化 fd_set
fd_set read_fds;
int max_sd; // 记录最大的文件描述符
// 客户端 socket 数组,初始化为 -1
int client_sockets[MAX_CLIENTS];
for (int i = 0; i < MAX_CLIENTS; i++) {
client_sockets[i] = -1;
}
while (1) {
// 清空文件描述符集合
FD_ZERO(&read_fds);
// 添加监听 socket 到集合
FD_SET(server_fd, &read_fds);
max_sd = server_fd;
// 添加所有客户端 socket 到集合
for (int i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];
if (sd > 0) {
FD_SET(sd, &read_fds);
if (sd > max_sd) {
max_sd = sd;
}
}
}
// 5. 调用 select
// max_sd + 1 是 nfds
// timeout 设为 NULL,表示一直阻塞直到有事件发生
int activity = select(max_sd + 1, &read_fds, NULL, NULL, NULL);
if (activity < 0 && errno != EINTR) {
perror("select error");
}
// 6. 处理就绪的 FD
// 检查是否有新的连接
if (FD_ISSET(server_fd, &read_fds)) {
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept");
continue;
}
printf("New connection, socket fd is %d, ip is : %s, port : %d\n",
new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
// 将新的 socket 添加到客户端数组
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_sockets[i] == -1) {
client_sockets[i] = new_socket;
break;
}
}
}
// 检查所有客户端 socket
for (int i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];
if (sd > 0 && FD_ISSET(sd, &read_fds)) {
// 读取数据
int valread = read(sd, buffer, BUFFER_SIZE);
if (valread == 0) {
// 客户端断开连接
printf("Client disconnected, socket fd %d\n", sd);
close(sd);
client_sockets[i] = -1; // 从数组中移除
} else {
// 回显数据
buffer[valread] = '\0';
printf("Message from client %d: %s", sd, buffer);
send(sd, buffer, valread, 0);
}
}
}
}
return 0;
}
select 的优缺点
优点
- 跨平台:几乎所有支持 sockets 的操作系统都实现了
select。 - 实现简单:API 和概念相对简单,容易理解和使用。
缺点
- 性能瓶颈:
- 文件描述符数量限制:
fd_set的大小通常是有限的(在 Linux 上默认是 1024),虽然可以通过FD_SETSIZE宏调整,但这是一个编译时限制。 - 线性扫描:
select内部是通过遍历fd_set来检查 FD 状态的,当监视的 FD 数量很大时(例如数千个),每次调用select都要进行线性扫描,效率会急剧下降,时间复杂度是 O(n)。
- 文件描述符数量限制:
- 集合修改:
select会修改传入的fd_set,每次循环都需要重新设置,增加了编程的复杂性。 - 无法得知具体事件:
select只能告诉你某个 FD “就绪了”,但无法像epoll那样告诉你具体是“可读”、“可写”还是“异常”,你需要自己再次检查。
select 是 I/O 多路复用的入门和基石,它通过让一个线程监视多个 FD,极大地提高了服务器的并发处理能力,由于其固有的性能限制(线性扫描和 FD 数量限制),在需要处理成千上万个连接的高性能服务器场景中,现代 Linux 系统更倾向于使用 epoll,而 BSD 系统则使用 kqueue,但对于中小型应用、学习或跨平台需求不高的场景,select 仍然是一个简单有效的选择。
