先设想一个很日常的困境。你是个服务生,负责很多张桌子的客人点菜。最笨的办法是:死盯着某一桌,等这桌客人叫你,结完这桌再去下一桌。可问题是你盯第一桌的时候,第二桌第三桌的客人可能早就饿晕了。换成计算机术语,就是"一个进程正在某个 socket 上的 read() 阻塞等待数据,这时候如果又来了一个新连接、或者另一个客户端发来数据,进程根本顾不上"。

那有没有办法让"一个进程同时伺候很多个 socket"?有,这就是 I/O 多路转接(也叫 I/O 多路复用,multiplexing)的思想,而 select 就是系统提供给我们的第一种、也是最经典的一种实现工具。今天这一篇,我们就把它彻底掰开揉碎。

你应该有的知识准备

在往下读之前,请确保你对下面这些概念不陌生,它们大多在你之前的网络编程课里出现过:

  • 文件描述符 fd:linux 中"打开的一个文件/套接字"对应的一个整数编号,0、1、2 分别是标准输入、标准输出、标准错误。
  • 阻塞与非阻塞:默认创建的 socket 是阻塞的。阻塞的含义是:当你 read 一个还没有数据的 socket 时,进程会"卡"在那里一直等,直到有数据才返回。
  • TCP 三次握手、accept、listen:一个 TCP 服务器从 socket 创建、bind 绑定、listen 监听、accept 接受客户端连接的完整流程。
  • 字节流:TCP 面向字节流,read/write 读写的是一串连续字节。

如果你忘了其中某条,建议先回去翻复习篇再来,否则下面这一路会走得比较吃力。准备好了,我们就从"select 到底解决了什么"开始。

为什么需要多路转接

我们先说清楚"阻塞服务器"的死穴。懒人写法是这样的:服务器 accept 一个客户端,就 read 这个客户端,等它发数据、处理完、再回去 accept。代码大约长这样(伪代码,只解释思想,不追求编译):

// 伪代码:单线程的"死盯一桌"服务器
while (1) {
    int fd = accept(listen_fd, ...);   // 等一个新连接
    read(fd, buf, sizeof(buf));        // 死等这个客户端发数据
    // 处理 buf...
}

你看,accept 本身就会阻塞(没有新连接就一直等),read 也会阻塞(客户端不发数据就一直等)。于是这段代码的每一次"等待"都只盯着一个 fd——要么在等 accept,要么在等 read,永远只伺候一个人。第二个客户端哪怕已经连上了,也只能干瞪眼排在队列里。

这暴露了阻塞式 I/O 的一个根本矛盾:一个进程一次只能在一个地方阻塞等待,而我们需要同时等待很多个地方。

那有人会想:开很多线程/进程,一人管一个客户端不就行了吗?行,但是每来一个客户端就 fork/开线程,进程/线程数量巨大时,光是创建、调度、上下文切换的开销就大得吓人;而且进程间共享内存还要加锁,代码复杂度陡增。

于是系统提供了另一个思路:把"谁来管哪一个 fd"这个问题,交给内核去侦察,进程只负责"在原地等内核汇报"。你要监视哪些 fd、看好不好读/好不好写,一次性告诉内核;然后进程就舒舒服服地去睡大觉(阻塞等待);等内核发现"其中有 fd 就绪了",再把你唤醒,告诉你"里面有货,你自己去取"。这就是多路转接。

所谓 多路转接,就是指"一个进程同时监视(转接/转接监视)多个文件描述符,哪一路先就绪就处理哪一路"。这里的"路"就是一条条连接、一个个 fd。而 select,就是内核提供给我们做这件事的第一个系统调用。

先记住三个关键字

往下讲之前,先把三个屡见不鲜的术语钉死:

  • fd_set:文件描述符"集合"的类型。它既像一个位图(bitmap),又是一组操作接口的载体。后面会详细拆。
  • 就绪:指某个 fd "现在可以去做某种操作且不会被阻塞"。比如"读就绪"就是"现在 read 它一定能读到东西(返回大于 0)或读到连接结束(返回 0)或收到新的连接请求(监听 socket)"。
  • 超时:如果你给 select 指定了一个等待时间,时间到了还没有任何 fd 就绪,select 就"超时返回",不再傻等。

这三个词会贯穿全篇,先混个脸熟。接下来我们看 select 长什么样。

select 的函数原型与参数

select 声明在头文件 <sys/select.h> 里,原型如下:

// 声明来自 <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

一共有 5 个参数,第 1 个是"监视范围",中间 3 个是三种不同的"事件集合",最后 1 个是"等待多久"。逐个拆清楚:

参数一:nfds——监视哪个范围的 fd

