77. select 模型:如何实现多路复用?
多路复用,说白了就是让一个线程同时盯着多个文件描述符。哪个有动静了,就去处理哪个。我刚开始做网络编程时,总觉得这玩意儿很玄乎,后来自己手写了一遍 select 模型,才明白它其实就是个「轮询 + 通知」的机制。
为什么需要多路复用?
你想想看,如果让你同时等 10 个快递员的电话,你会怎么做?
- 方案一:轮流给每个快递员打电话问「到了没」——这叫轮询,CPU 空转,效率极低。
- 方案二:给每个快递员配一部专用手机,响了就接——这叫多线程/多进程,资源开销大。
- 方案三:坐在电话旁,等任何一个电话打进来——这就是 select 模型的核心思想。
我在项目中遇到过这样一个场景:一个嵌入式网关需要同时处理 8 个串口的数据。如果用多线程,每个串口一个线程,光线程切换就能把 CPU 吃光。用 select 模型,一个线程搞定所有串口,CPU 占用率从 60% 降到了 5%。
select 的核心机制
select 模型本质上是一个阻塞调用。你告诉内核:「帮我盯着这组文件描述符,只要其中任何一个可读、可写或发生异常,就通知我。」
它的函数原型长这样:
#include <sys/select.h>
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
参数看着多,其实就三件事:
- nfds:你要监听的最大文件描述符 + 1。注意是「最大+1」,不是总数。我刚开始老写错,导致 select 永远不返回。
- readfds/writefds/exceptfds:三个位图,分别表示「关注可读」「关注可写」「关注异常」。每个 bit 对应一个文件描述符。
- timeout:超时时间。传 NULL 表示一直等,传 0 表示立即返回(轮询模式)。
关键点:select 返回后,fd_set 会被内核修改。只有那些「就绪」的描述符对应的 bit 会被保留,其他的会被清零。所以每次调用 select 前,必须重新设置 fd_set。
操作 fd_set 的四个宏
fd_set 本质上是一个位图,但你不能直接操作位。内核提供了四个宏:
| 宏 | 作用 |
|---|---|
| FD_ZERO(fd_set *set) | 清空集合,把所有 bit 置 0 |
| FD_SET(int fd, fd_set *set) | 把 fd 对应的 bit 置 1,加入监听 |
| FD_CLR(int fd, fd_set *set) | 把 fd 对应的 bit 置 0,移除监听 |
| FD_ISSET(int fd, fd_set *set) | 检查 fd 是否在集合中(是否就绪) |
我的习惯:每次调用 select 前,都重新用 FD_ZERO + FD_SET 构建集合。不要试图复用上次的 fd_set,因为内核已经把它改得面目全非了。
一个完整的 select 示例
下面这个例子,监听标准输入(fd=0)和一个 UDP socket。哪个先来数据就处理哪个:
#include <stdio.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
int main() {
int sock = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sock, (struct sockaddr*)&addr, sizeof(addr));
fd_set readfds;
char buf[1024];
while (1) {
FD_ZERO(&readfds);
FD_SET(STDIN_FILENO, &readfds); // 监听标准输入
FD_SET(sock, &readfds); // 监听 socket
int maxfd = (sock > STDIN_FILENO) ? sock : STDIN_FILENO;
// 阻塞等待,直到有数据到达
int ret = select(maxfd + 1, &readfds, NULL, NULL, NULL);
if (ret < 0) {
perror("select");
break;
}
if (FD_ISSET(STDIN_FILENO, &readfds)) {
// 键盘有输入
fgets(buf, sizeof(buf), stdin);
printf("键盘输入: %s", buf);
}
if (FD_ISSET(sock, &readfds)) {
// socket 有数据
struct sockaddr_in from;
socklen_t fromlen = sizeof(from);
int n = recvfrom(sock, buf, sizeof(buf), 0,
(struct sockaddr*)&from, &fromlen);
if (n > 0) {
buf[n] = '\0';
printf("收到 UDP 数据: %s\n", buf);
}
}
}
close(sock);
return 0;
}
这段代码我写过很多次。注意看 while 循环里的结构:每次循环都重新设置 fd_set,然后 select 阻塞等待。一旦返回,用 FD_ISSET 挨个检查哪些描述符就绪了。
select 的局限性
嗯,这里要泼点冷水。select 虽然好用,但有几个硬伤:
- 文件描述符数量有限:FD_SETSIZE 通常为 1024。你没法同时监听 2000 个 socket。我在一个高并发项目里就踩过这个坑,后来不得不换成 epoll。
- 每次都要重新设置 fd_set:select 会修改集合,所以每次调用前都得重新 FD_SET。如果监听 1000 个 fd,每次都要循环 1000 次设置位图,开销不小。
- 返回后需要遍历所有 fd:select 只告诉你「有几个就绪了」,但不告诉你是哪几个。你得从 0 到 maxfd 挨个 FD_ISSET。如果 1000 个 fd 里只有 1 个就绪,你也得遍历 1000 次。
- 位图大小固定:fd_set 是编译时确定的,没法动态扩展。如果你在 64 位系统上编译,FD_SETSIZE 可能还是 1024。
我曾经在一个嵌入式项目里,用 select 监听 800 多个 socket。结果发现每次 select 返回后,遍历所有 fd 的时间比处理数据的时间还长。后来改成了 epoll,性能直接翻倍。
select 的适用场景
虽然 select 有局限,但它并不是一无是处。我个人觉得以下场景用 select 很合适:
- 嵌入式系统:文件描述符数量少(几十个以内),select 足够用,而且代码简单、可移植性好。
- 跨平台需求:select 在 Linux、Windows、macOS 上都支持,epoll 是 Linux 独有的。
- 原型开发:先拿 select 快速验证逻辑,后期再优化成 epoll。
- 超时控制:select 的 timeout 参数可以精确到微秒级,做超时检测很方便。
select 的工作流程
下面这张图,帮你理清 select 的完整调用流程:
避坑指南
最后,分享几个我踩过的坑:
- nfds 参数写错:记住是「最大 fd + 1」,不是「fd 总数」。我见过有人传 1024,结果只监听了 3 个 fd,select 白白扫描了 1024 个位。
- 忘记重新设置 fd_set:select 返回后,fd_set 已经被内核改了。如果你不重新 FD_SET,下次 select 可能只监听 1 个 fd。
- timeout 被修改:Linux 上 select 会修改 timeout 结构体,记录剩余时间。如果你要重复使用同一个 timeout 变量,每次都得重新赋值。
- 信号中断:select 被信号中断时会返回 -1,errno 设为 EINTR。这时候应该重新调用 select,而不是退出循环。
我的建议:如果你刚开始学网络编程,先用 select 把多路复用的逻辑跑通。等理解了「阻塞 + 就绪通知」这个核心思想,再去看 epoll 和 kqueue 会轻松很多。select 虽然老,但它是理解所有 I/O 多路复用模型的基础。
公众号:蓝海资料掘金营,微信deep3321