Linux 核心 IO 模型深析:非阻塞 IO 与多路转接实现
Linux IO 操作本质为等待与拷贝。非阻塞 IO 通过 fcntl 设置 O_NONBLOCK 标志,使读写在无数据时立即返回而非挂起,适合轮询场景。多路转接 IO 利用 select 函数同时监控多个文件描述符的读、写及异常状态,通过 fd_set 集合管理,配合超时机制实现高效等待。两者均用于提升高并发下的 IO 效率,但 select 在连接数较多时存在性能瓶颈。

Linux IO 操作本质为等待与拷贝。非阻塞 IO 通过 fcntl 设置 O_NONBLOCK 标志,使读写在无数据时立即返回而非挂起,适合轮询场景。多路转接 IO 利用 select 函数同时监控多个文件描述符的读、写及异常状态,通过 fd_set 集合管理,配合超时机制实现高效等待。两者均用于提升高并发下的 IO 效率,但 select 在连接数较多时存在性能瓶颈。

IO 是 Linux 系统性能的核心瓶颈之一,所有 IO 操作本质上都离不开'等待'与'拷贝'两个关键步骤。在五种经典 IO 模型中,非阻塞 IO 以'轮询'打破传统阻塞限制,多路转接 IO 凭'多文件描述符监听'实现高效等待,二者凭借独特的工作逻辑,成为高并发、低延迟场景的核心选择。
现在我们知道所谓的输入和输出都是从下层的接收和发送缓冲区里面拿,数据的获取交给底层协议的通信,而这些读写接口如果没有拿到数据就会是——等待;如果有数据就需要——拷贝。
因此 IO 本质是:等待 + 拷贝的过程,根据数据的情况从传输层拷贝到应用层!如果想优化效率,大多都是合理运用等待的时长——非阻塞。
**特点:**一次操控一个文件描述符。
原型:
#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 不同返回不同结果。errno 设为 EAGAIN 或 EWOULDBLOCK)。errno。作用:对传入的文件描述符执行多种操作(查询 / 设置该文件描述符对应文件的属性)。
现在我们的客户端是可以正常给服务端发送请求:服务端阻塞式的读。
现在我们调用 fcntl() 形成非阻塞式的读:对应读写不用一直阻塞也可以有返回值。
// 读取客户端内容
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 一直是阻塞的,读到数据才会有返回值。
非阻塞使用顺序:先查原状态,再添加非阻塞状态,再更新文件描述符属性。
为什么要
|=?因为会去除原来的文件属性,|=为继续添加新属性。
// 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);
}
**特点:**一次操控多个文件描述符,且支持读、写、监听状态。
原型:
#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); // 等待超时时间
返回值:
FD_ISSET() 逐个查具体的文件描述符,例如:// 监听 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 */
}
}
errno 分类调查具体的错误原因。第一个参数
nfds = 6+1。第二个参数(输入输出型)
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 从集合里移除 | 取完奶茶,把号从列表移除 |
第三个参数(输入输出型)
第四个参数(输入输出型)
第五个参数(输入输出型)
struct timeval 类型的结构体变量,例如:struct timeval {
long tv_sec; // 秒
long tv_usec; // 微秒(1 秒=1000000 微秒)
};
timeout = NULL:一直等,直到有 fd 有事件(死等,类似阻塞模式)。timeout = {0, 0}:不等待,直接返回当前有事件的 fd(非阻塞模式)。timeout = {5, 0}:最多等 5 秒,没事件就返回 0。(1)等待时间注意
select() 的第三个参数表示最多等待多长时间,因此:一但有客户端连接,就会直接跳过等待。
如果没有客户端连接,每次调用 select() 需要重新设置时间,因为该参数是输入输出特点。
(2)输入输出参数
准确来说除了第一个参数,其它都是输入输出参数,即每次调用需要重新设置。
既然 select() 属于输入输出结构的,所以需要在循环里面去重新设置。
它可以代替 accept() 去处理等的时间,当有新链接到来再交给 accept() 处理。
_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;
break;
}
case -1: {
std::cout << "select 调用失败" << std::endl;
break;
}
default: {
// 处理新链接
Handle(set);
}
}
}
此时说明有新链接到来,先判断是不是那个 listen 套接字,再执行 accept() 处理新链接请求:
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
那么执行步骤:将 accept() 返回的加入辅助数组下标为 -1 的位置。
// 找到添加的位置
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;
}
此时如果不是 listen 套接字,那么就只能是读端了。
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;
}
}
select 的第一个参数需要保证是最大的,所以需要在辅助数组找最大值。
再把辅助数组中不是 -1 的重新添加到 fd_set 对应变量中。
数组说明:
// 定义辅助数组
int _arry[max_num_arry] = {-1}; // (max_num_arry==sizeof(fd_set)*8)
如果 set 里面有准备好的文件描述符:
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;
}
}
}
}
监听指定的文件描述符:
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);
}
}
}
}
首先由于 select 每次调用都会对描述符集全部清零,所以我们需要准备一个辅助数组:
那么对 select 描述符集的管理就变成了对辅助数组的增删管理!
如果 select 中有描述符就绪了,那么需要对就绪中的文件描述符进行判断:
accept 返回的文件描述符重新添加到数组。注意:此时需要遍历辅助数组进行判断,因为数组中的描述符集就代表
select中就绪的。
非阻塞 IO 通过 fcntl 设置标志位,避免了进程在等待 IO 时的挂起,适合少量连接的高频轮询场景。而多路转接 IO 利用 select 机制,允许单个线程同时监控多个文件描述符的状态,显著减少了上下文切换和线程开销,适用于中等规模的高并发服务。开发者应根据具体业务场景的连接数量和 IO 密集程度选择合适的模型。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online