nfds 是你想监视的 最大的文件描述符编号 + 1。它不是"要监视几个 fd",而是"你关心的 fd 最大到几号,从 0 数到几"。举个例子:你监视了 fd 3 和 fd 7,那最大的是 7,所以 nfds = 7 + 1 = 8,表示"把 0 到 7 这 8 个编号都交给内核去排查要不要看"。

为什么是 "+1" 而不是直接传最大 fd 7?因为内核是以"0 到 nfds-1"这样的左闭右开区间来遍历的——nfds 传什么,内核就检查 0,1,...,nfds-1 这些编号。传 7,就只查 0 到 6,把你关心的 7 漏掉了。所以口诀就一句:nfds = max_fd + 1。

在默认的 Linux glibc 里,fd 的上限(FD_SETSIZE,含义后面讲)一般是 1024,也就是说 fd 的取值范围是 0 ~ 1023,所以你传的 nfds 最大不超过 1024。

参数二三四:readfds / writefds / exceptfds——三种事件

  • readfds:读文件描述符集合。你想"监视哪些 fd 可读",就把这些 fd 放进这个集合。
  • writefds:写文件描述符集合。你想"监视哪些 fd 可写",就放这里。
  • exceptfds:异常文件描述符集合。你想"监视哪些 fd 发生异常"(主要是收到带外数据),就放这里。

不需要监视哪一种,就传 NULL。绝大多数场景我们只关心"可读",所以后面例子都是 readfds 给集合、writefds 和 exceptfds 传 NULL。

参数五:timeout——最多等多久

timeout 是个指向 struct timeval(一个描述时间段的结构体)的指针,用来告诉 select "最多等我多久"。它的取值分三种,从"死等"到"立刻走":

#include <sys/select.h>
#include <sys/time.h>        // struct timeval 的定义在这
 
// struct timeval 长这样(表示"一段时间长短")
struct timeval {
    long tv_sec;     // 秒
    long tv_usec;    //  微秒(1 秒 = 1000000 微秒)
};
 
// 用法一:让 select 永久阻塞,直到有 fd 就绪才返回
int r1 = select(nfds, &readfds, NULL, NULL, NULL);
//                       ↑ 最后一栏传 NULL,含义是"没有时间限制,一直等"
 
// 用法二:只检测当前是否就绪,无论好坏立即返回(相当于"轮询一次")
struct timeval t0 = { 0, 0 };        // 0 秒 0 微秒
int r2 = select(nfds, &readfds, NULL, NULL, &t0);
 
// 用法三:最多等待 3 秒,3 秒内没就绪就超时返回
struct timeval t3 = { 3, 0 };        // 3 秒
int r3 = select(nfds, &readfds, NULL, NULL, &t3);

三个取值的区别一句话总结:

  • timeout == NULL:没有超时,select 一直阻塞,直到某个 fd 发生事件才返回;永不见效的等待会一直等下去。
  • timeout 指向 0:立即返回。select 只"看一眼"现在有没有就绪的,然后就回来,不做任何等待。这常用于做非阻塞的轮询。
  • timeout 指向一个具体时间:若在该时间段内没有任何 fd 就绪,select 超时返回,返回值是 0。

注意一个坑:select 返回时,Linux 会就地修改你传入的 timeval,把它写成"剩余还差多少时间"(比如你让等 3 秒,第 1 秒就绪返回,它会把 tv_sec 改成 2)。所以如果你想让每次等待"都是满 3 秒",循环里就得每次重新赋一遍 timeval,而不是复用上一个被改过的。

好了,接口这些"外壳"我们认识了,接下来要去挖最核心的东西——fd_set 这个位图到底是什么。

fd_set:一个用位来表达的"监控名单"

我已经反复提到"集合"二字,但 fd_set 到底是块什么内存?直接把话说透:fd_set 本质上就是一个整数数组,更严格地讲,它是一张"位图"(bitmap)——用每一位(bit)来表示一个文件描述符是否被"盯上"。

想象有很长很长一排开关(位),编号从 0 开始。编号 k 的那个开关代表 fd=k。开关"开"(位为 1)表示"我要监视 fd=k",开关"关"(位为 0)表示"我不关心 fd=k"。这样一个 fd_set 就能同时装下 sizeof(fd_set) * 8 个 fd(每位对应一个)。这就是前文说的"上限"的计算来源。

举一个能看见的数。为了画图方便,我们假设 fd_set 只有 1 个字节(8 位,每位对应 fd 0~7)。那么这一套操作就是:

