IO 多路转接之 select:从代码读懂一个服务器

在这里插入图片描述

我挖掘了一个巨牛的 人工智能 学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站。

面向已经掌握 socket 基础、正在学习 IO 多路转接的 C++ 学习者。本文不把 select 当成一张 API 表来背,而是沿着一个服务器真正的执行顺序,看看每一行代码为什么存在,删掉它又会发生什么。

一、先把问题摆出来:一个进程怎样同时等很多个 fd?

我们先回到最朴素的阻塞式服务器。监听套接字上调用 accept,没有新连接时会阻塞;连接套接字上调用 recv,没有数据时也会阻塞。假如服务器刚好卡在客户端 A 的 recv 上,即使客户端 B 已经发来数据,程序也处理不到 B。

问题不在于 accept 或 recv 不会工作,而在于我们的线程一次只能堵在一个地方,却要同时关心很多个文件描述符(fd)。

select 做的事可以用一句话概括:一次性把一批 fd 交给内核,让内核替我们同时等待;哪几个 fd 就绪了,内核就把结果交还给我们。

这里一定要区分 IO 的两个阶段:

  1. 等待条件就绪,例如等连接到来、等数据到达;
  2. 真正执行 IO,例如 accept、recv。

select 主要负责第一阶段。它不替我们收数据,只替我们把“逐个等待”变成“批量等待”。返回以后,应用层仍然要自己调用 accept 或 recv 完成第二阶段。

整个服务器的主循环可以先看成下面这张图:

返回值大于 0

返回值等于 0

返回值小于 0

监听 fd

普通连接 fd

从 _fd_array 重建 rfds
同时求 max_fd

select 等待

遍历 _fd_array
用 FD_ISSET 查询

本轮超时

等待出错

哪个 fd 就绪?

AcceptClient
接收新连接

ServiceIO
recv 并回显

先记住这个循环,后面所有代码都只是把图里的方框逐个实现出来。

二、认识 select:先别背参数,先看它在问什么

Linux 中的函数原型如下:

#include <sys/select.h>

int select(int nfds,
           fd_set* readfds,
           fd_set* writefds,
           fd_set* exceptfds,
           struct timeval* timeout);

它问内核四件事:

  • readfds:这些 fd 中,哪些已经满足“读条件”?
  • writefds:这些 fd 中,哪些已经满足“写条件”?
  • exceptfds:这些 fd 中,哪些出现了特定的异常条件?这里不是泛指所有 socket 错误,在 TCP 场景中通常用于带外数据等异常状态。
  • timeout:最多等多久?

本文的 echo 服务器只关心读事件,所以调用是:

int n = ::select(max_fd + 1,
                 &rfds,
                 nullptr,
                 nullptr,
                 nullptr);

1. 为什么第一个参数是 max_fd + 1?

nfds 不是 fd 的数量,也不是数组长度,而是“待检查的最大 fd 再加 1”。内核会检查 [0, nfds) 这个半开区间。因此,如果当前最大的文件描述符是 8,就要传 9;传 8 会漏掉 fd 8,传得过大则会让内核多做无意义的扫描。

也正因为如此,主循环每轮都要在有效 fd 中重新求出 max_fd。连接关闭以后,原来的最大 fd 可能消失;继续传旧值通常还能工作,但扫描范围更大。重新计算既简单,也能避免无效开销。

2. timeout 的三种写法,行为完全不同

struct timeval {
    time_t      tv_sec;   // 秒
    suseconds_t tv_usec;  // 微秒
};

tv_usec 的单位是微秒,合法常用范围是 0~999999,不是毫秒。总等待时间为:

tv_sec + tv_usec / 1,000,000 秒

三种等待策略可以放在一起对比:

传法行为适用场景注意点
nullptr永久阻塞,直到至少一个 fd 就绪或等待被信号打断纯 IO 服务器,没有定时任务本文代码实际使用这种方式
timeval timeout{0, 0};,传 &timeout不等待,检查后立即返回循环里还有计算、游戏帧、状态机等其他工作如果没有别的工作,紧密循环会让 CPU 空转
timeval timeout{sec, usec};,传 &timeout最多等待指定时长;期间有事件会提前返回,等满则返回 0心跳、连接超时清理、周期任务select 可能改写 timeout,下一轮必须重新赋值

因此,{0, 0} 绝不等于 nullptr:前者是“一点都不等”,后者是“可以一直等”。两者几乎是相反的行为。

源码里虽然写了:

struct timeval timeout = {0, 0};

但实际调用处是:

::select(max_fd + 1, &rfds, nullptr, nullptr, /*&timeout*/ nullptr);

也就是说,&timeout 被注释掉了,真正传入的是空指针,服务器会一直睡到事件到来。若要改成定时等待,不能只把注释打开,还必须在每一轮循环里重新构造或重新赋值 timeout,因为它也是可能被改写的参数。

3. 返回值是在告诉我们“发生了多少事”

返回值含义服务器通常怎样处理
> 0三个集合中已就绪 fd 的总数遍历自己的 fd 名单并处理事件
== 0等待超时,没有 fd 就绪执行定时任务,然后进入下一轮
< 0调用失败检查 errno;EINTR 通常重试,其他错误记录并处理

注意,返回值只告诉我们“有几个就绪”,不会直接告诉我们“是哪几个”。要找出具体 fd,还得用 FD_ISSET 查询结果集合。

三、fd_set:同一张位图,调用前后是两种语义

可以把 fd_set 理解成一张位图:第 i 位对应 fd i。位为 1,表示这个 fd 被登记或就绪;位为 0,表示未登记或未就绪。我们不直接操作它的内部存储,而是使用四个宏:

fd_set rfds;

FD_ZERO(&rfds);          // 所有位清 0:先得到一张干净的位图
FD_SET(fd, &rfds);       // 第 fd 位置 1:登记“我关心 fd”
FD_CLR(fd, &rfds);       // 第 fd 位清 0:不再关心 fd
bool ready = FD_ISSET(fd, &rfds); // 查询第 fd 位是否为 1

FD_SET(fd, &rfds) 只是用户态对位图写位的操作,不是系统调用,也不会在执行这一行时把 fd 单独“托管”给内核。真正把整张位图交给内核,是后面的 select。

最关键的结论来了:fd_set 是输入输出型参数。

假设调用前我们关心 fd 3、4、7:

调用 select 前(输入:关心集合)
fd:    7 6 5 4 3 2 1 0
bit:   1 0 0 1 1 0 0 0

本轮只有 fd 4 可读,select 返回后可能变成:

select 返回后(输出:就绪集合)
fd:    7 6 5 4 3 2 1 0
bit:   0 0 0 1 0 0 0 0

也就是说,调用前的 1 表示“我关心”,返回后的 1 表示“它就绪”。内核会保留就绪位,把没有就绪的位清 0。于是 FD_SET 和 FD_ISSET 正好组成一次请求与答复:

FD_ZERO
清空旧快照

FD_SET
逐个写入关心的 fd

select
内核等待并写回

FD_ISSET
逐个读取就绪结果

这里有一个容易说错的细节:不是等待期间某些位“慢慢变成 0”。对应用层来说,select 在返回前完成结果写回;如果传 nullptr 永久等待而始终没有事件,它根本不会正常返回,也就没有一份新的结果集合供我们读取。若限时等待最终返回 0,则没有任何 fd 就绪,结果集合中的位都会被清掉。

为什么每轮都必须 FD_ZERO?

因为上一轮返回后,rfds 已经从“完整关心名单”变成了“本轮就绪快照”。假设第一轮关心 3、4、7,但只有 4 就绪,那么返回后只剩 fd 4 的位为 1。如果第二轮直接复用这张位图,内核就只会继续监视 fd 4;fd 3 和 fd 7 已经悄悄从监视集合中丢失。如此反复,集合会越缩越小,服务器逐渐收不到其他连接的事件。

