一、为什么需要 I/O 复用?
1.1 多进程服务器的缺点
之前我们实现过多进程并发服务器:每个客户端连接就 fork 一个子进程来处理。这种方式虽然能实现并发,但存在明显问题:
- 资源开销大:每个进程都有独立的地址空间,创建进程需要大量内存和时间。
- 进程数量有限:系统能同时运行的进程数受限于内存和 CPU,几百个进程就可能让系统不堪重负。
- 上下文切换频繁:大量进程切换会降低 CPU 效率。
一句话:多进程服务器"重",不适合高并发场景(如成千上万个客户端)。
1.2 I/O 复用的核心思想
用一个进程(线程)同时监视多个文件描述符,当其中任何一个有 I/O 事件(可读、可写、异常)时,就通知程序去处理。
就像餐厅里一个服务员同时照看多张桌子,哪桌客人举手(有事件)就过去服务,而不是每桌配一个服务员。
1.3 I/O 复用的优势
- 单进程处理多个连接,节省内存和 CPU。
- 无进程/线程创建销毁开销。
- 易于管理 ,代码逻辑相对简单。

二、select 函数模型
2.1 文件描述符集合 fd_set
select 使用 fd_set 结构体来保存一组待监视的文件描述符(socket)。fd_set 内部是一个位图(bitset),每一位代表一个文件描述符,最多支持 FD_SETSIZE(通常为 1024)个。
操作 fd_set 的四个宏:
| 宏 | 作用 |
|---|---|
FD_ZERO(fd_set *set) |
将集合清空(所有位归零) |
FD_SET(int fd, fd_set *set) |
将 fd 加入集合(对应位置1) |
FD_CLR(int fd, fd_set *set) |
将 fd 从集合中移除(对应位置0) |
FD_ISSET(int fd, fd_set *set) |
判断 fd 是否仍在集合中(返回非0表示在) |
2.2 select 函数原型
c
#include <sys/select.h>
#include <sys/time.h>
int select(int maxfd, fd_set *readset, fd_set *writeset,
fd_set *exceptset, struct timeval *timeout);
- maxfd :监视的最大文件描述符 +1(因为 select 会遍历 0 ~ maxfd-1)。
- readset:监视"可读"事件(有数据可读、连接请求到达、连接关闭等)。
- writeset:监视"可写"事件(缓冲区有空闲可写)。
- exceptset:监视"异常"事件(一般不用,传 NULL)。
- timeout:超时时间,结构体如下:
c
struct timeval {
long tv_sec; // 秒
long tv_usec; // 微秒
};
返回值:
>0:发生事件的文件描述符数量。0:超时,没有事件发生。-1:出错。
2.3 select 的工作流程
- 初始化一个
fd_set,加入要监视的套接字(如监听套接字serv_sock)。 - 调用
select,程序阻塞等待。 select返回时,readset等集合会被修改,只保留那些发生事件的 fd。- 遍历所有可能 fd,用
FD_ISSET检查哪个 fd 就绪。 - 处理该 fd 上的事件(accept、read、write)。
- 重复步骤 2~5。