fd_set set;                       // 假设它只有 8 bit,代表 fd 0~7
FD_ZERO(&set);                    // 全部清零 -> 每一位都是 0:  0000 0000
FD_SET(5, &set);                  // 把"fd=5"这一位置 1  ->  0010 0000
FD_SET(2, &set); FD_SET(1, &set); // 再把 fd=2、fd=1 置 1 ->  0010 0110
// 现在 set 代表:"我想监视 fd 1、2、5" 这三路
int nfds = 5 + 1;                 // 最大 fd 是 5,所以 nfds = 6
select(nfds, &set, 0, 0, 0);      // 阻塞等待:监视 fd 0~5 谁可读

第 5 位代表 fd=5(我们把一位这样摆放:左边是第 7 位,右边是第 0 位):

  • FD_ZERO 后:0000 0000,谁都没被监视。
  • FD_SET(5) 后:0010 0000,第 5 位是 1。
  • 再 FD_SET(2)、FD_SET(1) 后:0010 0110,第 5、2、1 位都是 1。

我们把它放进 select 里等。若干时间后 select 返回,我们说"fd 1 和 fd 2 上都发生了可读事件"。注意这时候 set 变了——内核只在里面保留了"就绪了"的位,其余的全部清空。于是 set 变成了 0000 0110(只剩下第 1、2 位是 1),而那个"没有事件发生的 fd=5",被清空了。

这句话是整个 select 模型里最要命的细节,我把后面会反复强调的一句话先立在这:select 返回时,会就地修改你传进去的 fd_set,把它改写成"只包含就绪 fd"的新位图。这既是我们判断"哪些 fd 就绪了"的依据,也是我们必须"每轮重建集合"的原因。

操作 fd_set 的四个宏

fd_set 你自己直接拿位运算去操作比较痛苦,系统贴心地提供了一组接口(其实都是宏):

#include <sys/select.h>
 
void FD_CLR(int fd, fd_set *set);   // 把 set 中代表 fd 的那一位"清掉"(置 0)
int  FD_ISSET(int fd, fd_set *set); // 测试 fd 在 set 里那一位是否为 1(就绪/被监视),是则返回非 0
void FD_SET(int fd, fd_set *set);   // 把 set 中代表 fd 的那一位"点亮"(置 1)
void FD_ZERO(fd_set *set);          // 把 set 的每一位都清 0(清空整个集合)

逐个记:

  • FD_ZERO:格式化磁盘前先分区清零一样,每次组装新集合前第一件事就是 FD_ZERO,否则里面残留上次的"脏位",会莫名监视一堆不该监视的 fd。
  • FD_SET:把某个 fd 加入监控名单。
  • FD_CLR:把某个 fd 移出监控名单。
  • FD_ISSET:select 返回后,用它逐个查验"这个 fd 是不是就绪了"。它返回非 0 表示"这一位为 1",也就是"就绪了"。

一个小提醒:FD_SET 是系统提供的宏,不是函数,所以它全大写;它跟"把一个 fd 加入监视"是同一个意思,别跟数学里的"集合 set"或者后面要讲的 FD_ISSET 搞混了。我们常说的"select 会改 fd_set",主要就是指这个宏们操作的那张位图会被内核改写。

一个典型的、教科书级别的使用片段是这样(这也是后面所有服务器的心脏):

fd_set readset;                    // 一个读集合
FD_ZERO(&readset);                 // 先清空
FD_SET(fd, &readset);              // 把要监视的 fd 放进去
select(fd + 1, &readset, NULL, NULL, NULL); // nfds = fd + 1,阻塞等它可读
if (FD_ISSET(fd, &readset)) {      // select 返回后,这一个if判断"fd 就绪了吗"
    // 是的,fd 可读了,放心 read
}

注意看 select(fd + 1, ...) ——台上台下都验证了那句 nfds = max_fd + 1。

函数返回值与错误处理

选完型、喂完参,select 到底给你回什么?返回值有三种含义,分清楚:

返回值含义
> 0就绪的 fd 个数(这些 fd 中具体是哪些,看 FD_ISSET 逐个验)
0在超时时间内没有任何 fd 就绪(只有你给了非 0 的 timeout 才会出现)
-1出错,错误码保存在 errno 里

重要:当返回 -1 出错时,readfds、writefds、exceptfds 和 timeout 的内容都会"变得不可预测"(手册原话),也就是说 don't rely on them——不要再拿这套已经乱掉的集合去判断什么。

常见出错码(errno)有这么几个,越靠前越常踩:

  • EBADF:集合里有无效的 fd,比如某个 fd 已经被 close 了,却还留在集合里。这提醒我们:fd 一旦关闭,必须先从 fd_set/监控数组里把它摘掉,否则下一轮 select 就会带着"死 fd"进去直接失败。
  • EINTR:select 被某个信号打断了(比如终端收到 SIGINT、或者很常见的 SIGCHLD 之类)。处理方式是"重试",很多健壮代码会这样写:while (select(...) == -1 && errno == EINTR) ;。
  • EINVAL:参数非法,比如 nfds 是负数、或者 nfds 超过 FD_SETSIZE 等。
  • ENOMEM:内核内存不足,很少见。

