15. select模型:select()函数详解、fd_set操作、最大文件描述符限制
说到网络编程里的多路复用,select 模型绝对是个绕不开的老前辈。我刚开始写网络程序那会儿,用的就是它。说实话,现在 epoll 大行其道,但 select 在很多嵌入式场景和老系统里依然坚挺。你想想看,搞懂 select,其实就搞懂了 I/O 多路复用的核心思想。
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:最大文件描述符 + 1。注意,不是文件描述符的数量,是最大值加一。我见过有人直接传 1024,结果程序跑得莫名其妙。
- readfds:要监视可读的集合。比如 socket 有数据来了,这个集合就会标记它。
- writefds:要监视可写的集合。缓冲区有空了,就能写。
- exceptfds:监视异常。比如带外数据。
- timeout:超时时间。传 NULL 表示一直等,传 0 表示立即返回。
返回值:成功返回就绪的文件描述符数量,超时返回 0,出错返回 -1。
fd_set 操作:别直接赋值!
fd_set 是个位图结构。每个 bit 对应一个文件描述符。但你不能直接操作它,得用宏:
fd_set readfds;
FD_ZERO(&readfds); // 清空集合
FD_SET(sockfd, &readfds); // 把 sockfd 加进去
FD_CLR(sockfd, &readfds); // 把 sockfd 移除
if (FD_ISSET(sockfd, &readfds)) {
// 判断 sockfd 是否在集合中
}
我个人习惯每次调用 select 前都重新设置 fd_set。因为 select 返回后,集合会被修改,只保留就绪的描述符。你想想看,如果不重新设置,第二次调用时集合就乱了。
小技巧:每次循环开始前,用 FD_ZERO 清空,再用 FD_SET 把需要的描述符加进去。别偷懒,否则 bug 很难查。
最大文件描述符限制:1024 的痛
select 有个硬伤——它默认最多监视 1024 个文件描述符。这个限制来自 FD_SETSIZE,通常是 1024。为什么会这样?因为 fd_set 是个固定大小的位图,底层是数组。
我在项目中遇到过这个问题。当时写一个高并发聊天服务器,连接数刚过 1000 就出问题了。查了半天,发现是 select 的限制。后来改用了 poll 才解决。
| 限制来源 | 说明 |
|---|---|
| FD_SETSIZE | 默认 1024,可修改但需重新编译内核 |
| 内核限制 | select 每次都要遍历所有 fd,性能随数量线性下降 |
| 位图大小 | fd_set 是固定大小,超出范围会越界 |
注意:如果你试图监视超过 FD_SETSIZE 的文件描述符,程序会崩溃。我曾经在代码里没检查,结果线上服务挂了。后来加了个断言,才放心。
一个完整的 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 8888
#define MAX_CLIENTS 10
int main() {
int server_fd, client_fd, max_fd, activity;
struct sockaddr_in address;
fd_set readfds;
int client_sockets[MAX_CLIENTS] = {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, 3);
printf("服务器启动,监听端口 %d\n", PORT);
while (1) {
FD_ZERO(&readfds);
FD_SET(server_fd, &readfds);
max_fd = server_fd;
// 把客户端 socket 也加进去
for (int i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];
if (sd > 0) {
FD_SET(sd, &readfds);
}
if (sd > max_fd) {
max_fd = sd;
}
}
// 等待活动
activity = select(max_fd + 1, &readfds, NULL, NULL, NULL);
if (activity < 0) {
perror("select 出错");
continue;
}
// 有新连接
if (FD_ISSET(server_fd, &readfds)) {
client_fd = accept(server_fd, NULL, NULL);
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_sockets[i] == 0) {
client_sockets[i] = client_fd;
printf("新客户端加入,fd = %d\n", client_fd);
break;
}
}
}
// 处理客户端数据
for (int i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];
if (FD_ISSET(sd, &readfds)) {
char buffer[1024] = {0};
int valread = read(sd, buffer, sizeof(buffer));
if (valread == 0) {
// 客户端断开
close(sd);
client_sockets[i] = 0;
printf("客户端断开,fd = %d\n", sd);
} else {
printf("收到数据: %s\n", buffer);
send(sd, buffer, valread, 0);
}
}
}
}
return 0;
}
这段代码里,我每次循环都重新构建 readfds。嗯,这里要注意:select 返回后,集合里只保留就绪的描述符。所以必须重新设置。
select 的核心流程
下面这张图,把 select 的整个调用过程画清楚了。你一看就懂:
避坑指南
我总结几个实际开发中容易踩的坑:
- 忘记重新设置 fd_set:select 返回后集合会被修改。每次循环必须重新 FD_ZERO 和 FD_SET。
- nfds 参数传错:要传最大文件描述符 + 1,不是总数。传小了 select 会漏掉高位的 fd。
- timeout 参数被修改:Linux 下 select 会修改 timeval 结构体。如果重用,每次都要重新设置。
- 信号中断:select 被信号中断时会返回 -1,errno 是 EINTR。这时候应该重新调用,不是直接退出。
我的建议:如果你只是写个简单的聊天室或者嵌入式程序,select 完全够用。但要是高并发场景,还是用 epoll 吧。select 的 1024 限制和 O(n) 遍历,在高负载下确实扛不住。
好了,select 模型的核心内容就这些。说白了,它就是一套帮你管理多个 socket 的机制。理解它的工作原理,后面学 poll 和 epoll 会轻松很多。