io复用 select/poll/epoll

一、先铺垫【前置核心知识点】

1. 什么是「文件描述符 fd」?

  • 通俗讲:fd 就是 Linux 里「所有打开的资源」的编号、身份证号
  • 比如:你写的 HTTP 服务器里,监听套接字sockfd是一个 fd,客户端连接的c是一个 fd,打开的index.html文件是一个 fd,图片dog2.jpg也是一个 fd
  • 你的笔记:有多少个连接,就产生多少个描述符 → ✔️ 完全正确!来 1 个客户端连接 = 生成 1 个客户端 fd;来 1000 个连接 = 1000 个 fd;连接越多,fd 数量越多

2. 什么是「我们关心的事件」?

就是我们的服务器想盯着的、需要处理的动作,对网络编程(你的 HTTP 服务器)来说,只有 2 种核心事件,也是唯一关心的事件:

  1. 读事件:fd 有数据可以读了 → 比如 客户端给服务器发 HTTP 请求了、浏览器请求图片了
  2. 写事件:fd 可以往外写数据了 → 比如 服务器要给浏览器返回 HTML 文件、返回图片内容了
  • 其他事件咱们不用管,服务器只盯着:哪些 fd 有「读 / 写」的需求

3. 为什么要「收集文件描述符」?

  • 服务器运行时,会同时存在很多 fd(监听 fd + 多个客户端 fd + 打开的文件 fd)
  • 我们需要把所有需要监听的 fd,全部放进一个「容器」(比如数组、结构体)里,这个容器就是专门装 fd 的「监听器名单」
  • 作用:把分散的 fd 汇总起来,方便后面select/poll/epoll一次性全部监听

二、核心痛点:为什么会出现 select/poll/epoll ?(图片重点,你之前的代码对比)

你之前写的是 多线程服务器:一个客户端连接 → 创建一个线程,专门处理这个客户端的 fd;

优点:简单好写,逻辑清晰❌ 致命缺点:连接一多就崩了!比如来了 1000 个客户端连接,就要创建 1000 个线程,线程是重量级资源,占内存、占 CPU,服务器直接扛不住,这叫「高并发撑不住」

图片里的核心:多线程 / 多进程 处理高并发,效率极低、资源浪费严重 → 这就是为什么必须学 select/poll/epoll!


三、核心方案:IO 多路复用(图片 + 笔记的核心)

✅ 什么是 IO 多路复用?

一句话大白话 :用 「1 个主线程」 ,代替之前的「成百上千个子线程」,一次性监听「容器里所有的 fd」,同时盯着这些 fd 有没有「我们关心的读 / 写事件」,哪个 fd 的事件就绪了,就去处理哪个 fd。

  • 核心精髓:一个线程,搞定所有连接的监听 + 处理,极大节省内存和 CPU 资源,专门解决「高并发连接」问题
  • 你的笔记这句话,就是 IO 多路复用的官方定义 ✔️

✅ IO 多路复用的实现方式:就是 select / poll / epoll 三个函数

这三个函数,就是实现「一个线程监听多个 fd」的三把工具 ,都是 Linux 系统提供的系统调用,功能完全一样:监听多个 fd、返回就绪的事件 ,只是底层实现不一样、效率不一样、用法不一样。图片里的 select/poll/epoll 就是这个意思!


四、图片 + 笔记 重点:select/poll/epoll 三者的核心区别(通俗易懂版,必记,图片全部内容)

✅ 共同点(三者都一样)

  1. 都是「IO 多路复用」工具,都能一个线程监听多个 fd;
  2. 都是「阻塞等待」:调用后线程会卡着,直到有 fd 就绪(有事件),或者超时才返回;
  3. 都能监听「读事件、写事件」,满足我们服务器的所有需求。

✅ 核心不同点(效率 + 底层逻辑,图片重点,最常考,通俗讲)