下面这段小程序,把"检测标准输入"用 select 完整实现了一遍,也把返回值处理、每轮重建集合的规范动作都带上了。它虽然简单,却是理解全部后续代码的钥匙:

// select_stdin.c —— 用 select 检测标准输入(文件描述符 0)是否可读
// 它只监视 0 号 fd,但已经完整演示了 select 的完整套路
// 编译:gcc select_stdin.c -o stdin_demo
// 运行:./stdin_demo,然后随意敲几行字;不输入时程序会一直等你
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
 
int main(void)
{
    for (;;) {                     // 无限循环:反复"组装集合 -> select -> 处理"
        fd_set read_fds;           // 本轮的读文件描述符集合
        FD_ZERO(&read_fds);        // 每次组装前必先清空位图(关键动作)
        FD_SET(0, &read_fds);      // 把 fd=0(标准输入)对应的位置 1,即"监视它"
 
        printf("> ");              // 打印一个提示符
        fflush(stdout);            // 刷新输出缓冲,让提示符立刻显示出来
 
        // 监视范围是 0 号 fd,所以 nfds = 0 + 1 = 1;永久阻塞(timeout=NULL)
        int ret = select(1, &read_fds, NULL, NULL, NULL);
        if (ret < 0) { perror("select"); continue; }   // 出错,重来
        if (ret == 0) { continue; }                    // 超时返回(此处不会发生),防御性写上
 
        if (FD_ISSET(0, &read_fds)) {                  // select 返回后逐个查验
            char buf[1024] = { 0 };                    // 留一个位置放'\0'
            ssize_t r = read(0, buf, sizeof(buf) - 1); // 读标准输入
            if (r > 0) {
                printf("input: %s", buf);              // 原样回显
            }
        }
    }
    return 0;
}

为什么这里只监视 0 号 fd,还要在循环里反复 FD_ZERO + FD_SET?因为select 返回后动过 read_fds——它把没就绪的位清了、把就绪的位留着。你如果偷懒不复位,第二轮 select 拿到的就是一张"被污染"的集合:要么漏掉本想监视的 fd(因为上一次没就绪就被清了),要么误当成就绪。所以标准动作永远是"每轮先 FD_ZERO 再逐个 FD_SET"。

你可以把上面这段自己敲出来跑一跑:不输入时它挂在 > 那里等你(select 阻塞),一输入就回显 input: ...。

socket 的就绪条件(读/写/异常)

select 说"这个 fd 就绪了",到底是"什么情况算就绪"?这不能凭感觉,内核对 socket 有一套精确的定义。我们把读、写、异常三块分别说清楚,因为只有理解了就绪条件,你才知道"select 返回可读后,究竟该做什么、会不会卡住"。

读就绪

一个 socket 满足下面任一条件,select 就认为它"可读"(放在 readfds 里会返回):

  1. 接收缓冲区里的数据 >= 低水位标记SO_RCVLOWAT。此时你应该可以直接 read 而不被阻塞,且返回值大于 0。所谓低水位,可以粗略理解为"内核认为'至少攒了多少字节才值得让你来读'的一个阈值",默认是 1(即只要有 1 字节就可读)。你 read 返回的就是这些字节。
  2. TCP 对端关闭连接(close 或 shutdown)。此时你去 read 它,返回 0,表示"读到 EOF,连接结束了"。
  3. 监听 socket(listen_fd)上有新的连接请求。也就是存在一个已完成的握手、等待 accept 的挂起连接。对监听 socket 来说,"可读"约等于"有客户端连进来了,可以 accept 了"。
  4. socket 上有尚未处理的错误。此时需要去读 socket 的错误状态(比如用 SO_ERROR),而不是傻乎乎地去 read。

这里有个容易踩的误区:"可读"并不永远等于"有业务数据"。比如对端断开导致 read 返回 0,这也是"可读就绪";监听 socket 的可读意味着"可以 accept"。所以处理"读就绪"的代码,一定要同时想清楚"读返回 0 或 -1 怎么办"(通常是关闭该 fd)。

写就绪