三、基于 select 的回声服务器实现
下面我们实现一个单进程、非阻塞、支持多客户端的回声服务器(收到什么就返回什么)。
3.1 服务器代码(完整注释)
c
// 包含标准输入输出库,用于打印错误信息
#include <stdio.h>
// 标准库,用于 exit() 退出程序
#include <stdlib.h>
// 字符串操作库,用于 memset、strlen 等
#include <string.h>
// UNIX 标准函数,用于 close、read、write
#include <unistd.h>
// 网络地址转换函数,如 inet_ntoa、htonl 等
#include <arpa/inet.h>
// socket 相关函数和结构体
#include <sys/socket.h>
// timeval 结构体,用于 select 超时
#include <sys/time.h>
// select 函数及相关宏
#include <sys/select.h>
#define BUF_SIZE 100 // 消息缓冲区大小
// 错误处理函数声明
void error_handling(char *buf);
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock; // 服务器监听套接字 和 客户端连接套接字
struct sockaddr_in serv_adr, clnt_adr; // 服务器地址结构 和 客户端地址结构
// 超时时间结构体,用于 select
struct timeval timeout;
// reads: 原始监视集合(所有需要监视的套接字)
// cpy_reads: 每次 select 调用时使用的副本(select 会修改它)
fd_set reads, cpy_reads;
// 客户端地址结构大小(用于 accept)
socklen_t adr_sz;
// fd_max: 当前最大的文件描述符值(用于 select 第一个参数)
// str_len: 读取数据的长度
// fd_num: select 返回的就绪文件描述符数量
int fd_max, str_len, fd_num, i;
// 数据缓冲区
char buf[BUF_SIZE];
// 检查命令行参数:需要端口号
if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
// ========== 1. 创建 TCP 套接字 ==========
// PF_INET: IPv4 协议族,SOCK_STREAM: 面向连接的 TCP,0: 自动选择协议
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
// 初始化服务器地址结构,全部置零
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET; // IPv4 地址族
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY); // 本机任意可用 IP
serv_adr.sin_port = htons(atoi(argv[1])); // 命令行指定的端口,转网络字节序
// ========== 2. 绑定套接字到地址 ==========
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
// ========== 3. 开始监听 ==========
// 第二个参数 5 表示等待连接队列的最大长度
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
// ==========******** 4. 初始化文件描述符集合 ********==========
// FD_ZERO: 将 reads 集合所有位清零
FD_ZERO(&reads);
// FD_SET: 将监听套接字 serv_sock 加入到 reads 集合中
FD_SET(serv_sock, &reads);
// 当前最大文件描述符就是监听套接字(因为还没有客户端连接)
fd_max = serv_sock;
// ========== 5. 主循环:反复调用 select 处理 I/O 事件 ==========
while (1)
{
// 每次 select 调用前,将原始集合复制一份给 cpy_reads
// 因为 select 会修改传入的集合(只保留就绪的 fd)
cpy_reads = reads;
// 设置超时时间:5 秒 + 5000 微秒(即 5.005 秒)
// 如果传 NULL,select 会一直阻塞直到有事件
timeout.tv_sec = 5;
timeout.tv_usec = 5000;
// ========== 6. 调用 select 函数 ==========
// 参数1: 最大文件描述符 + 1(告诉内核要检查 0 到 fd_max 这些 fd)
// 参数2: 读集合(我们只关心可读事件)
// 参数3,4: 写集合和异常集合,这里不关心,传 NULL
// 参数5: 超时时间
// 返回值: 就绪的文件描述符数量,-1 表示出错,0 表示超时
fd_num = select(fd_max + 1, &cpy_reads, NULL, NULL, &timeout);
if (fd_num == -1) // 出错,退出循环
break;
if (fd_num == 0) // 超时,没有事件发生,继续下一次循环
continue;
// ========== 7. 遍历所有可能的文件描述符(从 0 到 fd_max) ==========
for (i = 0; i < fd_max + 1; i++)
{
// FD_ISSET: 判断文件描述符 i 是否在 cpy_reads 集合中(即是否就绪)
if (FD_ISSET(i, &cpy_reads))
{
// 情况1: 是监听套接字 serv_sock 就绪 -> 有新的客户端连接请求
if (i == serv_sock)
{
adr_sz = sizeof(clnt_adr);
// 接受连接,返回新的客户端套接字
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
// 将新客户端套接字加入 reads 集合(以后也要监视它)
FD_SET(clnt_sock, &reads);
// 如果新套接字的值大于当前 fd_max,则更新 fd_max
if (fd_max < clnt_sock)
fd_max = clnt_sock;
printf("connected client: %d \n", clnt_sock);
}
// 情况2: 是普通客户端套接字就绪 -> 有数据到达或者连接关闭
else
{
// 读取数据(尝试读取最多 BUF_SIZE 字节)
str_len = read(i, buf, BUF_SIZE);
if (str_len == 0) // read 返回 0 表示客户端正常关闭连接
{
// 从监视集合中移除该客户端套接字
FD_CLR(i, &reads);
// 关闭套接字
close(i);
printf("closed client: %d \n", i);
}
else // 收到数据
{
// 将收到的数据原样写回(回声)
write(i, buf, str_len);
}
}
}
}
}
// 关闭监听套接字(实际上由于无限循环,不会执行到这里)
close(serv_sock);
return 0;
}
// 错误处理函数:输出错误信息并退出程序
void error_handling(char *buf)
{
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}
3.2 代码关键点解析
| 关键点 | 解释 |
|---|---|
fd_set reads 与 cpy_reads |
reads 保存所有需要监视的套接字;每次 select 前复制一份到 cpy_reads,因为 select 会修改传入的集合。 |
fd_max |
select 第一个参数是最大 fd +1,所以需要动态跟踪当前最大 fd。 |
select 超时 |
这里设为 5 秒 + 5 毫秒,避免无限阻塞,但实际服务器中通常传 NULL 永久等待。 |
FD_ISSET(i, &cpy_reads) |
判断 fd i 是否就绪。 |
| 新连接处理 | 对 serv_sock 调用 accept,获得新客户端套接字,加入 reads 并更新 fd_max。 |
| 数据读取 | 对普通客户端套接字调用 read,如果返回 0 表示对方关闭,则关闭该套接字并从集合中移除;否则将数据原样写回(回声)。 |
3.3 客户端代码(标准回声客户端)
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char *message);
int main(int argc, char *argv[])
{
int sock; // 客户端套接字
char message[BUF_SIZE]; // 发送和接收缓冲区
int str_len; // 实际接收的字节数
struct sockaddr_in serv_adr; // 服务器地址结构
// 检查命令行参数:需要服务器 IP 和端口
if (argc != 3) {
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(1);
}
// ========== 1. 创建套接字 ==========
sock = socket(PF_INET, SOCK_STREAM, 0);
if (sock == -1)
error_handling("socket() error");
// ========== 2. 初始化服务器地址结构 ==========
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET; // IPv4
serv_adr.sin_addr.s_addr = inet_addr(argv[1]); // 点分十进制 IP 转整数
serv_adr.sin_port = htons(atoi(argv[2])); // 端口转网络字节序
// ========== 3. 连接服务器 ==========
if (connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("connect() error!");
else
puts("Connected...........");
// ========== 4. 循环发送消息并接收回声 ==========
while (1)
{
// 提示用户输入
fputs("Input message(Q to quit): ", stdout);
// 从标准输入读取一行,存入 message
fgets(message, BUF_SIZE, stdin);
// 如果输入 "q\n" 或 "Q\n",退出循环
if (!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
break;
// 发送消息给服务器(不包括结尾的 '\0')
write(sock, message, strlen(message));
// 接收服务器返回的回声
str_len = read(sock, message, BUF_SIZE - 1);
// 在字符串末尾添加 '\0',确保正确打印
message[str_len] = 0;
printf("Message from server: %s", message);
}
// 关闭套接字,结束程序
close(sock);
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
四、运行与测试
4.1 编译
bash
gcc select_server.c -o server
gcc client.c -o client
4.2 运行服务器
bash
./server 9190
4.3 运行多个客户端
bash
# 终端2
./client 127.0.0.1 9190
# 终端3
./client 127.0.0.1 9190
4.4 测试结果
- 每个客户端可以独立发送消息,服务器会原样返回(回声)。
- 服务器端打印连接和关闭信息。
- 服务器只使用一个进程,却能同时服务多个客户端。
五、select 的局限与改进
5.1 优点
- 跨平台(几乎所有操作系统都支持)。
- 代码简单,易于理解。
5.2 缺点
| 缺点 | 说明 |
|---|---|
| 文件描述符上限 | 默认 1024,虽可修改但效率会下降。 |
| 效率低 | 每次调用都要把所有 fd 从用户态拷贝到内核态,且内核需要遍历所有 fd。 |
| 无法动态修改集合 | 每次循环都要重新设置集合(因为 select 会修改)。 |
| O(n) 扫描 | 应用程序需要遍历所有 fd 检查就绪位,复杂度 O(n)。 |
5.3 改进方案
- poll:去除了 1024 限制,但仍是 O(n) 扫描。
- epoll(Linux 特有):事件驱动,只返回就绪的 fd,效率高,适合海量连接。
- kqueue(BSD 系统)。
六、总结
- I/O 复用 用一个进程监视多个套接字,避免多进程/多线程的高昂开销。
- select 是最简单的 I/O 复用模型,通过
fd_set管理监视对象,通过select等待事件。 - 实现步骤:
- 创建监听套接字,加入
fd_set。 - 循环调用
select。 - 根据
FD_ISSET判断事件类型:- 监听套接字 →
accept新连接,加入集合。 - 客户端套接字 →
read数据,返回 0 表示断开,否则处理数据。
- 监听套接字 →
- 创建监听套接字,加入