✔️ 1. select 【最老、最简单、效率最低】
  • 底层容器:用固定大小的数组 存要监听的 fd,数组上限是 1024 → 最多只能监听 1024 个 fd!
  • 致命缺点 1:监听的 fd 有上限,超过 1024 个就不行了;
  • 致命缺点 2:每次调用 select,都要把「所有监听的 fd」从程序拷贝到内核,fd 越多拷贝越慢;
  • 致命缺点 3:select 返回后,不知道哪个 fd 就绪了,需要我们自己「遍历所有监听的 fd」,一个个判断,fd 越多遍历越慢。
  • 总结:适合 fd 少的场景(比如几百个连接),简单够用就行。
✔️ 2. poll 【select 的升级版,解决了一个致命问题】
  • 底层容器:用链表 存要监听的 fd,没有上限 → 想监听多少个 fd 就监听多少个,解决了 select 的 1024 上限问题;
  • 但 poll继承了 select 的另外 2 个缺点:每次调用还是要拷贝所有 fd 到内核、返回后还是要遍历所有 fd 找就绪的;
  • 总结:比 select 好用一点,能处理更多连接,但效率还是一般,fd 多了照样慢。
✔️ 3. epoll 【终极版、最高效、最常用,图片重点】
  • 是 Linux 特有的,专门为高并发、大连接数 设计的,图片里的 sockfd 1W 1K 就是说它 → 能轻松监听 10000+ 个 fd,只占用极少资源;
  • 底层容器:用红黑树存所有监听的 fd,没有上限,增删改查 fd 都很快;
  • 核心优点 1:无需重复拷贝 fd:fd 只需要拷贝一次到内核,之后增删 fd 只改红黑树就行,不用重复拷贝;
  • 核心优点 2:无需遍历所有 fd:内核会帮我们「记住就绪的 fd」,epoll 返回时,直接把「就绪的 fd 列表」给我们,拿到就能处理,不用遍历,效率拉满;
  • 核心优点 3:支持「边缘触发 / 水平触发」两种模式,灵活度拉满;
  • 总结:生产环境首选,你的 HTTP 服务器如果要做高并发,一定用 epoll!

五、图片里的 sockfd 1W 1K 是什么意思?

这个是 epoll 的性能描述,通俗易懂解释:

  • 1W → epoll 可以轻松监听 10000 个 套接字 fd(客户端连接);
  • 1K → 这 10000 个连接里,只有 1000 个连接有事件就绪(有请求要处理);
  • 核心想表达:epoll 面对「海量连接、少量就绪」的高并发场景,效率碾压 select/poll,这也是实际生产中最常见的场景(比如直播间 1 万人连接,只有 100 人发弹幕)。

六、所有知识点 终极总结(一页纸复习,全部考点浓缩,通俗易懂)

✔️ 核心逻辑链

有很多客户端连接 → 产生很多 fd → 用容器收集所有要监听的 fd → 用 select/poll/epoll(IO 多路复用)一次性监听所有 fd → 哪个 fd 有事件就处理哪个 → 用 1 个线程搞定所有连接,解决高并发问题。

✔️ 核心对比

  1. 多线程:一个连接一个线程 → 简单,高并发崩;
  2. select:监听 fd 上限 1024 → 简单,效率低;
  3. poll:无 fd 上限 → 一般,效率一般;
  4. epoll:无上限 + 无需拷贝 + 无需遍历 → 最强,高并发首选。

✔️ 一句话记住

select 是青铜,poll 是白银,epoll 是王者!你的 HTTP 服务器想支持万人访问,必须用 epoll。

七、基于select实现的 IO 复用代码

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

