📌 核心结论(TL;DR):
IO 多路复用:用一个线程同时监控多个文件描述符(fd),解决单线程阻塞和多线程开销大的问题。
select 机制 :利用
fd_set(位图)向内核注册关心的 fd,内核阻塞等待直到有 fd 就绪。三宗罪 :每次调用都要把位图全量拷贝 到内核;内核需要遍历 所有 fd 找就绪的;受限于位图大小,最多只能监听 1024 个 fd。
致命大坑 :
select返回时会把未就绪的 fd 从集合中清空,下次调用前必须重新初始化(重置位图)。
一、 为什么需要多路复用?
传统的阻塞 IO 只能服务一个客户端,非阻塞 IO 需要死循环轮询导致 CPU 飙高。多线程/多进程方案在高并发下会导致内存和调度崩溃。IO 多路复用允许我们使用一个线程,同时监控成百上千个文件描述符。只要有数据就绪,就通知程序处理。
二、 select 的 API 设计

select 使用 fd_set 位图结构来传递信息。比如你想监视 fd 为 5 的套接字,就把第 5 位置 1。调用 select 后,如果没有数据,线程会在这里挂起;如果有数据,select 返回,并告诉你哪些 fd 还是 1(就绪),哪些变成了 0(没就绪)。
三、 实战与核心踩坑

cpp
// 每次调用前必须用临时变量备份!
fd_set tmp_fds = read_fds;
int ret = select(max_fd + 1, &tmp_fds, NULL, NULL, NULL);
for (int fd = 0; fd <= max_fd; fd++) {
if (!FD_ISSET(fd, &tmp_fds)) continue; // 必须遍历检查
// 处理就绪的 fd ...
}
排雷实验 :在代码循环中,如果不使用 tmp_fds 备份,而是直接把 &read_fds 传进去,程序运行第二次时 select 将失效。这是因为 select 会修改传入的位图,把没就绪的 fd 直接清零。这种"输入输出参数混用"的设计极其反人类。
四、 为什么说 select 已经被淘汰?
当连接数达到几千上万时,每次 select 都要进行"两次用户态与内核态的拷贝",并且要遍历所有注册的 fd。如果只有 1 个客户端发了消息,却要遍历 10000 个连接,这种 O(n) 的复杂度直接拖垮了性能。这就引出了后续更高级的多路复用技术:poll 和 epoll。
自我实战操作
咱们来写一个最简单的 select 服务器骨架,为了测试方便,我们把"标准输入(键盘)"和"服务器监听"加在一起。
bash
cd ~/linux_study/network_test
实战:极简 select 服务器
bash
vim select_server.c
(输入以下代码,注意重点看 fd_set 的重置和遍历)
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h>
#define PORT 9999
#define BUF_SIZE 1024
int main() {
// 1. 创建监听 socket
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
server_addr.sin_addr.s_addr = INADDR_ANY;
bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
listen(listen_fd, 5);
printf("Select 服务器启动,监听端口 %d...\n", PORT);
// 2. 初始化 select 的位图
fd_set read_fds;
FD_ZERO(&read_fds); // 清零
FD_SET(0, &read_fds); // 加入标准输入(键盘)
FD_SET(listen_fd, &read_fds);// 加入监听 socket
int max_fd = listen_fd; // 记录最大的 fd
while (1) {
fd_set tmp_fds = read_fds; // 🌟 极其重要:每次都要用临时变量!因为内核会修改它
// 3. 调用 select 阻塞等待
printf("等待数据中...\n");
int ret = select(max_fd + 1, &tmp_fds, NULL, NULL, NULL);
if (ret < 0) { perror("select"); break; }
// 4. 遍历判断是谁就绪了(从 0 循环到 max_fd)
for (int fd = 0; fd <= max_fd; fd++) {
if (!FD_ISSET(fd, &tmp_fds)) continue; // 没就绪就跳过
if (fd == listen_fd) {
// 场景A:有新连接来了
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &len);
printf("新连接接入, fd = %d\n", conn_fd);
FD_SET(conn_fd, &read_fds); // 把新 fd 加入集合
if (conn_fd > max_fd) max_fd = conn_fd; // 更新最大 fd
}
else if (fd == 0) {
// 场景B:键盘有输入
char buf[BUF_SIZE];
fgets(buf, sizeof(buf), stdin);
printf("键盘输入: %s", buf);
}
else {
// 场景C:普通客户端发来了数据
char buf[BUF_SIZE];
int n = read(fd, buf, BUF_SIZE - 1);
if (n > 0) {
buf[n] = '\0';
printf("收到客户端(fd=%d)消息: %s\n", fd, buf);
write(fd, buf, n); // Echo 回显
} else if (n == 0) {
printf("客户端(fd=%d)断开\n", fd);
FD_CLR(fd, &read_fds); // 从集合中移除
close(fd);
}
}
}
}
close(listen_fd);
return 0;
}
编译运行:
cpp
gcc select_server.c -o select_server
./select_server


👉 现象:
-
你在终端输入文字按回车,它能打印出来。
-
你开另一个终端跑
telnet 127.0.0.1 9999连上来发消息,它也能回显。 -
排雷 :如果你把代码里的
fd_set tmp_fds = read_fds;改成直接传&read_fds给 select,第二次调用它就会出 Bug,因为read_fds已经被内核清空了。