Linux 学习笔记(五十六):IO 多路复用初探——select 的“三宗罪”

📌 核心结论(TL;DR):

  1. IO 多路复用:用一个线程同时监控多个文件描述符(fd),解决单线程阻塞和多线程开销大的问题。

  2. select 机制 :利用 fd_set(位图)向内核注册关心的 fd,内核阻塞等待直到有 fd 就绪。

  3. 三宗罪 :每次调用都要把位图全量拷贝 到内核;内核需要遍历 所有 fd 找就绪的;受限于位图大小,最多只能监听 1024 个 fd。

  4. 致命大坑 :select 返回时会把未就绪的 fd 从集合中清空,下次调用前必须重新初始化(重置位图)。

一、 为什么需要多路复用?

传统的阻塞 IO 只能服务一个客户端,非阻塞 IO 需要死循环轮询导致 CPU 飙高。多线程/多进程方案在高并发下会导致内存和调度崩溃。IO 多路复用允许我们使用一个线程,同时监控成百上千个文件描述符。只要有数据就绪,就通知程序处理。

二、 select 的 API 设计

select 使用 fd_set 位图结构来传递信息。比如你想监视 fd 为 5 的套接字,就把第 5 位置 1。调用 select 后,如果没有数据,线程会在这里挂起;如果有数据,select 返回,并告诉你哪些 fd 还是 1(就绪),哪些变成了 0(没就绪)。

三、 实战与核心踩坑

cpp 复制代码
// 每次调用前必须用临时变量备份!
fd_set tmp_fds = read_fds; 
int ret = select(max_fd + 1, &tmp_fds, NULL, NULL, NULL);

for (int fd = 0; fd <= max_fd; fd++) {
    if (!FD_ISSET(fd, &tmp_fds)) continue; // 必须遍历检查
    // 处理就绪的 fd ...
}

排雷实验 :在代码循环中,如果不使用 tmp_fds 备份,而是直接把 &read_fds 传进去,程序运行第二次时 select 将失效。这是因为 select 会修改传入的位图,把没就绪的 fd 直接清零。这种"输入输出参数混用"的设计极其反人类。

四、 为什么说 select 已经被淘汰?

当连接数达到几千上万时,每次 select 都要进行"两次用户态与内核态的拷贝",并且要遍历所有注册的 fd。如果只有 1 个客户端发了消息,却要遍历 10000 个连接,这种 O(n) 的复杂度直接拖垮了性能。这就引出了后续更高级的多路复用技术:poll 和 epoll。

自我实战操作

咱们来写一个最简单的 select 服务器骨架,为了测试方便,我们把"标准输入(键盘)"和"服务器监听"加在一起。

bash 复制代码
cd ~/linux_study/network_test
实战:极简 select 服务器
bash 复制代码
vim select_server.c

(输入以下代码,注意重点看 fd_set 的重置和遍历)

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h>

#define PORT 9999
#define BUF_SIZE 1024

int main() {
    // 1. 创建监听 socket
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = INADDR_ANY;
    bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
    listen(listen_fd, 5);
    printf("Select 服务器启动,监听端口 %d...\n", PORT);

    // 2. 初始化 select 的位图
    fd_set read_fds;
    FD_ZERO(&read_fds);          // 清零
    FD_SET(0, &read_fds);        // 加入标准输入(键盘)
    FD_SET(listen_fd, &read_fds);// 加入监听 socket
    int max_fd = listen_fd;      // 记录最大的 fd

    while (1) {
        fd_set tmp_fds = read_fds; // 🌟 极其重要:每次都要用临时变量!因为内核会修改它

        // 3. 调用 select 阻塞等待
        printf("等待数据中...\n");
        int ret = select(max_fd + 1, &tmp_fds, NULL, NULL, NULL);
        if (ret < 0) { perror("select"); break; }

        // 4. 遍历判断是谁就绪了(从 0 循环到 max_fd)
        for (int fd = 0; fd <= max_fd; fd++) {
            if (!FD_ISSET(fd, &tmp_fds)) continue; // 没就绪就跳过

            if (fd == listen_fd) {
                // 场景A:有新连接来了
                struct sockaddr_in client_addr;
                socklen_t len = sizeof(client_addr);
                int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &len);
                printf("新连接接入, fd = %d\n", conn_fd);
                
                FD_SET(conn_fd, &read_fds); // 把新 fd 加入集合
                if (conn_fd > max_fd) max_fd = conn_fd; // 更新最大 fd
            } 
            else if (fd == 0) {
                // 场景B:键盘有输入
                char buf[BUF_SIZE];
                fgets(buf, sizeof(buf), stdin);
                printf("键盘输入: %s", buf);
            } 
            else {
                // 场景C:普通客户端发来了数据
                char buf[BUF_SIZE];
                int n = read(fd, buf, BUF_SIZE - 1);
                if (n > 0) {
                    buf[n] = '\0';
                    printf("收到客户端(fd=%d)消息: %s\n", fd, buf);
                    write(fd, buf, n); // Echo 回显
                } else if (n == 0) {
                    printf("客户端(fd=%d)断开\n", fd);
                    FD_CLR(fd, &read_fds); // 从集合中移除
                    close(fd);
                }
            }
        }
    }
    close(listen_fd);
    return 0;
}

编译运行:

cpp 复制代码
gcc select_server.c -o select_server
./select_server

👉 现象:

  1. 你在终端输入文字按回车,它能打印出来。

  2. 你开另一个终端跑 telnet 127.0.0.1 9999 连上来发消息,它也能回显。

  3. 排雷 :如果你把代码里的 fd_set tmp_fds = read_fds; 改成直接传 &read_fds 给 select,第二次调用它就会出 Bug,因为 read_fds 已经被内核清空了。

相关推荐
Ruiery31 分钟前
Linux 6.6内核内存管理深度解析(十五):CMA 连续内存分配器
linux·运维·服务器
测试界的小码农1 小时前
【Linux 命令实战(01):认识 Linux 终端,从零掌握第一个命令】
linux·运维·服务器
布裘2 小时前
【银河麒麟】重启后时区丢失?排查发现 localtime 被业务程序替换成硬链接
linux·运维·网络
.痞老板.3 小时前
mysql初阶--数据库基础
数据库·mysql
zhikouai3 小时前
把外部助手接进聊天软件的架构分层与参数配置
服务器·网络·php
荣合技术服务3 小时前
HPC 超算集群故障维修实录:找荣合科算维护后,模拟仿真计算又能跑了
运维·服务器·超算服务器·荣合科算
城北徐宫4 小时前
MySQL约束条件一览
数据库·mysql
GeW5 小时前
高端化转型进入“补底层”阶段:Linux与数据库的再定位
linux
silent5 小时前
龙芯3B6000M运行Yolov8+FastApi
linux·loongarch·loongnix·龙芯