一、先铺垫【前置核心知识点】
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 种核心事件,也是唯一关心的事件:
- 读事件:fd 有数据可以读了 → 比如 客户端给服务器发 HTTP 请求了、浏览器请求图片了
- 写事件: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 三者的核心区别(通俗易懂版,必记,图片全部内容)
✅ 共同点(三者都一样)
- 都是「IO 多路复用」工具,都能一个线程监听多个 fd;
- 都是「阻塞等待」:调用后线程会卡着,直到有 fd 就绪(有事件),或者超时才返回;
- 都能监听「读事件、写事件」,满足我们服务器的所有需求。
✅ 核心不同点(效率 + 底层逻辑,图片重点,最常考,通俗讲)
✔️ 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 个线程搞定所有连接,解决高并发问题。
✔️ 核心对比
- 多线程:一个连接一个线程 → 简单,高并发崩;
- select:监听 fd 上限 1024 → 简单,效率低;
- poll:无 fd 上限 → 一般,效率一般;
- 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函数)
-
初始化服务器与 fd 数组:
- 调用
socket_init获取监听套接字sockfd; - 用
fds_init初始化 fd 数组,fds_add将监听套接字加入数组。
- 调用
-
循环监听逻辑:
- 清空 select 集合 :每次循环调用
FD_ZERO重置集合; - 添加有效 fd 到集合:遍历 fd 数组,将所有非 - 1 的 fd 加入 select 集合,并记录最大 fd(select 要求);
- 调用 select:监听所有 fd 的读事件,超时时间 5 秒;
- 处理 select 返回结果 :
-1:select 调用失败;0:超时;>0:遍历 fd 数组,判断哪个 fd 就绪,分两种情况处理:- 监听套接字就绪 :有新客户端连接,调用
accept接收连接,将新客户端 fd 加入数组; - 客户端套接字就绪:读取客户端数据,断开则删除 fd,否则打印并回显数据。
- 监听套接字就绪 :有新客户端连接,调用
- 清空 select 集合 :每次循环调用
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替代。