所以这两步必须成对出现:

FD_ZERO(&rfds);                  // 清掉上一轮的“就绪答案”
for (每一个仍然有效的 fd) {
    FD_SET(fd, &rfds);           // 重建这一轮的“关心问题”
}

只 FD_ZERO 不重新 FD_SET,传给内核的是空集合;只 FD_SET 新 fd 而不清空,旧结果和新请求会混在一起。正确做法就是“先清底,再从权威名单完整重建”。

四、为什么还要 _fd_array:它才是长期保存的权威名单

既然 rfds 能装 fd,为什么类里还要一个辅助数组?原因有两个:

  1. rfds 会被 select 改写,它只是每一轮的临时快照,不能长期保存全部连接;
  2. fd_set 只提供置位、清位和查询,没有“把当前所有置位 fd 枚举出来”的迭代接口。我们仍然需要一个可遍历的容器。

因此,代码把 _fd_array 当成权威名单:

class SelectServer
{
    static const int N = sizeof(fd_set) * 8;
    static const int defaultfd = -1;

private:
    uint16_t _port;
    std::unique_ptr<Socket> _listensock;
    int _fd_array[N];
};

数组中的合法 fd 会跨越多轮循环一直存在,空槽用 -1 表示。Linux 上 fd_set 通常由 FD_SETSIZE 限制为 1024 位,也就是只能表示 fd 0~1023;这里用 sizeof(fd_set) * 8 得到位数。更严谨的生产代码还应在 FD_SET 前检查 fd < FD_SETSIZE,因为限制的是 fd 数值能否放进位图,不只是当前连接数量。对越界 fd 使用这些宏会导致未定义行为。

约定 _fd_array[0] 永远放监听 fd,其余位置放客户端连接:

SelectServer(uint16_t port)
    : _port(port),
      _listensock(std::make_unique<TcpSocket>())
{
    InetAddr addr("0", _port);
    _listensock->BuildListenSocket(addr);

    for (int i = 0; i < N; ++i) {
        _fd_array[i] = defaultfd;
    }

    _fd_array[0] = _listensock->SockFd();
}

BuildListenSocket 把 socket → bind → listen 封装起来了,所以正文里没有直接出现 ::listen,但监听动作并没有消失。若使用原生 sockaddr_in,端口要通过 htons 转成网络字节序,IPv4 地址通常用 inet_pton 或 htonl 处理;这些细节应由 InetAddr/BuildListenSocket 的封装负责,不能把主机字节序的端口直接塞给内核。

初始化顺序也有讲究:先把全部槽位置为 -1,再把 0 号位改成监听 fd。否则后面的遍历会把未初始化的随机整数误当成合法 fd,select 轻则返回 EBADF,重则触发越界位操作。

0 号位固定放监听 fd 的好处,是新增客户端时可以从下标 1 开始寻找空槽,永远不会覆盖监听入口。同时,事件处理时只要比较当前 fd 是否等于监听 fd,就能决定调用 AcceptClient 还是 ServiceIO。

五、沿执行顺序读代码

第一步:每轮重建 rfds,顺便求出 max_fd

void Loop()
{
    while (true)
    {
        fd_set rfds;
        FD_ZERO(&rfds);              // 必须清掉上一轮 select 的结果

        int max_fd = defaultfd;      // 从 -1 开始,避免保留旧最大值

        for (int i = 0; i < N; ++i)
        {
            if (_fd_array[i] == defaultfd)
                continue;            // 空槽不是 fd,绝不能加入位图

            FD_SET(_fd_array[i], &rfds);

            if (max_fd < _fd_array[i])
                max_fd = _fd_array[i];
        }

        int n = ::select(max_fd + 1,
                         &rfds,
                         nullptr,
                         nullptr,
                         nullptr);

        switch (n)
        {
        case 0:
            // 只有设置了有限 timeout,才会正常走到这里
            break;
        case -1:
            // 教学代码记录错误;生产代码应根据 errno 分类
            break;
        default:
            HandlerEvent(rfds);
            break;
        }
    }
}