int main()
{
    // 定义要监听的文件描述符:STDIN_FILENO 是键盘输入,固定值为0
    int fd = STDIN_FILENO;
    // 定义select的文件描述符集合,用来存放要监听的fd
    fd_set fdset;

    // 死循环,持续监听
    while(1)
    {
        // 1. 清空文件描述符集合 (每次循环必须重置,因为select会修改这个集合)
        FD_ZERO(&fdset);
        // 2. 将需要监听的fd,添加到监听集合中
        FD_SET(fd, &fdset);

        // 3. 设置select的超时时间:结构体timeval 秒+微秒
        struct timeval tv = {5, 0}; // 超时时间:5秒,0微秒

        // 4. 核心:调用select函数,监听集合中的文件描述符
        // 参数1:监听的最大文件描述符+1 (select的硬性要求)
        // 参数2:读事件集合,关心有没有数据可以读
        // 参数3:写事件集合,NULL表示不监听写事件
        // 参数4:异常事件集合,NULL表示不监听异常事件
        // 参数5:超时时间,超时返回0
        int n = select(fd+1, &fdset, NULL, NULL, &tv);
        
        // 5. 处理select的三种返回值
        if(n == -1)
        {
            // 返回-1:调用失败
            perror("select err");
            exit(1);
        }
        else if(n == 0)
        {
            // 返回0:超时,指定时间内没有事件就绪
            printf("time out\n");
        }
        else
        {
            // 返回>0:有n个文件描述符就绪,需要判断哪个fd就绪
            if(FD_ISSET(fd, &fdset))
            {
                // 判断我们监听的fd是否在就绪集合中
                char buff[128] = {0};
                // 读取键盘输入的内容
                read(fd, buff, sizeof(buff)-1);
                printf("read buf = %s\n", buff);
            }
        }
    }
    exit(0);
}

核心知识点

1. 运行效果
  • 运行后程序阻塞等待,5 秒内不输入内容 → 打印 time out 并重新等待
  • 5 秒内输入任意内容 + 回车 → 程序立刻读取并打印你输入的内容
  • 循环执行,永不退出
2. select 核心固定套路 (背下来!所有 select 代码都这么写)
复制代码
① FD_ZERO(&fdset);    清空集合
② FD_SET(fd, &fdset); 添加要监听的fd
③ 定义timeval 设置超时时间
④ n = select(最大fd+1, 读集合, 写集合, 异常集合, &tv);
⑤ 判断返回值:-1失败 / 0超时 / >0有就绪事件
⑥ FD_ISSET(fd, &fdset) 判断目标fd是否就绪,就绪则处理
3. 关键宏解释

STDIN_FILENO : 系统宏定义,等价于数字0,就是 键盘标准输入 的文件描述符,这是 Linux 里固定的。

4. 为什么每次循环都要重新 FD_ZERO + FD_SET ?

✔️ 重中之重:select函数执行后,会修改传入的 fdset 集合 ------ 内核会把集合中「未就绪的 fd 全部清空」,只保留「就绪的 fd」。所以下一次循环必须重新初始化集合、重新添加要监听的 fd,否则监听会失效!

八、基于select的 IO 复用 TCP 服务器

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

// 定义最大监听的文件描述符数量
#define MAXFD 10

/**************************************************************************
函数名:socket_init
函数功能:TCP服务器初始化(创建套接字+绑定+监听)
返回值:成功返回监听套接字描述符,失败返回-1
**************************************************************************/
int socket_init()
{
    // 创建TCP套接字(AF_INET:IPv4,SOCK_STREAM:TCP)
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1)
    {
        perror("socket err");
        return -1;
    }

    // 初始化服务器地址结构体
    struct sockaddr_in saddr;
    memset(&saddr, 0, sizeof(saddr));
    saddr.sin_family = AF_INET;         // 地址族:IPv4
    saddr.sin_port = htons(6000);       // 绑定端口6000(主机序转网络序)
    saddr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 绑定本地回环地址

    // 将套接字与地址绑定
    int res = bind(sockfd, (struct sockaddr*)&saddr, sizeof(saddr));
    if (res == -1)
    {
        perror("bind err");
        return -1;
    }

    // 开启监听(队列长度5,最多同时等待5个未完成连接)
    if (listen(sockfd, 5) == -1)
    {
        perror("listen err");
        return -1;
    }

    return sockfd; // 返回监听套接字
}