同理,满足任一条件就算是"可写"(会出现在 writefds 里):

  1. 发送缓冲区里的可用空间 >= 低水位标记 SO_SNDLOWAT。此时 write 不会阻塞,且返回值大于 0。
  2. socket 的写方向已经被关闭(本地对端 shutdown(SHUT_WR) 或被关闭)。特别警告:对"写方向已关闭"的 socket 再写,会触发 SIGPIPE 信号,默认行为是直接终止进程——这是服务端半夜挂掉的一大元凶,不处理就得先 signal(SIGPIPE, SIG_IGN) 或检查返回值。
  3. 用非阻塞 connect 连接,结果成功或失败了。connect 是异步完成的,select 检测到"可写"往往意味着连接建立好了(或失败了,要用 SO_ERROR 去查)。
  4. socket 上有未读取的错误。

异常就绪(选学)

放在 exceptfds 里被 select 监控的异常,最常见的就一个:socket 收到了带外数据(Out-Of-Band,OOB)。它和 TCP 的紧急模式有关——回忆 TCP 报文头里有一个"紧急指针"字段,就是干这个的。带外数据用于传输"需要优先处理"的少数字节,属于进阶话题,感兴趣的同学课后自行查阅资料,我们这一篇先不展开。


到这里,"select 是什么、怎么用、就绪了意味着什么"都讲明白了。趁热打铁,我们把它真正组装成一个能跑的多客户端服务器——这是本篇最重要的一段代码,你值得亲手敲一遍。

实例:用 select 写一个多客户端回显服务器

我们要做一个"回显服务器":客户端发来什么字节,服务器就原样发回去。核心思想是:

用一个数组 fds[] 记住所有需要监视的 fd(监听 fd + 所有客户端 fd),每轮从数组重新组装一张 fd_set,交给 select,返回后用 FD_ISSET 逐个查验数组里的 fd 谁就绪了。

为什么必须有那个数组而"光靠 fd_set 不够"?因为 select 每次返回都会把 fd_set 改掉(只留就绪的),我们必须留一份"幕后花名册",下轮才能从这份花名册把完整的集合重新搭起来。这个数组既当"源数据"(重建集合),也当"对照表"(遍历查谁就绪)。

对齐一下目标:我们要监视的对象分两种,处理方式也不同——

  • 监听 socket 可读 → 有客户端连入 → accept 拿新 fd → 把新 fd 加进数组。
  • 客户端 socket 可读 → read 收数据 → 原样 write 回显;若读到 0(断开)或出错 → 关闭并移出数组。

下面是完整可编译的 C 实现,每一行都写了注释,请一行行跟下来:

// select_server.c —— 用 select 实现的多客户端"回显服务器"
// 原理:用一个数组保存所有要监视的 fd,每轮重新组装 fd_set 交给 select,
//       select 返回后用 FD_ISSET 逐个查验谁就绪,然后分别处理。
// 编译:gcc select_server.c -o server
// 运行:./server
// 测试:另开一个终端,执行  nc 127.0.0.1 8888 ,连上后输入几行字看效果
#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>
 
#define PORT 8888          // 服务器监听的端口号
 