这一段正好对应主循环的三个动作:

  1. FD_ZERO + FD_SET 重建关心集合,同时求 max_fd;
  2. select(max_fd + 1, ...) 把等待交给内核;
  3. 返回值大于 0 时调用 HandlerEvent(rfds) 消费就绪结果。

defaultfd 是 -1,它只表示空槽,不能加入 fd_set。如果忘了 continue,拿 -1 调用 FD_SET 会访问非法位。max_fd 也要每轮重算,不能想当然地用数组最后一个下标:数组位置和 fd 数值没有必然关系,_fd_array[2] 完全可能存着 fd 100。

这里传入 nullptr,所以 case 0 正常情况下不会发生;它仍被保留,是为了以后切换有限超时。case -1 的教学版只是记录错误,生产中至少要区分 errno == EINTR(被信号打断,可继续下一轮)和真正的参数或 fd 错误。

第二步:HandlerEvent 用 FD_ISSET 分派

void HandlerEvent(fd_set& rfds)
{
    for (int i = 0; i < N; ++i)
    {
        if (_fd_array[i] == defaultfd)
            continue;

        if (!FD_ISSET(_fd_array[i], &rfds))
            continue; // 在权威名单里,但本轮没有就绪

        if (_fd_array[i] == _listensock->SockFd())
            AcceptClient();
        else
            ServiceIO(i);
    }
}

为什么还要遍历整个 _fd_array?因为 select 只返回就绪数量,fd_set 又没有迭代器。程序只能拿权威名单中的每个合法 fd 去问:你的那一位还在吗?FD_ISSET 为真,就说明这个 fd 属于本轮就绪集合。

监听 fd 的“可读”和普通 fd 的“可读”含义不同:

  • 监听 fd 可读:TCP 全连接队列不为空,至少有一个已经完成握手的连接可以 accept;
  • 普通连接 fd 可读:接收缓冲区里有数据,或者收到了对端的 FIN,或者出现了某些需要通过读取暴露的错误。

所以同样是读事件,监听 fd 走 AcceptClient(),普通 fd 走 ServiceIO(i)。

第三步:监听 fd 就绪,AcceptClient 接收并登记连接

void AcceptClient()
{
    InetAddr clientaddr;
    int sockfd = _listensock->Accepter(&clientaddr);
    if (sockfd < 0)
        return;

    int pos = 1;                    // 0 号位留给监听 fd
    for (; pos < N; ++pos)
    {
        if (_fd_array[pos] == defaultfd)
            break;
    }

    if (pos == N)
    {
        ::close(sockfd);            // 没有槽位,不能把连接悬空
        return;
    }

    _fd_array[pos] = sockfd;        // 加入权威名单
}

这里调用 accept 为什么通常不会阻塞?因为 select 已经告诉我们监听 fd 可读,而监听 fd 的可读条件就是全连接队列中已有连接。我们不是盲目调用 accept,而是在内核发出“可以取了”的通知后再取。

clientaddr 是输出对象:调用前准备一块空间,accept 返回时由内核填入对端 IP 和端口。原生接口中对应的是 sockaddr* 与一个输入输出型的地址长度 socklen_t*。显示端口时要用 ntohs 从网络字节序转回主机字节序;这一转换通常由 InetAddr::Port() 封装。

新 sockfd 不能马上在这里长期阻塞地 recv,否则服务器又退回“一次只服务一个客户端”的模式。正确做法是把它放进 _fd_array。下一轮循环会自动通过 FD_SET 把它加入新的 rfds,从那以后由 select 统一等待。