/**************************************************************************
函数名:fds_init
函数功能:初始化文件描述符数组(将所有元素设为-1,表示未使用)
参数:fds[] 存储文件描述符的数组
**************************************************************************/
void fds_init(int fds[])
{
    for (int i = 0; i < MAXFD; i++)
    {
        fds[i] = -1; // -1表示该位置未占用
    }
}

/**************************************************************************
函数名:fds_add
函数功能:将文件描述符添加到数组中(管理监听的fd)
参数:fds[] 存储数组;fd 要添加的文件描述符
**************************************************************************/
void fds_add(int fds[], int fd)
{
    for (int i = 0; i < MAXFD; i++)
    {
        if (fds[i] == -1) // 找到第一个未占用的位置
        {
            fds[i] = fd;
            break;
        }
    }
}

/**************************************************************************
函数名:fds_del
函数功能:从数组中删除指定的文件描述符(释放资源)
参数:fds[] 存储数组;fd 要删除的文件描述符
**************************************************************************/
void fds_del(int fds[], int fd)
{
    for (int i = 0; i < MAXFD; i++)
    {
        if (fds[i] == fd) // 找到目标fd的位置
        {
            fds[i] = -1; // 设为-1表示释放
            break;
        }
    }
}

/**************************************************************************
主函数:基于select的IO复用服务器,监听多个客户端连接
**************************************************************************/
int main()
{
    // 初始化服务器,获取监听套接字
    int sockfd = socket_init();
    if (sockfd == -1)
    {
        exit(1);
    }

    // 初始化文件描述符数组,管理所有要监听的fd
    int fds[MAXFD];
    fds_init(fds);
    fds_add(fds, sockfd); // 将监听套接字加入数组

    fd_set fdset; // select的文件描述符集合

    while (1)
    {
        FD_ZERO(&fdset); // 清空select监听集合
        int maxfd = -1;  // 记录最大的文件描述符(select需要)

        // 遍历fd数组,将所有有效fd添加到select监听集合
        for (int i = 0; i < MAXFD; i++)
        {
            if (fds[i] == -1)
            {
                continue; // 跳过未占用的位置
            }
            FD_SET(fds[i], &fdset); // 将有效fd加入select集合
            if (fds[i] > maxfd)
            {
                maxfd = fds[i]; // 更新最大fd
            }
        }

        // 设置select超时时间:5秒
        struct timeval tv = {5, 0};
        // 调用select监听所有fd的读事件
        int n = select(maxfd + 1, &fdset, NULL, NULL, &tv);

        // 处理select返回结果
        if (n == -1)
        {
            perror("select err");
            continue;
        }
        else if (n == 0)
        {
            printf("select timeout\n");
            continue;
        }

        // 遍历fd数组,判断哪个fd有事件就绪
        for (int i = 0; i < MAXFD; i++)
        {
            if (fds[i] == -1)
            {
                continue;
            }
            // 判断当前fd是否在select就绪集合中
            if (FD_ISSET(fds[i], &fdset))
            {
                // 如果是监听套接字就绪,说明有新客户端连接
                if (fds[i] == sockfd)
                {
                    struct sockaddr_in caddr;
                    int len = sizeof(caddr);
                    // 接收新连接,得到客户端套接字
                    int c = accept(sockfd, (struct sockaddr*)&caddr, &len);
                    if (c > 0)
                    {
                        printf("new client connect: %d\n", c);
                        fds_add(fds, c); // 将新客户端fd加入数组
                    }
                }
                // 如果是客户端套接字就绪,说明有数据可读
                else
                {
                    char buff[128] = {0};
                    int num = read(fds[i], buff, 127);
                    if (num <= 0)
                    {
                        // 客户端断开连接,删除该fd
                        printf("client %d disconnect\n", fds[i]);
                        close(fds[i]);
                        fds_del(fds, fds[i]);
                    }
                    else
                    {
                        // 读取到客户端数据,打印并回显
                        printf("recv from %d: %s\n", fds[i], buff);
                        write(fds[i], buff, num);
                    }
                }
            }
        }
    }

    exit(0);
}