int main(void)
{
    // ---------- 1. 创建监听套接字 ----------
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) { perror("socket"); exit(1); }   // 失败就退出
 
    // ---------- 2. 绑定地址和端口 ----------
    struct sockaddr_in addr;                 // IPv4 地址结构
    memset(&addr, 0, sizeof(addr));          // 先清零,避免里面残留垃圾
    addr.sin_family = AF_INET;               // 地址族:IPv4
    addr.sin_port = htons(PORT);             // 端口,转成网络字节序
    addr.sin_addr.s_addr = htonl(INADDR_ANY);// 绑定本机任意一个地址
    if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("bind"); exit(1);
    }
 
    // ---------- 3. 开始监听 ----------
    if (listen(listen_fd, 5) < 0) { perror("listen"); exit(1); }
    printf("服务器已启动,监听端口 %d\n", PORT);
    fflush(stdout);
 
    // ---------- 4. 幕后花名册 ----------
    // 用一个足够大的数组, 保存"当前需要监视的所有 fd"。
    // 为什么必须要这份数组?因为 select 返回后会改掉 fd_set(只留就绪的),
    // 我们必须从这里重新组装下一轮的 fd_set。
    int fds[FD_SETSIZE];        // 数组大小沿用 select 的上限,足够装下全部 fd
    int fd_cnt = 0;             // 当前花名册里有效 fd 的个数
    fds[fd_cnt++] = listen_fd;  // 监听 socket 一定是第一个登记的
 
    // ---------- 5. 事件循环 ----------
    for (;;)
    {
        // ---- 5.1 每轮先把"本轮要监视的读集合"重新搭好 ----
        fd_set read_fds;             // 本轮的读集合
        FD_ZERO(&read_fds);          // 先清空位图(铁律!)
        int max_fd = 0;              // 本轮最大 fd,用来算 nfds
        for (int i = 0; i < fd_cnt; ++i) {
            FD_SET(fds[i], &read_fds);           // 把 fds[i] 点亮,表示"监视它"
            if (fds[i] > max_fd) max_fd = fds[i];// 顺带更新最大 fd
        }
 
        // ---- 5.2 阻塞等待,直到有 fd 可读 ----
        int n = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
        if (n < 0) { perror("select"); continue; } // 出错,跳过本轮重新来
        if (n == 0) { continue; }                  // 无超时不会发生,防御性写上
 
        // ---- 5.3 监听 socket 可读?说明有客户端连进来了 ----
        if (FD_ISSET(listen_fd, &read_fds)) {
            struct sockaddr_in peer;               // 用来拿客户端地址,这里不细用
            socklen_t len = sizeof(peer);
            int new_fd = accept(listen_fd, (struct sockaddr*)&peer, &len);
            if (new_fd >= 0) {
                if (fd_cnt >= FD_SETSIZE) {        // 花名册满了就是"连接过多"
                    printf("客户端太多,已达上限 %d,拒绝新连接\n", FD_SETSIZE);
                    close(new_fd);                 // 立刻关掉这个多余连接
                } else {
                    fds[fd_cnt++] = new_fd;        // 把新客户端 fd 加进花名册
                    printf("新客户端连入 fd=%d\n", new_fd);
                }
                fflush(stdout);
            }
        }
 
        // ---- 5.4 逐个检查每个客户端 fd 是否可读 ----
        // 注意:这里从 i=1 开始,因为下标 0 是监听 socket(上面已处理)。
        // 用 while 风格而不是 for(i++) 的原因:删除元素用"最后一个覆盖当前位置",
        // 新挪来的元素还未检查,所以这一轮不要自增 i。
        for (int i = 1; i < fd_cnt; ) {
            int fd = fds[i];
            if (!FD_ISSET(fd, &read_fds)) { ++i; continue; } // 本轮没就绪,跳过
 
            char buf[1024];
            ssize_t r = read(fd, buf, sizeof(buf));
            if (r <= 0) {
                // r == 0: 对端关闭连接;r < 0: 出错。两种情况都该清掉这个 fd
                printf("客户端 fd=%d 断开\n", fd);
                close(fd);              // 关闭 fd,并(重点)移出监视范围
                fds[i] = fds[fd_cnt - 1]; // 把最后一个 fd 覆盖到被删位置
                fd_cnt--;                 // 有效个数减一(被删的元素"补位"结束)
                continue;                 // 新补上来的元素还没查,别自增 i
            }
            // 读到了数据,原样回显给客户端
            write(fd, buf, (size_t)r);
            ++i;
        }
    }
    return 0;
}

这段代码把 select 的所有关键要点都用上了

对照着看,你会发现刚才的每一句话都落进了代码:

  1. 数组 fds[] 是"源数据"和"对照表"。5.1 节用它重建集合、算 max_fd;5.4 节又用它遍历,配合 FD_ISSET 判断谁就绪。这正是课件里说的"要把 fd 加入 select 监控集的同时,再另用一个 array 保存它"。
  2. 每轮都 FD_ZERO 再逐个 FD_SET:因为 select 会改 read_fds,绝不复用它。
  3. nfds = max_fd + 1:5.1 里实时算出最新的最大 fd。
  4. 关闭的 fd 一定要从花名册摘掉:否则下轮 FD_SET 一个已关闭的 fd,select 会直接以 EBADF 失败,整个服务器就"哑"了。这就是 EBADF 那个坑在真实代码里的样子。
  5. 上限保护:5.3 里 fd_cnt >= FD_SETSIZE 就拒绝新连接,防止越界写数组。

你可以编译起来真刀真枪地试:

# 编译
gcc select_server.c -o server
 
# 终端A:启动服务器
./server
 
# 终端B/C/D:开多个终端,同时执行下面命令,多路连接
nc 127.0.0.1 8888

你会发现:开多个 nc 连接同时发消息,服务器都能一一回显、互不阻塞——这就是多路转接真正"多路同时伺候"的威力。你再观察服务器控制台,能清楚看到每个 fd 的连入和断开。

select 的三个致命缺点

用起来挺爽对吧?但要清醒——select 这几十年来被无数人吐槽,毛病相当硬核。总结起来三大缺点,每一个都源于它"古老的位图 + 每轮全量拷贝"的设计。这三个缺点,也是后来 poll、epoll 诞生并被广泛替代的根因。

缺点一:监视上限太小(FD_SETSIZE)

能监视多少个 fd,取决于 sizeof(fd_set)——fd_set 一共多少 bit,最多就能放多少个 fd。默认情况下,Linux glibc 把 FD_SETSIZE 定为 1024,也就是说一个进程用 select 最多只能同时监视 fd 0 ~ 1023,也就最多同时服务 1023 个客户端连接(还要扣掉监听 fd、标准输入等占位)。

