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 的完整调用流程:

select 模型工作流程 1. 构建 fd_set FD_ZERO + FD_SET 2. 调用 select() 阻塞等待就绪 3. 内核检查就绪 修改 fd_set 位图 返回就绪数量 4. 遍历所有 fd FD_ISSET 检查就绪 5. 处理就绪 fd read / write / accept 6. 回到步骤 1 重新构建 fd_set 循环 关键说明: • 每次循环都必须重新 FD_ZERO + FD_SET,因为 select 会修改 fd_set • select 返回后,只有就绪的 fd 在集合中,未就绪的已被清除 • 遍历时从 0 到 maxfd,用 FD_ISSET 判断每个 fd 是否就绪

避坑指南

最后,分享几个我踩过的坑:

  • 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