15. select模型:select()函数详解、fd_set操作、最大文件描述符限制

说到网络编程里的多路复用,select 模型绝对是个绕不开的老前辈。我刚开始写网络程序那会儿,用的就是它。说实话,现在 epoll 大行其道,但 select 在很多嵌入式场景和老系统里依然坚挺。你想想看,搞懂 select,其实就搞懂了 I/O 多路复用的核心思想。

select() 函数到底在干嘛?

简单说,select 就是帮我们盯着多个文件描述符。哪个可读了,哪个可写了,哪个出异常了,它都能告诉我们。这样我们就不用一个个去轮询了,省心不少。

#include <sys/select.h>
#include <sys/time.h>

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

参数看着多,其实好理解:

  • nfds:最大文件描述符 + 1。注意,不是文件描述符的数量,是最大值加一。我见过有人直接传 1024,结果程序跑得莫名其妙。
  • readfds:要监视可读的集合。比如 socket 有数据来了,这个集合就会标记它。
  • writefds:要监视可写的集合。缓冲区有空了,就能写。
  • exceptfds:监视异常。比如带外数据。
  • timeout:超时时间。传 NULL 表示一直等,传 0 表示立即返回。

返回值:成功返回就绪的文件描述符数量,超时返回 0,出错返回 -1。

fd_set 操作:别直接赋值!

fd_set 是个位图结构。每个 bit 对应一个文件描述符。但你不能直接操作它,得用宏:

fd_set readfds;
FD_ZERO(&readfds);        // 清空集合
FD_SET(sockfd, &readfds); // 把 sockfd 加进去
FD_CLR(sockfd, &readfds); // 把 sockfd 移除
if (FD_ISSET(sockfd, &readfds)) {
    // 判断 sockfd 是否在集合中
}

我个人习惯每次调用 select 前都重新设置 fd_set。因为 select 返回后,集合会被修改,只保留就绪的描述符。你想想看,如果不重新设置,第二次调用时集合就乱了。

小技巧:每次循环开始前,用 FD_ZERO 清空,再用 FD_SET 把需要的描述符加进去。别偷懒,否则 bug 很难查。

最大文件描述符限制:1024 的痛

select 有个硬伤——它默认最多监视 1024 个文件描述符。这个限制来自 FD_SETSIZE,通常是 1024。为什么会这样?因为 fd_set 是个固定大小的位图,底层是数组。

我在项目中遇到过这个问题。当时写一个高并发聊天服务器,连接数刚过 1000 就出问题了。查了半天,发现是 select 的限制。后来改用了 poll 才解决。

限制来源 说明
FD_SETSIZE 默认 1024,可修改但需重新编译内核
内核限制 select 每次都要遍历所有 fd,性能随数量线性下降
位图大小 fd_set 是固定大小,超出范围会越界

注意:如果你试图监视超过 FD_SETSIZE 的文件描述符,程序会崩溃。我曾经在代码里没检查,结果线上服务挂了。后来加了个断言,才放心。

一个完整的 select 服务端示例

下面这个例子,我简化了错误处理,但核心逻辑都在。你一看就明白:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/select.h>

#define PORT 8888
#define MAX_CLIENTS 10

int main() {
    int server_fd, client_fd, max_fd, activity;
    struct sockaddr_in address;
    fd_set readfds;
    int client_sockets[MAX_CLIENTS] = {0};

    // 创建 socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    listen(server_fd, 3);

    printf("服务器启动,监听端口 %d\n", PORT);

    while (1) {
        FD_ZERO(&readfds);
        FD_SET(server_fd, &readfds);
        max_fd = server_fd;

        // 把客户端 socket 也加进去
        for (int i = 0; i < MAX_CLIENTS; i++) {
            int sd = client_sockets[i];
            if (sd > 0) {
                FD_SET(sd, &readfds);
            }
            if (sd > max_fd) {
                max_fd = sd;
            }
        }

        // 等待活动
        activity = select(max_fd + 1, &readfds, NULL, NULL, NULL);
        if (activity < 0) {
            perror("select 出错");
            continue;
        }

        // 有新连接
        if (FD_ISSET(server_fd, &readfds)) {
            client_fd = accept(server_fd, NULL, NULL);
            for (int i = 0; i < MAX_CLIENTS; i++) {
                if (client_sockets[i] == 0) {
                    client_sockets[i] = client_fd;
                    printf("新客户端加入,fd = %d\n", client_fd);
                    break;
                }
            }
        }

        // 处理客户端数据
        for (int i = 0; i < MAX_CLIENTS; i++) {
            int sd = client_sockets[i];
            if (FD_ISSET(sd, &readfds)) {
                char buffer[1024] = {0};
                int valread = read(sd, buffer, sizeof(buffer));
                if (valread == 0) {
                    // 客户端断开
                    close(sd);
                    client_sockets[i] = 0;
                    printf("客户端断开,fd = %d\n", sd);
                } else {
                    printf("收到数据: %s\n", buffer);
                    send(sd, buffer, valread, 0);
                }
            }
        }
    }
    return 0;
}

这段代码里,我每次循环都重新构建 readfds。嗯,这里要注意:select 返回后,集合里只保留就绪的描述符。所以必须重新设置。

select 的核心流程

下面这张图,把 select 的整个调用过程画清楚了。你一看就懂:

select 模型核心流程 1. 创建 socket 并监听 2. FD_ZERO 清空集合 3. FD_SET 添加 fd 4. 调用 select() 阻塞等待 5. select 返回,检查就绪 fd 6a. 处理新连接 6b. 处理客户端数据 循环回到步骤 2

避坑指南

我总结几个实际开发中容易踩的坑:

  • 忘记重新设置 fd_set:select 返回后集合会被修改。每次循环必须重新 FD_ZERO 和 FD_SET。
  • nfds 参数传错:要传最大文件描述符 + 1,不是总数。传小了 select 会漏掉高位的 fd。
  • timeout 参数被修改:Linux 下 select 会修改 timeval 结构体。如果重用,每次都要重新设置。
  • 信号中断:select 被信号中断时会返回 -1,errno 是 EINTR。这时候应该重新调用,不是直接退出。

我的建议:如果你只是写个简单的聊天室或者嵌入式程序,select 完全够用。但要是高并发场景,还是用 epoll 吧。select 的 1024 限制和 O(n) 遍历,在高负载下确实扛不住。

好了,select 模型的核心内容就这些。说白了,它就是一套帮你管理多个 socket 的机制。理解它的工作原理,后面学 poll 和 epoll 会轻松很多。

公众号:蓝海资料掘金营,微信deep3321