

**前引:**IO 是 Linux 系统性能的核心瓶颈之一,所有 IO 操作本质上都离不开 "等待" 与 "拷贝" 两个关键步骤。在五种经典 IO 模型中,非阻塞 IO 以 "轮询" 打破传统阻塞限制,多路转接 IO 凭 "多文件描述符监听" 实现高效等待,二者凭借独特的工作逻辑,成为高并发、低延迟场景的核心选择。本文将深入剖析两种模型的底层原理、工作流程、优劣势差异,以及实际开发中的落地要点,帮助开发者真正理解其设计思想并灵活运用!
目录
【一】IO介绍
现在我们知道所谓的输入和输出都是从下层的接收和发送缓冲区里面拿,数据的获取交给底层协议的通信,而这些读写接口如果没有拿到数据就会是------等待;如果有数据就需要------拷贝
因此IO本质是:等待+拷贝的过程,根据数据的情况从传输层拷贝到应用层!如果想优化效率,大多都是合理运用等待的时长------非阻塞
【二】非阻塞IO模型
**特点:**一次操控一个文件描述符
(1)函数接口介绍
原型:
cpp
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */);
参数:
**第一个参数:**要操作的文件描述符
**第二个参数:**操作命令,告诉 fcntl()要做什么
| 命令 | 作用 | arg 说明 |
|---|---|---|
F_GETFL |
获取 fd 当前的状态 |
无需 arg(第三个参数省略),返回值为当前状态标志(整数) |
F_SETFL |
设置 fd 的当前的属性 |
arg 传入新的状态标志(整数),仅能修改 O_APPEND、O_NONBLOCK(非阻塞)``O_ASYNC 等标志 |
第三个参数: 可选参数,当 cmd 需要额外参数时传入
返回值:
- 成功:根据
cmd不同返回不同结果 - (非阻塞模式下:读写无数据时,立即返回 -1,且
errno设为EAGAIN或EWOULDBLOCK) - 失败:返回 -1,并设置
errno
作用:对传入的文件描述符执行多种操作(查询 / 设置该文件描述符对应文件的属性)
(2)举例
现在我们的客户端是可以正常给服务端发送请求:服务端阻塞式的读

现在我们调用fcntl()形成非阻塞式的读:对应读写不用一直阻塞也可以有返回值
cpp
//读取客户端内容
void Recv(const int& new_token)
{
//1:查看当前状态
int fc =fcntl(new_token,F_GETFL);
if(fc==-1)
{
std::cout << "fcntl获取标志失败" << std::endl;
}
//2:继续添加非阻塞(关键)
fc |= O_NONBLOCK;
//3:写入新状态
if (fcntl(new_token, F_SETFL, fc) == -1)
{
perror("fcntl F_SETFL error");
exit(0);
}
char buffer[max_buffer]={0};
while(1)
{
ssize_t t =recv(new_token, buffer, sizeof(buffer)-1, 0);
if(t>0)
{
std::cout<<"客户端发送:"<<buffer<<std::endl;
}
else if(t==0)
{
std::cout<<"对方关闭了连接"<<std::endl;
break;
}
else if(t==-1)
{
if(errno == EAGAIN || errno == EWOULDBLOCK)
{
printf("非阻塞模式:当前无输入,立即返回\n");
sleep(1);
}
else
{
std::cout<<"服务端读取错误"<<std::endl;
break;
}
}
}
}

如果不设置fcntl(),我们看看是什么效果:recv一直是阻塞的,读到数据才会有返回值
cpp
//1:查看当前状态
int fc =fcntl(new_token,F_GETFL);
if(fc==-1)
{
std::cout << "fcntl获取标志失败" << std::endl;
}
//2:继续添加非阻塞(关键)
fc |= O_NONBLOCK;
//3:写入新状态
if (fcntl(new_token, F_SETFL, fc) == -1)
{
perror("fcntl F_SETFL error");
exit(0);
}