有人会说 1024 不小了吧?对于一个真正的高并发服务器,动辄上万连接,1024 简直是杯水车薪。而且这个上限是在编译期定死的:想提高它,得先改内核相关配置/源码并重新编译内核,还得让每个用到 select 的程序都按新的 FD_SETSIZE 重编译,那是极其别扭、基本不可行的邪路。所以现实中 select 的 1024 上限基本就是天花板。

缺点二:select 会"毁掉"你传进去的 fd_set,每轮都得重置

这是接口设计上的"心累"。select 返回后,你辛辛苦苦搭好的 readfds 被内核改得只剩"就绪的位"——没就绪的全清零了。于是每轮循环你都要:

  1. 从备份数组里把每个 fd 重新 FD_SET 回去;
  2. 重新遍历数组求一遍 max_fd。

从接口使用的角度来说,这非常不便——它不给你"增量更新"的能力,你必须次次"全量重来"。就算只有一个 fd 的状态变了,你也得把整套集合重新搭一遍。这个缺陷在 epoll 里被彻底解决(epoll 用"事件表"长期挂在内核里,增删是增量的,不需要每轮重建)。

缺点三:每轮全量拷贝 + 内核线性扫描,O(n) 开销

select 的性能开销有两笔,都随 fd 数量线性增长:

  • 用户态到内核态的拷贝:每次调用 select,都要把整个 fd_set(可能是 128 字节甚至更大)从用户态拷进内核态。fd 一多,光是拷贝就耗电。
  • 内核里的线性扫描:内核拿到集合后,得把 0 到 nfds-1 一个一个检查过去,暴力遍历每个可能的 fd,看它有没有就绪事件。这是 O(n) 的操作——你的连接越多,每轮 select 越慢。哪怕其实只有 1 个 fd 就绪,内核也要"从头扫到尾"。

两份均为 O(n) 的开销叠加,让 select 在"成千上万并发"的场景下沦为性能瓶颈。而 epoll 之所以被划时代地推崇,正是因为它在内核里维护了一张就绪事件表,只需 O(就绪数) 汇报,不用 O(总 fd 数) 扫描。

小结一张表

缺点表现根源
上限小最多监视 FD_SETSIZE(默认 1024)个 fd编译期定死的位图大小
每轮重置select 就地改 fd_set,必须备份数组并全量重建返回时已摧毁输入集合
O(n) 开销内核每轮把 0~nfds-1 全扫一遍 + 全量拷贝线性遍历 + 用户态/内核态拷贝

别忘了的技术细节(坑点合集)

除了三大缺点,下面几个边角细节在真实开发里也频频咬人,一次性给你排雷。

  1. select 会对 timeout 就地更新:Linux 会在返回时把 timeval 改成"剩余时间"。想每次都等满固定时长,循环里要重新赋值。

  2. EBADF:千万别把已关闭的 fd 留在集合里:fd 一旦 close,本轮的 FD_SET 和下轮的 select 就会埋雷。收尾动作永远是"先从这个 fd 对应的连接里 read 判断,再 close,再把它从你的花名册里摘掉"。

  3. EINTR:被信号打断不是世界末日:select 是"慢系统调用",可能被信号信号打断返回 -1。按约定调用失败后要 errno == EINTR 时重试,它的进程才能活得够糙够稳。

  4. 监听 socket 本身也要放进监视范围:很多新手只监视客户端、忘了把 listen_fd 一起放进 fd_set,结果服务器"永远不接新连接"。回显服务器里 fds[fd_cnt++] = listen_fd、以及 5.3 节的 FD_ISSET(listen_fd, &read_fds),就是在处理这件事。

  5. 一个进程里 fd 的价值与编号是随时变的:每次 accept 拿到的 new_fd 不一定是连续编号,所以求 max_fd 必须每轮现算,绝不能想当然地写死。这就是 nfds = max_fd + 1 里的 max_fd 要动态维护的原因。

  6. 换个视角看"select 会改 fd_set":它改完的集合其实只告诉你"就绪的那几个",你把 FD_ISSET 依次拷问集合里的每一位,就能拿到就绪名单。但正因为如此,你才一定要保留一份原始花名册,否则名单无从重建——这是 select 架构里"用住址簿管理、每轮重发"的核心悖论。

思考题(附详解)

下面几道题覆盖了本篇最容易出错、也是最关键的几个点。请先自己动手想/写,再对照后面的详解。

思考题 1:为什么必须在每一轮 select 之前都 FD_ZERO 再重新 FD_SET,不能复用上一层 select 返回后的 read_fds?

