引言
在传统的多进程/多线程服务器模型中,每一个客户端连接都需要一个独立的进程或线程来处理。当客户端数量较少时,这种方式工作良好。但当并发连接数达到成千上万时,进程/线程的创建、销毁和切换开销会急剧增加,导致服务器性能严重下降。
I/O 多路复用就是为了解决这个问题而生的。它的核心思想是:用一个线程同时监控多个文件描述符,只处理那些已经就绪的(有数据可读、可写等)描述符。
I/O 多路复用允许单线程同时处理多个 I/O 操作,避免了为每个连接创建独立进程/线程的开销。Linux 系统提供了三种 I/O 多路复用机制:select、poll和epoll。今天,我们将从最基础的select开始讲解。
第一部分:select 函数基础
一、函数原型
#include <sys/select.h> #include <sys/time.h> int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);二、参数详解
| 参数 | 类型 | 作用 |
|---|---|---|
nfds | int | 监控的最大文件描述符值 + 1 |
readfds | fd_set* | 监控读事件(数据可读、新连接到达) |
writefds | fd_set* | 监控写事件(缓冲区空闲、可发送数据) |
exceptfds | fd_set* | 监控异常事件(通常设为 NULL) |
timeout | timeval* | 超时时间(NULL表示永久阻塞) |
三、返回值
| 返回值 | 含义 |
|---|---|
>0 | 就绪的文件描述符数量 |
=0 | 超时(没有就绪的描述符) |
=-1 | 调用失败(可通过 errno 获取错误码) |
四、timeout 结构体
struct timeval { time_t tv_sec; // 秒 suseconds_t tv_usec; // 微秒(1秒 = 1,000,000微秒) }; // 使用示例 struct timeval tv; tv.tv_sec = 5; // 等待5秒 tv.tv_usec = 0; // 0微秒timeout 参数的特殊值:
| 值 | 含义 |
|---|---|
NULL | 永久阻塞,直到有描述符就绪 |
tv_sec = 0, tv_usec = 0 | 非阻塞轮询,立即返回 |
tv_sec > 0 | 等待指定时间后超时 |
五、fd_set 结构体
fd_set是一个位掩码集合,用于存储要监控的文件描述符。
// fd_set 的内部实现(简化理解) typedef struct { long int __fds_bits[__FD_SETSIZE / (8 * sizeof(long int))]; } fd_set; // 常量 #define FD_SETSIZE 1024 // select 能监控的最大描述符数量相关操作宏:
| 宏 | 作用 |
|---|---|
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返回后会修改传入的fd_set集合,只保留就绪的描述符。因此每次调用select前都需要重新设置监控集合。
第二部分:select 的就绪条件
一、读就绪条件
| 场景 | 说明 |
|---|---|
| 套接字接收缓冲区有数据可读 | recv不会阻塞 |
| 监听套接字有新连接到达 | accept不会阻塞 |
| 对方关闭连接(收到 FIN) | recv返回 0 |
| 发生异常 | 需要进一步处理 |
二、写就绪条件
| 场景 | 说明 |
|---|---|
| 套接字发送缓冲区有空间 | send不会阻塞 |
| 连接已建立 | 可以发送数据 |
第三部分:select 的基本使用
一、监控标准输入
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/select.h> int main() { fd_set readfds; struct timeval tv; char buffer[128]; while (1) { // 1. 清空集合并添加标准输入 FD_ZERO(&readfds); FD_SET(STDIN_FILENO, &readfds); // 2. 设置超时时间(5秒) tv.tv_sec = 5; tv.tv_usec = 0; // 3. 监控读事件 int ret = select(STDIN_FILENO + 1, &readfds, NULL, NULL, &tv); if (ret == -1) { perror("select error"); break; } else if (ret == 0) { printf("5秒内没有输入,超时!\n"); } else { // 检查标准输入是否就绪 if (FD_ISSET(STDIN_FILENO, &readfds)) { int n = read(STDIN_FILENO, buffer, sizeof(buffer) - 1); if (n > 0) { buffer[n] = '\0'; printf("输入内容: %s", buffer); } } } } return 0; }二、nfds 参数详解
nfds参数的含义是:所有监控描述符中的最大值 + 1。
// 示例:监控描述符 3、5、7 // 最大描述符是 7,所以 nfds = 7 + 1 = 8 // 示例:只监控标准输入(描述符 0) // nfds = 0 + 1 = 1为什么需要这个参数?
内核需要知道要检测哪些描述符。如果nfds设置过小,可能导致部分描述符未被检测;设置过大则浪费性能(内核会检查到nfds-1)。
第四部分:select 实现多客户端服务器
一、数据结构设计
#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> #define PORT 6000 #define MAX_CLIENTS 1024 #define BUFFER_SIZE 128 // 客户端描述符数组(管理所有连接) int client_fds[MAX_CLIENTS]; // 初始化描述符数组 void init_fds() { for (int i = 0; i < MAX_CLIENTS; i++) { client_fds[i] = -1; // -1 表示空闲 } } // 添加描述符 void add_fd(int fd) { for (int i = 0; i < MAX_CLIENTS; i++) { if (client_fds[i] == -1) { client_fds[i] = fd; break; } } } // 移除描述符 void remove_fd(int fd) { for (int i = 0; i < MAX_CLIENTS; i++) { if (client_fds[i] == fd) { client_fds[i] = -1; break; } } }二、创建监听套接字
int create_listen_socket() { // 1. 创建套接字 int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd == -1) { perror("socket error"); return -1; } // 2. 设置端口复用 int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // 3. 绑定地址和端口 struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(PORT); addr.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) { perror("bind error"); close(listen_fd); return -1; } // 4. 监听 if (listen(listen_fd, 5) == -1) { perror("listen error"); close(listen_fd); return -1; } printf("服务器启动成功,端口:%d\n", PORT); return listen_fd; }三、构建监控集合
// 将客户端描述符数组中的有效描述符加入 fd_set int build_fdset(fd_set* readfds, int* max_fd) { FD_ZERO(readfds); int max = -1; for (int i = 0; i < MAX_CLIENTS; i++) { if (client_fds[i] != -1) { FD_SET(client_fds[i], readfds); if (client_fds[i] > max) { max = client_fds[i]; } } } *max_fd = max; return 0; }四、处理就绪事件
void handle_events(fd_set* readfds, int listen_fd) { // 1. 检查监听套接字(新连接) if (FD_ISSET(listen_fd, readfds)) { struct sockaddr_in client_addr; socklen_t len = sizeof(client_addr); int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &len); if (client_fd != -1) { printf("新客户端连接,fd=%d\n", client_fd); add_fd(client_fd); } } // 2. 检查所有客户端连接套接字(数据收发) for (int i = 0; i < MAX_CLIENTS; i++) { int fd = client_fds[i]; if (fd == -1) continue; if (FD_ISSET(fd, readfds)) { char buffer[BUFFER_SIZE]; int n = recv(fd, buffer, BUFFER_SIZE - 1, 0); if (n <= 0) { // 客户端关闭连接 printf("客户端 %d 断开连接\n", fd); close(fd); remove_fd(fd); } else { buffer[n] = '\0'; printf("收到数据 (fd=%d): %s\n", fd, buffer); send(fd, "OK", 2, 0); } } } }五、主循环
int main() { // 初始化 init_fds(); // 创建监听套接字 int listen_fd = create_listen_socket(); if (listen_fd == -1) { exit(1); } // 将监听套接字加入管理 add_fd(listen_fd); fd_set readfds; struct timeval tv; while (1) { // 1. 构建监控集合 int max_fd; build_fdset(&readfds, &max_fd); // 2. 设置超时(每次都要重新设置) tv.tv_sec = 2; tv.tv_usec = 0; // 3. 调用 select int ret = select(max_fd + 1, &readfds, NULL, NULL, &tv); if (ret == -1) { perror("select error"); break; } else if (ret == 0) { // 超时,可执行一些定时任务 // printf("select timeout\n"); continue; } // 4. 处理就绪事件 handle_events(&readfds, listen_fd); } close(listen_fd); return 0; }第五部分:select 的注意事项
一、每次调用前需要重置集合
select返回后会修改fd_set,只保留就绪的描述符。因此,每次调用select前都需要重新构建监控集合。
// 正确做法:每次循环都重新构建 while (1) { FD_ZERO(&readfds); FD_SET(listen_fd, &readfds); // ... 添加其他描述符 select(...); } // 错误做法:只初始化一次 FD_ZERO(&readfds); FD_SET(listen_fd, &readfds); while (1) { select(...); // 第一次调用后 readfds 被修改 // 后续循环中 readfds 已经不是原来的集合了 }二、超时时间需要重置
select返回后,timeout结构体可能会被修改(剩余时间)。因此每次调用前都需要重新设置。
// 正确做法 while (1) { tv.tv_sec = 2; tv.tv_usec = 0; select(..., &tv); }三、select 的最大描述符限制
select默认最多支持FD_SETSIZE(通常为 1024)个文件描述符。
# 查看当前系统的文件描述符限制 ulimit -n # 临时修改限制 ulimit -n 1000000如果需要处理超过 1024 个连接,应该选择epoll。
四、缓冲区截断问题
// 如果接收缓冲区太小,一次只能读取部分数据 char buffer[1]; // 只能读1个字节 recv(fd, buffer, 1, 0); // 剩余数据还在接收缓冲区中,下次 select 仍会报告该描述符就绪当客户端发送 "hello"(5个字节),而服务器缓冲区只有1字节时,需要多次select才能读完所有数据。
第六部分:select 的优缺点总结
优点
| 优点 | 说明 |
|---|---|
| 跨平台 | 几乎所有操作系统都支持 |
| 简单易懂 | API 相对简单,适合学习入门 |
| 事件类型丰富 | 同时支持读、写、异常事件 |
缺点
| 缺点 | 说明 |
|---|---|
| 描述符数量限制 | 默认只能监控 1024 个描述符 |
| 每次都需要重新构建集合 | 效率较低 |
| 不可知具体哪个描述符就绪 | 需要遍历所有描述符检查 |
| 缺乏边缘触发模式 | 只有水平触发 |
| 描述符集合会被修改 | 需要保存备份 |
总结
一、select 使用流程
二、核心函数速查表
| 函数/宏 | 作用 |
|---|---|
select() | 监控多个文件描述符的状态 |
FD_ZERO() | 清空集合 |
FD_SET() | 将描述符加入集合 |
FD_CLR() | 将描述符从集合中移除 |
FD_ISSET() | 检测描述符是否在集合中 |
三、使用注意事项
| 注意点 | 说明 |
|---|---|
| 每次调用前重置集合 | select会修改原始集合 |
| 每次调用前重置超时 | timeout可能被修改 |
| nfds 是最大值+1 | 否则可能漏检或浪费性能 |
| 遍历检查所有描述符 | 不能仅依赖返回值 |
select是最基础的 I/O 多路复用机制,理解它的工作原理对于学习更高级的epoll非常重要。
学习建议:
动手实现 select 服务器,理解其工作流程
对比多进程/多线程模型,体会 I/O 多路复用的优势
理解
select的局限性,为学习epoll打好基础