(3)重点:fcntl()直接使用
非阻塞使用顺序:先查原状态,再添加非阻塞状态,再更新文件描述符属性
为什么要 |= ?因为会去除原来的文件属性,|= 为继续添加新属性
cpp
//new_token是操作的文件描述符
//1:查看当前状态
int fc =fcntl(new_token,F_GETFL);
if(fc==-1)
{
std::cout << "fcntl获取标志失败" << std::endl;
}
//2:继续添加非阻塞(关键)
fc |= O_NONBLOCK;
//3:写入新状态
if (fcntl(new_token, F_SETFL, fc) == -1)
{
perror("fcntl F_SETFL error");
exit(0);
}
【三】多路转接------select
**特点:**一次操控多个文件描述,且支持读、写、监听状态
(1)函数接口介绍
原型:
cpp
#include <sys/select.h>
// 返回值:有事件的fd数量;失败返回-1;超时返回0
int select(
int nfds, // 要监听的最大fd + 1
fd_set *readfds, // 要监听"可读事件"的fd集合
fd_set *writefds, // 要监听"可写事件"的fd集合
fd_set *exceptfds, // 要监听"异常事件"的fd集合
struct timeval *timeout // 等待超时时间
);
返回值:
>0 返回的数字是 "触发事件的 fd 总数",需要用 FD_ISSET() 逐个查具体的文件描述符,例如:
cpp
// 监听fd0(标准输入)和fd5(客户端)
int ret = select(6, &read_fds, NULL, NULL, &tv);
if (ret > 0) {
// 逐个检查哪个fd有事件
if (FD_ISSET(0, &read_fds)) { /* 处理fd0 */ }
if (FD_ISSET(5, &read_fds)) { /* 处理fd5 */ }
}
=0 表明没有事件就绪
=-1 表明调用错误,可通过errno分类调查具体的错误原因
参数:
(1)第一个参数
理解:告诉它最大要查到哪个编号,比如3,4,5,6,最大要查到6,即 nfds = 6+1
填写:最大文件描述符+1
(2)第二个参数(输入输出型)
理解:告诉它要帮你管理哪些文件描述符的读状态,如果列表中的文件读状态就绪了,就告诉我
填写:需要一个 fd_set 类型的变量,比如 fd_set set;
| 函数 | 作用 | 类比操作 |
|---|---|---|
FD_ZERO(&set) |
清空集合**(必要)** | 把取餐号列表清空 |
FD_SET(fd, &set) |
把 fd 加入集合 | 把取餐号 "3" 加到待查列表 |
FD_ISSET(fd, &set) |
检查 fd 是否在集合里 | 看取餐号 "3" 是不是在响的列表里 |
FD_CLR(fd, &set) |
把 fd 从集合里移除 | 取完奶茶,把号从列表移除 |
(3)第三个参数(输入输出型)
理解:告诉它要帮你管理哪些文件描述符的写状态,如果列表中的文件写状态就绪了,就告诉我
填写:和(第二个参数)一样的变量类型)
(4)第四个参数(输入输出型)
理解:告诉它要帮你管理哪些文件描述符的监听状态,如果列表中的文件有情况,就告诉我
填写:和(第二个参数)一样的变量类型)
(5)第五个参数(输入输出型)
理解:设置等待方式,比如每隔5秒检测一次,每隔1秒检测一次。(每次需要重复设置)
填写:需要 struct timeval 类型的结构体变量,例如:
cpp
struct timeval
{
long tv_sec; // 秒
long tv_usec; // 微秒(1秒=1000000微秒)
};
timeout = NULL:一直等,直到有 fd 有事件(死等,类似阻塞模式)timeout = {0, 0}:不等待,直接返回当前有事件的 fd(非阻塞模式)timeout = {5, 0}:最多等 5 秒,没事件就返回 0
(2)select使用说明
(1)等待时间注意
select()的第三个参数表示最多等待多长时间,因此:一但有客户端连接,就会直接跳过等待
如果没有客户端连接,每次调用select()需要重新设置时间,因为该参数是输入输出特点
例如:每隔两秒检测一次,有客户端出现就会一直打印(这里就不例举了!)