详解:因为 select 的语义就是"返回时修改入参集合"。它会把 read_fds 就地改写成"只有就绪 fd 还保留着位、其余全部清零"的新位图。假如你会复用上一轮返回后的 read_fds:

  • 上一轮没就绪的 fd,位已经被清零,这一轮你就"失去"了对它的监视——等于漏报;
  • 上一轮就绪的 fd,位还留着,这一轮它明明可能已经没数据可读了,你却仍然监视它——等于加塞/误报。

所以无论上轮发生了啥,本轮都必须从你的备份数组(花名册)重新构建一张干净的 fd_set。这是 select 编程的头等铁律。

思考题 2:为什么 nfds 要传"最大 fd + 1"而不是最大 fd?传太小会出什么问题?

详解:内核检查 fd 用的是"左闭右开"区间——传 nfds,它就检查 0, 1, ..., nfds-1 这些编号。如果你监视的最大 fd 是 7,正确的 nfds 是 8,这样才能把编号 7 包含进去。你要是抠门一点传了 7,内核只检查 0~6,把 7 漏了,那么这个 fd 上发生的事件就永远等不到 select 汇报——它像个透明人,有数据也不告诉你。传成比正确值更小的数也一样会丢 fd;反过来传大了不会丢,但会让内核多扫一堆不关心的编号,白费 O(n) 时间。

思考题 3:select 能监视的最大 fd 数量由什么决定?如何解释"能监视的 fd 数 = sizeof(fd_set) × 8"?

详解:由 FD_SETSIZE 决定,而 FD_SETSIZE 本质上是"fd_set 这个位图一共有多少 bit"。因为 fd_set 的每一位对应一个 fd,所以它能放下多少个 fd,就等于它有多少个位。sizeof(fd_set) 给出它占几个字节,一个字节 8 个位,所以最多 sizeof(fd_set) * 8 个 fd。默认 glibc 里 FD_SETSIZE 是 1024,对应 sizeof(fd_set) == 128 字节(128 × 8 = 1024),能监视 fd 0~1023。不同系统/不同 libc 的 sizeof(fd_set) 可能不同(有的环境是 512 字节 → 4096 位),但同一套"位图,位即 fd、上限即位数"的规律不变。

思考题 4:既然 select 的开销是 O(n),又有 1024 上限,为什么它至今没被淘汰?

详解:第一,它够简单、够老、可移植性最好,几乎所有 POSIX 平台都支持,而 epoll 只有 Linux 有,跨平台要拿 poll 或 macOS 的 kqueue 再包一层。第二,当连接数量不大时(比如几十上百),O(n) 的开销微乎其微,和 epoll 的差距拉不开,反而代码更直白、没有 epoll 那些"事件表增删、边沿触发/水平触发"的复杂度。第三,很多传统设备、嵌入式环境还是以它为主。所以它并没有"被淘汰",只是在"高并发"这个滤镜下显得力不从心——也正是这个力不从心,催生了 poll 和 epoll。等将来学 epoll 时回头再看,你会明白这三者的取舍本质上是"简单性 vs 可扩展性"的权衡。

结语

这一篇我们做了不少事。从"阻塞服务器为什么只能伺候一个人"的痛点出发,认识了多路转接的思想,然后一点一点拆开 select:五个参数(nfds、三个集合、timeout)各司其职,fd_set 作为一张"位图"用四位宏 FD_ZERO/FD_SET/FD_CLR/FD_ISSET 来操作,理解了"返回值三态"与 EBADF、EINTR 等错误,接着把 socket 的读/写/异常就绪条件逐条梳理清楚。然后亲手写了一个完整的多客户端回显服务器,把"花名册 + 每轮重建集合 + FD_ISSET 查验 + 关闭即摘出"这套标准打法走了一遍。最后点破了 select 的三大致命缺点(FD_SETSIZE 上限、返回即毁集合、O(n) 全量拷贝与线性扫描),并排了几个高频雷。

如果你把 select_stdin.c、select_server.c 都亲手敲过、编译过、跑过、用多个 nc 连接把它"榨"一遍,这堂课的核心就算真正长在你脑子里了。select 是这个领域的"老前辈"——它笨重、它有限制,但今天的一切(poll、epoll、乃至 io_uring)都是在解决它留下的问题,理解它,就是理解后面所有高性能多路复用方案最扎实的起跑线。

而且你会发现,多路转接这条路上最关键的那句心智模型始终是那句:"把监视交给内核,进程负责在原地等待就绪的汇报;select 返回后,集合已被改写成就绪名单,你必须备好一份花名册,每轮重新搭一遍。" 记住这句,select 就算拿下了。下一篇文章,我们去看它"更上一层楼"的进化版 poll,以及 Linux 上真正的王者 epoll。准备好了吗?