如果数组已满,必须关闭刚刚 accept 出来的 fd。因为这个 fd 已经是内核资源,却没有进入服务器的管理名单;不关闭就会泄漏。教学代码一次只 accept 一个连接,生产环境常把监听 fd 设为非阻塞,并循环 accept 直到返回 EAGAIN,一次排空当前积压连接,避免高并发时队列里还有连接却要等下一轮通知。

第四步:普通 fd 就绪,ServiceIO 读取并回显

void ServiceIO(int pos)
{
    int fd = _fd_array[pos];
    char buffer[1024];

    ssize_t n = ::recv(fd, buffer, sizeof(buffer) - 1, 0);

    if (n > 0)
    {
        buffer[n] = '\0';

        std::cout << "client say# " << buffer << std::endl;

        std::string reply = "[server echo]# ";
        reply += buffer;
        ::send(fd, reply.data(), reply.size(), 0);
    }
    else if (n == 0)
    {
        ::close(fd);                 // 释放内核中的文件描述符
        _fd_array[pos] = defaultfd;  // 从权威名单移除
    }
    else
    {
        // 教学版简化:所有错误都关闭
        ::close(fd);
        _fd_array[pos] = defaultfd;
    }
}

这里的 recv 为什么通常也不会阻塞?同理,调用它之前 select 已经报告普通连接 fd 可读。把等待交给内核的价值就在这里:应用层只对已经满足读条件的 fd 做读取。

缓冲区大小是 1024,但传给 recv 的是 sizeof(buffer) - 1,故意留下一个字节给字符串结束符 \0。若直接读取 1024 字节再写 buffer[n] = '\0',当 n == 1024 时就会越界。需要注意,TCP 是字节流,单次 recv 得到的并不天然等于一条完整业务消息;这里能直接打印,只是 echo 教学示例。真实协议还要解决粘包、拆包和消息边界。

recv 的三类返回值决定了后续动作:

recv 返回值含义本代码处理
> 0实际读到的字节数补 \0、打印、拼接并回显
== 0收到对端 FIN,读到 TCP EOFclose(fd),再把槽位置为 -1
< 0读取失败,原因在 errno教学版直接关闭;生产版应分类
为什么 recv == 0 必须关闭?

对流式 TCP 套接字,在本代码请求读取非零长度的前提下,recv 返回 0 表示已经读到 EOF:对端关闭了发送方向,后续不可能再从这个方向收到新字节。select 只能告诉我们“这个 fd 可读”,不能区分“有数据”和“EOF”;真正的区别必须靠 recv 的返回值判断。因此,对端关闭也是一种可读事件,而且如果不处理,fd 往往会在后续轮次持续报告可读。

这个 echo 服务器不需要保留半关闭连接,于是执行两步清理:

::close(fd);                 // 释放内核资源
_fd_array[pos] = defaultfd;  // 从服务器名单中删除

两步缺一不可:

  • 只 close 不置 -1:数组里残留一个已经失效的数字,下一轮仍会 FD_SET,select 可能因无效 fd 返回 EBADF;更危险的是,该数字日后可能被系统复用给别的资源,形成串号问题。
  • 只置 -1 不 close:应用层虽然不再管理它,但内核资源仍在,最终造成 fd 泄漏,服务器运行一段时间后可能达到进程文件描述符上限。

对于 n < 0,示例直接关闭是为了突出 select 主线。生产代码应该检查 errno:EINTR 表示被信号打断,通常可以重试;非阻塞套接字上的 EAGAIN/EWOULDBLOCK 表示此刻没有数据,也不该直接断开;ECONNRESET 等不可恢复错误才适合关闭。

最后再提醒一个教学代码有意省略的点:send 也可能只发送一部分数据,还可能遇到 EINTR、EAGAIN 或对端关闭。完整服务器需要处理部分写,并根据需要把 fd 加入写事件集合;本文只沿读事件讲解,所以暂不展开。

六、把四个高频问题一次说透