(2)输入输出参数
准确来说除了第一个参数,其它都是输入输出参数,即每次调用需要重新设置
(3)多路转接使用教程
(1)基本结构
既然select()属于输入输出结构的,所以需要在循环里面去重新设置
它可以代替accept()去处理等的时间,当有新链接到来再交给accept()处理
cpp
_V.Socket();
//绑定
_V.Bind();
//发起连接
_V.Listen();
//创建套接字
int socket=_V.Fd();
for(;;)
{
fd_set set;
//清空
FD_ZERO(&set);
//添加文件描述符
FD_SET(socket,&set);
//设置时间
struct timeval tim={2,0};
int sel =select(socket+1,&set,nullptr,nullptr,&tim);
//判断事件
switch (sel)
{
case 0:
{
std::cout<<"没有客户端访问我...."<<std::endl;
}
case -1:
{
std::cout<<"select调用失败"<<std::endl;
break;
}
default:
{
//处理新链接
Handle(set);
}
}
}
(2)判断listen套接字
此时说明有新链接到来,先判断是不是那个listen套接字,再执行accept()处理新链接请求:
cpp
void Handle(fd_set set)
{
//如果该listen监听的套接字准备就绪了
if(_arry[i] == _V.Fd() && FD_ISSET(_V.Fd(), &set))
{
//可以直接accept不会阻塞
struct sockaddr_in addr;
memset(&addr,0,sizeof(addr));
socklen_t sz = sizeof(addr);
int new_token=accept(_V.Fd(),(sockaddr*)&addr,&sz);
}
}
但是accept()之后就说明有数据了吗?我们还需要添加到 set 里面让select()管理,但出现一个问题:如果现在添加,那么下次调用select()会被重置,所以我们需要辅助数组!数组的大小我们设置为 fd_set 的大小:这里需要讲解一下 fd_set 的大小:所以需要*8
select()的读写监听是操作的位图,例如0000 0000监控1号------>0000 0001
再监控2号------>0000 0021
cpp//定义辅助数组 int _arry[max_num_arry]={-1}; //辅助数组大小 static const int max_num_arry= (sizeof(fd_set)*8); //全部初始化为-1 std::fill(_arry,_arry+max_num_arry,-1);
那么执行步骤:将accept()返回的加入辅助数组下标为-1的位置
cpp
// 找到添加的位置
int j = 1;
for (j; j < max_num_arry; j++)
{
if (_arry[j] != -1)
continue;
else
break;
}
// 如果数组满了
if (j == max_num_arry)
{
std::cout << "满了,执行错误" << std::endl;
close(new_token);
}
else
{
// 添加到数组
std::cout << "成功添加到数组" << std::endl;
_arry[j] = new_token;
}
(3)判断读端
此时如果不是listen套接字,那么就只能是读端了
cpp
else if (FD_ISSET(_arry[i], &set)) // 就绪了才能读取
{
std::cout << "recv" << std::endl;
sleep(1);
char buffer[1024] = {0};
ssize_t d = recv(_arry[i], buffer, sizeof(buffer) - 1, 0);
if (d > 0)
{
buffer[d] = 0;
std::cout << "客户端发送了数据 : ";
std::cout << buffer << std::endl;
}else if (d == 0)
{
// 对方断开了连接
close(_arry[i]);
_arry[i] = -1;
// 关闭当前的文件描述符,并且从数组中删掉
}else{
// 读取错误
close(_arry[i]);
_arry[i] = -1;
}
}
(4)重新写入set
select的第一个参数需要保证是最大的,所以需要在辅助数组找最大值
再把辅助数组中不是-1的重新添加到fd_set对应变量中
(5)完整代码
数组说明:
cpp
// 定义辅助数组
int _arry[max_num_arry] = {-1};
//(max_num_arry==sizeof(fd_set)*8)
如果set里面有准备好的文件描述符:
cpp
void Handle(fd_set set)
{
// 此时我们只关注了读,如果不是listen套接字说明是读端就绪了
for (int i = 0; i < max_num_arry; i++)
{
if (_arry[i] == -1)
continue;
std::cout << "i==" << i << " " << "_arry[i]==" << _arry[i] << std::endl;
// 如果是listen套接字,说明需要将accept返回的文件描述符再次添加到读端
if (_arry[i] == _V.Fd() && FD_ISSET(_V.Fd(), &set))
{
// 可以直接accept不会阻塞
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
socklen_t sz = sizeof(addr);
int new_token = accept(_V.Fd(), (sockaddr *)&addr, &sz);
// 找到添加的位置
int j = 1;
for (j; j < max_num_arry; j++)
{
if (_arry[j] != -1)
continue;
else
break;
}
// 如果数组满了
if (j == max_num_arry)
{
std::cout << "满了,执行错误" << std::endl;
close(new_token);
}
else
{
// 添加到数组
std::cout << "成功添加到数组" << std::endl;
_arry[j] = new_token;
}
}
else if (FD_ISSET(_arry[i], &set)) // 就绪了才能读取
{
std::cout << "recv" << std::endl;
sleep(1);
char buffer[1024] = {0};
ssize_t d = recv(_arry[i], buffer, sizeof(buffer) - 1, 0);
if (d > 0)
{
buffer[d] = 0;
std::cout << "客户端发送了数据 : ";
std::cout << buffer << std::endl;
}else if (d == 0)
{
// 对方断开了连接
close(_arry[i]);
_arry[i] = -1;
// 关闭当前的文件描述符,并且从数组中删掉
}else{
// 读取错误
close(_arry[i]);
_arry[i] = -1;
}
}
}
}
监听指定的文件描述符:
cpp
void Deal()
{
std::fill(_arry, _arry + max_num_arry, -1);
// 创建套接字
int socket = _V.Fd();
_arry[0] = socket;
// 借助辅助数组重新设置
int max = _arry[0];
for (;;)
{
fd_set set;
// 清空
FD_ZERO(&set);
// 设置时间
struct timeval tim = {2, 0};
for (int i = 0; i < max_num_arry; i++)
{
std::cout << _arry[i] << " ";
if (_arry[i] == -1)
continue;
// 说明存在文件描述
// 添加到set里面
FD_SET(_arry[i], &set);
// 如果出现比max更大的文件描述符
if (_arry[i] > max)
max = _arry[i];
}
std::cout << std::endl;
int sel = select(max + 1, &set, nullptr, nullptr, &tim);
// 判断事件
switch (sel)
{
case 0:
{
std::cout << "没有客户端访问我...." << std::endl;
continue;
}
case -1:
{
std::cout << "select调用失败" << std::endl;
perror("failed to select call back");
break;
}
default:
{
// 处理新链接
Handle(set);
}
}
}
}
(4)整体思路讲解
首先由于select每次调用都会对描述符集全部清零,所以我们需要准备一个辅助数组:
那么对select描述符集的管理就变成了对辅助数组的增删管理!
如果select中有描述符就绪了,那么需要对就绪中的文件描述符进行判断:
(1)如果就绪中的是listen套接字的,说明需要将accept返回的文件描述符重新添加到数组
(2)如果只有读端,那么剩余就绪中的是读端描述符了,代表有数据读取
注意:此时需要遍历辅助数组进行判断,因为数组中的描述符集就代表select中就绪的