实现单线程同时监听多个客户端连接的功能(替代多线程 / 多进程),核心逻辑如下:

1. 核心模块说明
  • socket_init:TCP 服务器初始化(创建套接字 + 绑定 6000 端口 + 监听),是网络编程的基础流程。
  • fds_init/fds_add/fds_del:自定义的 fd 管理工具函数,用数组管理所有要监听的文件描述符(监听套接字 + 客户端套接字),避免 select 每次手动处理 fd。
  • main:select 的核心逻辑,实现 "单线程监听多个 fd" 的 IO 复用。
2. select 服务器的核心流程(main函数)
  1. 初始化服务器与 fd 数组:

    • 调用socket_init获取监听套接字sockfd;
    • 用fds_init初始化 fd 数组,fds_add将监听套接字加入数组。
  2. 循环监听逻辑:

    • 清空 select 集合 :每次循环调用FD_ZERO重置集合;
    • 添加有效 fd 到集合:遍历 fd 数组,将所有非 - 1 的 fd 加入 select 集合,并记录最大 fd(select 要求);
    • 调用 select:监听所有 fd 的读事件,超时时间 5 秒;
    • 处理 select 返回结果 :
      • -1:select 调用失败;
      • 0:超时;
      • >0:遍历 fd 数组,判断哪个 fd 就绪,分两种情况处理:
        • 监听套接字就绪 :有新客户端连接,调用accept接收连接,将新客户端 fd 加入数组;
        • 客户端套接字就绪:读取客户端数据,断开则删除 fd,否则打印并回显数据。
3. 编译与运行

bash

运行

复制代码
# 编译
gcc -o select_server select_server.c
# 运行
./select_server

可通过telnet 127.0.0.1 6000或网络调试工具连接服务器,测试多客户端同时通信。

4. 核心知识点(select 的固定套路)
  • fd 管理:用数组管理所有监听的 fd,避免 select 每次手动处理 fd 的繁琐。
  • select 参数 :第一个参数必须是最大fd+1,否则监听会失效。
  • 集合重置 :每次循环必须FD_ZERO+ 重新添加 fd,因为 select 会修改集合。

✅ 代码优势与局限性

  • 优势:单线程处理多连接,资源占用远低于多线程 / 多进程,适合中小规模并发(select 默认最大监听 1024 个 fd)。
  • 局限性 :select 的 fd 数量有上限(默认 1024)、每次调用需拷贝 fd 到内核、返回后需遍历所有 fd,高并发场景(万级连接)需用epoll替代。
相关推荐
陈希12320 分钟前
Linux 防火墙配置实战:firewalld、iptables 与云安全组
linux·运维·服务器
女神下凡1 小时前
芯参谋(95):嵌入式文件系统_开发指南
linux·服务器·arm开发·驱动开发·设计规范
且从容.4 小时前
Ubuntu安装flume
linux·ubuntu·flume
承渊政道5 小时前
Linux网络学习【Reactor反应堆模式】
linux·网络·学习·ubuntu·reactor
且从容.8 小时前
ubuntu安装flink
linux·ubuntu·flink
爱和冰阔落9 小时前
【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork
linux·运维·服务器·网络
程序员沉梦听雨9 小时前
WSL2的安装与使用
linux·wsl
微小的鱼10 小时前
Linux的渲染与显示系统链路
android·java·linux
lisanmengmeng10 小时前
alertmanger 标签 rules 打上分组标签
linux·运维·服务器
Android系统攻城狮11 小时前
Linux FFmpeg深度解析之av_write_frame调用流程与实战(三十三)
linux·运维·服务器·ffmpeg·音视频进阶·ffmpeg音视频