1. FD_ZERO(&rfds) 为什么每轮都要执行?

因为 select 会把 rfds 从“关心集合”改写成“就绪集合”。上一轮没就绪的位已经被清 0,如果直接复用,下一轮只会监视上一轮碰巧就绪的 fd,集合会越来越小。正确模板永远是:每轮先 FD_ZERO 清底,再从 _fd_array 对全部合法 fd 重新 FD_SET。

2. timeval timeout{0, 0} 到底表示什么?

只有把 &timeout 传入 select 时它才生效,此时表示 0 秒 0 微秒,调用立即返回,适合循环里还有其他工作的轮询模式;没有其他工作会造成 CPU 空转。传 nullptr 才是无限等待。传 {sec, usec} 是限时等待,适合心跳和超时清理,并且每轮都要重新设置,因为 select 可能改写剩余时间。

3. FD_SET 做了什么?rfds 前后语义怎样变化?

FD_SET(fd, &rfds) 在用户态把位图第 fd 位置 1,表达“这一轮我关心它”,本身不是系统调用。select 返回时,内核一次性把这张位图写成结果:就绪位保留为 1,未就绪位清 0。之后应用层用 FD_ISSET 查询“它是否就绪”。所以 FD_SET 是写请求,FD_ISSET 是读答复,而 _fd_array 负责在轮次之间保存完整名单。

4. recv 返回 0 为什么直接关闭?

它表示收到了对端 FIN,当前读取方向已经到达 EOF。连接不会再提供新数据,继续保留只会占用 fd 槽位。应同时 close 释放内核资源,并把数组槽位置为 -1 移出监视名单。select 只报告“可读”,到底是普通数据还是 EOF,必须通过 recv > 0 与 recv == 0 区分。

七、select 的局限:为什么后面还要学 poll 和 epoll?

select 很适合用来建立 IO 多路转接的第一套心智模型,但它的代价也很明显:

  1. fd 数值受位图上限限制。 Linux 中通常 FD_SETSIZE == 1024,能表达的 fd 是 0~1023。即使只连接少量客户端,只要某个 fd 数值已经超过上限,也不能安全放进集合。
  2. 每轮都要拷贝集合。 用户态把位图交给内核,内核再写回结果;连接数增加时,固定大小的数据拷贝会反复发生。
  3. 内核需要线性扫描。 内核要在 [0, nfds) 范围检查 fd;应用层返回后还要遍历 _fd_array,找出具体是谁就绪。
  4. 输入输出参数混用。 fd_set 返回后原始关心集合丢失,应用层必须每轮 FD_ZERO + FD_SET 重建。

poll 先解决固定 1024 位图和输入输出集合混用的问题,改用 pollfd 数组;epoll 则进一步把“长期注册关心对象”和“取回本轮就绪事件”分开,避免每轮完整传递和扫描全部关心集合。等学到它们时,你会发现改进点正好对应 select 暴露出来的这些痛点。

八、小结:真正要记住的是这条执行链

一个 select 服务器的核心不是某个孤立 API,而是一套反复执行的协作关系:

_fd_array(长期权威名单)
        ↓ 每轮完整重建
rfds(本轮关心集合)
        ↓ select 等待并改写
rfds(本轮就绪集合)
        ↓ FD_ISSET 查询与分派
accept / recv(真正执行 IO)
        ↓ 新增或删除连接
更新 _fd_array,进入下一轮

监听 fd 就绪,说明全连接队列里有连接,去 accept;普通 fd 就绪,说明可能有数据,也可能是 EOF,去 recv 再看返回值。新增连接写入 _fd_array,关闭连接则 close + 置 -1。而每一轮开头,都要忘掉上一轮的临时答案,从权威名单重新提出完整问题。

把这条链真正走通,select 就不再是一组难记的宏,而是一个很清楚的分工:数组负责记住所有人,位图负责描述这一轮,内核负责等待,应用层负责处理。

Logo

更多推荐