主题
第 3 章 · select 详解
学完本章你将知道:select 是怎么用一张"位图"同时盯多个 fd 的、为什么它有 1024 这个魔咒、它的三大致命缺陷是什么——这三大缺陷恰好是 poll 和 epoll 出现的原因。
1. 一句话开场
select 是 1983 年 4.2BSD 引入的第一个 I/O 多路复用系统调用。 它给你 3 张"位图",分别表示「我关心哪些 fd 的读 / 写 / 错误事件」,传给内核——内核帮你盯着,任何一个 fd 准备好了就回报。
生活类比:你给前台桌子上摆了 3 个本子——
- 读本子:写下你关心的 fd 编号("3、5、8 来电话叫我")
- 写本子:写下你想发消息的 fd 编号
- 错本子:写下你担心出问题的 fd 编号
然后你跟前台说"任一个有动静叫我",人就睡了。前台某个本子上的某个 fd 出事了,就在本子上画个圈,喊醒你——你回来挨个本子看哪儿被画圈了。
一个细节:前台每次"扫一遍本子看谁出事"是 O(n) 的——这就是 select 的痛点。
2. 函数签名一览
c
#include <sys/select.h>
int select(int nfds,
fd_set *readfds, // 关心可读的 fd 集合(位图)
fd_set *writefds, // 关心可写的 fd 集合
fd_set *exceptfds, // 关心异常的 fd 集合
struct timeval *timeout);
// 操作 fd_set 的辅助宏
void FD_ZERO(fd_set *set); // 清空集合
void FD_SET(int fd, fd_set *set); // 加入一个 fd
void FD_CLR(int fd, fd_set *set); // 移除一个 fd
int FD_ISSET(int fd, fd_set *set); // 判断 fd 是否在集合中| 参数 | 含义 | 关键说明 |
|---|---|---|
nfds | 最大 fd + 1 | 是上界,告诉内核"扫到这里就行" |
readfds | 关心可读的 fd 集合 | 输入是"想监听的",输出是"已就绪的"——会被改写 |
writefds | 关心可写的 fd 集合 | 同上 |
exceptfds | 关心异常事件的 fd 集合 | 同上 |
timeout | 超时时间 | NULL=永久阻塞,{0,0}=立即返回,其他=最多等多久 |
返回值:
> 0:就绪 fd 总数0:超时-1:出错(看 errno)
3. fd_set:位图的真面目
3.1 它究竟是什么?
fd_set 在 Linux 上定义为:
c
typedef struct {
unsigned long fds_bits[1024 / (8 * sizeof(unsigned long))];
} fd_set;
// 在 64 位机器上 = unsigned long fds_bits[16]
// 16 * 64 = 1024 bit,正好可以表示 0~1023 共 1024 个 fd本质:一个 1024 比特的位图(bitmap)——下标 i 处的 bit 是 1,表示"关心 fd=i"。
3.2 为什么叫"位图"
text
fd_set readfds 的内部结构(128 字节 = 1024 bit)
fd 编号: 0 1 2 3 4 5 6 7 ... 62 63 | 64 65 ... 127 | ... | 1023
───────────────────────────── ───────────── ─────
bit 值: 0 0 0 1 0 1 0 0 ... 0 0 | 0 0 ... 0 | ... | 0
↑ ↑
│ └── FD_SET(5, &readfds) 之后这一位变成 1
└──────── FD_SET(3, &readfds) 之后这一位变成 1
这表示:现在我关心 fd=3 和 fd=5 的可读事件关键洞察:因为是位图,每个 fd 只占 1 bit。所以 1024 个 fd 才用 128 字节——非常省空间。但代价是位图大小是固定的——这就是 1024 限制的根源。
3.3 为什么是 1024?能改吗?
FD_SETSIZE 是个编译期常量(不是运行时配置),在 /usr/include/sys/select.h 中:
c
#define __FD_SETSIZE 1024理论上可以重新编译 glibc 改大它——但你不应该这么干。
- 改大后所有依赖 glibc 的程序都要重新编译
- 即使大了,select 性能还是 O(n),没意义
- 正确做法:要监听更多 fd,用 poll 或 epoll
4. 标准用法模板
4.1 单 fd 等待(简化场景)
c
fd_set rfds;
struct timeval tv = {.tv_sec = 5, .tv_usec = 0}; // 超时 5 秒
FD_ZERO(&rfds);
FD_SET(STDIN_FILENO, &rfds);
int ret = select(STDIN_FILENO + 1, &rfds, NULL, NULL, &tv);
if (ret == -1) perror("select");
else if (ret == 0) printf("5 秒超时,啥也没来\n");
else {
if (FD_ISSET(STDIN_FILENO, &rfds))
printf("stdin 有数据可读了\n");
}4.2 多 fd 服务器骨架(核心模板)
c
fd_set master, working; // master 是"想监听集合",working 是给 select 改写的副本
FD_ZERO(&master);
FD_SET(listen_fd, &master);
int max_fd = listen_fd;
while (1) {
working = master; // ⚠️ 必须每次循环都拷贝!select 会改写
int n = select(max_fd + 1, &working, NULL, NULL, NULL);
if (n < 0) { perror("select"); break; }
for (int fd = 0; fd <= max_fd; fd++) { // ⚠️ 必须遍历所有 fd
if (!FD_ISSET(fd, &working)) continue;
if (fd == listen_fd) {
int conn = accept(listen_fd, NULL, NULL);
FD_SET(conn, &master); // 加入监听
if (conn > max_fd) max_fd = conn;
} else {
char buf[1024];
ssize_t r = read(fd, buf, sizeof(buf));
if (r <= 0) {
close(fd);
FD_CLR(fd, &master); // 移除
} else {
write(fd, buf, r); // echo
}
}
}
}三个易错点(强烈记住):
- 每次循环必须
working = master:select 会修改你传入的 fd_set,把"我关心的"改成"已就绪的"- 必须遍历
0 ~ max_fd:因为 fd_set 是位图,没法"只看就绪的"- fd 数字会被 accept 一直推大:要维护
max_fd,否则 select 会漏看新 fd
5. 核心机制深挖
5.1 内核里 select 究竟做了什么?
图解说明:select 一次调用涉及:
- 2 次 O(n) 拷贝(用户↔内核)
- 1 次 O(n) 加等待队列
- 1 次 O(n) 遍历检查就绪状态
- 1 次 O(n) 用户态遍历找就绪 fd
——所以 1000 个 fd 时,select 一次至少做 4000+ 次操作。这就是它慢的根本原因。
5.2 等待队列:进程是怎么被唤醒的?
每个 fd 在内核里都有一个等待队列(wait queue)——所有"在等这个 fd 的进程"都挂在上面。
text
fd=4 (socket) 的内核数据结构
┌────────────────────────────┐
│ struct file │
│ ├─ private_data → sock │
│ │ │
│ └─ wait_queue: │
│ ├─→ Process A (在 select)
│ ├─→ Process B (在 epoll_wait)
│ └─→ Process C (在 read)
└────────────────────────────┘
当数据到达 fd=4 时:
内核遍历 wait_queue → 唤醒 A、B、C
(这就是惊群问题的根源)6. select 的三大致命缺陷
缺陷 1:fd 数量上限 1024
c
FD_SET(2000, &rfds); // ⚠️ 编译能过,运行时未定义行为(写到位图外内存了)影响:写不了高并发服务器。
缺陷 2:每次 O(n) 拷贝 + O(n) 遍历
监听 10000 个 fd 但只有 1 个就绪?select 还是会做 10000 次 bit 检查。
影响:fd 越多 select 越慢,这是它根本性的瓶颈。
缺陷 3:fd_set 被改写,必须每次重置
c
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(3, &rfds);
select(4, &rfds, NULL, NULL, NULL); // 调用后 rfds 被改写
// 现在 rfds 里只有"就绪"的 fd,"原本想监听"的信息丢了影响:必须用两个 fd_set(master + working),增加心智负担。
7. 动手实践
实践 1:编译运行 echo 服务器
请直接看本页底部 [💻 示例代码] 区,包含 4 个由浅入深的样例:
| 文件 | 内容 |
|---|---|
01_simple_select.c | 入门:监听 stdin 5 秒超时演示 |
02_echo_server.c | 实战:select 版多客户端 echo 服务器 |
03_timer_loop.c | 进阶:用 select 的 timeout 当定时器 |
04_fd_set_internals.c | 内幕:手动操作 fd_set 看位图原理 |
实践 2:用 telnet 测试 echo 服务器
bash
# 终端 1
gcc 02_echo_server.c -o echo_srv && ./echo_srv 9999
# 终端 2
telnet localhost 9999
# 输入啥都会被原样返回
# 终端 3
telnet localhost 9999
# 同时第二个客户端,select 也能处理实践 3:观察 select 系统调用
bash
strace -e trace=select ./echo_srv 9999 &
SRV=$!
# 在另一个终端 telnet 进去
echo "hello" | nc localhost 9999
# 看 strace 输出,会看到类似:
# select(5, [3 4], NULL, NULL, NULL) = 1 (in [4])
# select(5, [3 4], NULL, NULL, NULL) = 1 (in [4])
# ...
kill $SRV解读:每次
select返回时,[3 4]这个数组就是 select 改写后的"就绪 fd 集合"——可以直接看到 select 是怎么和内核打交道的。
8. 常见陷阱
🪤 陷阱 1:忘记每次重置 fd_set
c
// ❌ 错误:fd_set 调用一次就被改写了
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(fd, &rfds);
while (1) {
select(fd + 1, &rfds, NULL, NULL, NULL); // 第二次开始 fd_set 是空的!
// ...
}
// ✅ 正确:用 master + working 两份
fd_set master, working;
FD_ZERO(&master);
FD_SET(fd, &master);
while (1) {
working = master;
select(fd + 1, &working, NULL, NULL, NULL);
}🪤 陷阱 2:nfds 写小了
c
FD_SET(10, &rfds);
select(5, &rfds, ...); // ❌ nfds=5,但你监听的 fd 是 10,永远不会触发!正确:nfds 必须是「最大 fd 数字 + 1」。
🪤 陷阱 3:对 fd > 1024 的使用
c
int fd = open(...); // 假设拿到 fd = 2000
FD_SET(fd, &rfds); // ❌ 越界写入,可能踩坏内存解决:要么用 poll/epoll,要么自己确保 fd 不超 1024(关掉一些不用的)。
🪤 陷阱 4:超时时间被改写
部分 Linux 实现下,select 返回后会修改 timeout 为剩余时间。所以循环里要重置 timeout:
c
struct timeval tv = {5, 0};
while (1) {
tv.tv_sec = 5; tv.tv_usec = 0; // 必须重置
select(...);
}9. 本章小结
text
select 的关键事实清单
─────────────────────────────────
✅ 第一个多路复用 syscall,跨平台(POSIX)
✅ 用位图传递 fd 集合,1024 个 fd 上限
✅ 一次调用:2 次 O(n) 拷贝 + 2 次 O(n) 遍历
✅ fd_set 会被内核改写,必须 master + working
✅ 三大缺陷 → poll 解决"1024 限制",epoll 解决"O(n)"10. 真实面试题
Q1:select 为什么有 1024 这个限制?能改吗?
A:因为
fd_set的底层是 1024 bit 的固定大小位图(__FD_SETSIZE编译期常量)。理论上可以重新编译 glibc 改大,但实践不可行——影响太大且不解决性能问题,应直接用 poll/epoll。
Q2:select 调用后为什么 fd_set 不能直接复用?
A:select 内核里会修改传入的 fd_set——把"想监听"的 bit 改成"已就绪"的 bit。所以下次循环必须重新设置(标准做法是 master/working 两份)。
Q3:select 性能为什么慢?请定量说明。
A:每次调用涉及——① 用户→内核拷贝整个 fd_set(O(n));② 内核遍历所有 fd 检查就绪(O(n));③ 内核→用户拷贝改写后的 fd_set(O(n));④ 用户态用 FD_ISSET 找就绪 fd(O(n))。4 倍 O(n) + 系统调用开销,万级 fd 时性能崩溃。
Q4:select 和 poll 的本质区别是什么?
A:① 数据结构:select 用位图(固定 1024 大小),poll 用结构体数组(无上限);② 接口:select 有 3 张表(读/写/错),poll 用同一个
pollfd通过events字段区分;③ 复用:select 的 fd_set 会被改写,poll 的revents与events分离不会污染。性能本质都是 O(n),没有质的飞跃。
Q5:用 select 写 echo 服务器,每次循环必须做什么?为什么?
A:① 重新拷贝 master fd_set 给 working——因为 select 会改写;② 重新设置超时时间——某些实现下 timeout 会被改写为剩余时间;③ 遍历 0~max_fd 的所有位——因为位图没法只看就绪的。
下一站 → 第 4 章:poll 详解,看 1997 年 System V 的 poll 是怎么用结构体数组优雅地解决 1024 上限的,以及为什么它仍然不够快。
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗
💻 示例代码
c
/*
* 01_simple_select.c —— 入门:select 监听 stdin 5 秒超时
*
* 编译: gcc 01_simple_select.c -o simple_select
* 运行: ./simple_select
* 玩法: 5 秒内输入任意文字回车,或啥都不做等超时
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
#include <errno.h>
int main(void) {
fd_set rfds;
struct timeval tv;
int retval;
FD_ZERO(&rfds);
FD_SET(STDIN_FILENO, &rfds);
tv.tv_sec = 5;
tv.tv_usec = 0;
printf("⏰ 5 秒内输入任意文字...\n");
fflush(stdout);
/*
* select 的第一个参数是「最大 fd + 1」
* STDIN_FILENO == 0,所以这里是 1
*/
retval = select(STDIN_FILENO + 1, &rfds, NULL, NULL, &tv);
if (retval == -1) {
perror("select error");
return EXIT_FAILURE;
} else if (retval == 0) {
printf("😴 5 秒到了,啥也没等到\n");
} else {
if (FD_ISSET(STDIN_FILENO, &rfds)) {
char buf[256];
ssize_t n = read(STDIN_FILENO, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("✅ 收到 %ld 字节: %s", n, buf);
}
}
}
return EXIT_SUCCESS;
}c
/*
* 02_echo_server.c —— select 版多客户端 echo 服务器
*
* 编译: gcc 02_echo_server.c -o echo_srv
* 运行: ./echo_srv 9999
* 测试: 开多个终端 `telnet localhost 9999` 或 `nc localhost 9999`
*
* 这个例子完整展示了 select 写服务器的标准套路:
* 1) master / working 两份 fd_set
* 2) 每次循环 working = master
* 3) 遍历 0 ~ max_fd 检查就绪
* 4) accept 新连接时维护 max_fd
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>
#define BUF_SIZE 1024
static int make_listen_socket(int port) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) { perror("socket"); exit(1); }
int yes = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(port);
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind"); exit(1);
}
if (listen(fd, 64) < 0) {
perror("listen"); exit(1);
}
printf("🚀 echo 服务器已启动,端口 %d\n", port);
return fd;
}
int main(int argc, char *argv[]) {
int port = (argc > 1) ? atoi(argv[1]) : 9999;
int listen_fd = make_listen_socket(port);
fd_set master, working;
FD_ZERO(&master);
FD_SET(listen_fd, &master);
int max_fd = listen_fd;
while (1) {
working = master; /* select 会改写 working,必须每轮拷贝 */
int n = select(max_fd + 1, &working, NULL, NULL, NULL);
if (n < 0) {
if (errno == EINTR) continue;
perror("select");
break;
}
for (int fd = 0; fd <= max_fd && n > 0; fd++) {
if (!FD_ISSET(fd, &working)) continue;
n--;
if (fd == listen_fd) {
/* 新连接到达 */
struct sockaddr_in cli;
socklen_t cli_len = sizeof(cli);
int conn = accept(listen_fd, (struct sockaddr *)&cli, &cli_len);
if (conn < 0) { perror("accept"); continue; }
FD_SET(conn, &master);
if (conn > max_fd) max_fd = conn;
printf("✅ 新客户端 fd=%d, 来自 %s:%d (当前 max_fd=%d)\n",
conn, inet_ntoa(cli.sin_addr), ntohs(cli.sin_port), max_fd);
} else {
/* 已连接客户端有数据 */
char buf[BUF_SIZE];
ssize_t r = read(fd, buf, sizeof(buf));
if (r <= 0) {
if (r == 0) printf("👋 客户端 fd=%d 断开\n", fd);
else perror("read");
close(fd);
FD_CLR(fd, &master);
} else {
/* echo 回去 */
write(fd, buf, r);
printf("📨 fd=%d 收到 %ld 字节,已回 echo\n", fd, r);
}
}
}
}
close(listen_fd);
return 0;
}c
/*
* 03_timer_loop.c —— 用 select 的 timeout 当定时器
*
* 编译: gcc 03_timer_loop.c -o timer_loop
* 运行: ./timer_loop
*
* 思路: 把 timeout 设成 1 秒,每次返回都打印一次 tick;
* 同时还能监听 stdin,输入 'q' 退出。
* 这是没有专用 timerfd 的年代,select 兼任定时器的常用技巧。
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
int main(void) {
fd_set rfds;
struct timeval tv;
int ticks = 0;
printf("⏱ 每秒打印一次 tick;输入 q + 回车退出\n");
fflush(stdout);
while (1) {
FD_ZERO(&rfds);
FD_SET(STDIN_FILENO, &rfds);
tv.tv_sec = 1;
tv.tv_usec = 0;
int n = select(STDIN_FILENO + 1, &rfds, NULL, NULL, &tv);
if (n < 0) { perror("select"); break; }
if (n == 0) {
/* 超时 = 一个 tick */
printf("🔔 tick #%d\n", ++ticks);
fflush(stdout);
} else {
/* stdin 有数据 */
char buf[64];
ssize_t r = read(STDIN_FILENO, buf, sizeof(buf));
if (r > 0 && buf[0] == 'q') {
printf("👋 收到 q,退出\n");
break;
}
printf("📨 你输入了: %.*s", (int)r, buf);
}
}
return 0;
}c
/*
* 04_fd_set_internals.c —— fd_set 内部位图原理探秘
*
* 编译: gcc 04_fd_set_internals.c -o fd_internals
* 运行: ./fd_internals
*
* 这个示例不调用 select,只是把 fd_set 的内部布局打印出来,
* 帮助你直观理解「为什么 fd_set 是位图」、「为什么有 1024 限制」。
*/
#include <stdio.h>
#include <string.h>
#include <sys/select.h>
static void dump_fd_set(const char *tag, fd_set *s) {
unsigned char *bytes = (unsigned char *)s;
size_t bits = sizeof(*s) * 8;
printf("[%s] 总大小 = %zu 字节 = %zu bit (理论最多支持 %zu 个 fd)\n",
tag, sizeof(*s), bits, bits);
/* 找出所有为 1 的 bit */
printf(" bit 为 1 的 fd: ");
int found = 0;
for (size_t i = 0; i < bits; i++) {
size_t byte = i / 8, bit = i % 8;
if (bytes[byte] & (1u << bit)) {
printf("%zu ", i);
found = 1;
}
}
if (!found) printf("(无)");
printf("\n");
/* 打印前 16 字节的二进制布局 */
printf(" 前 16 字节十六进制: ");
for (size_t i = 0; i < 16 && i < sizeof(*s); i++) {
printf("%02x ", bytes[i]);
}
printf("\n\n");
}
int main(void) {
fd_set s;
FD_ZERO(&s);
dump_fd_set("初始 (FD_ZERO 后)", &s);
FD_SET(0, &s); /* fd=0 */
FD_SET(3, &s); /* fd=3 */
FD_SET(7, &s); /* fd=7 */
FD_SET(63, &s); /* fd=63 (位于第一个 long 的最高位) */
FD_SET(64, &s); /* fd=64 (跨到第二个 long) */
dump_fd_set("FD_SET(0,3,7,63,64) 后", &s);
FD_CLR(3, &s);
dump_fd_set("FD_CLR(3) 后", &s);
printf("FD_ISSET(7, &s) = %d\n", FD_ISSET(7, &s));
printf("FD_ISSET(3, &s) = %d (已清除)\n", FD_ISSET(3, &s));
printf("\n💡 关键启示:\n");
printf(" 1. 每个 fd 只占 1 bit -> 极度省空间\n");
printf(" 2. 位图大小 = FD_SETSIZE = 1024 -> 这是编译期常量\n");
printf(" 3. FD_SET(2000, &s) 会越界写入 -> 未定义行为,可能踩坏内存\n");
return 0;
}01_simple_select.c ↗ · 02_echo_server.c ↗ · 03_timer_loop.c ↗ · 04_fd_set_internals.c ↗