《Linux 核心 IO 模型深析(初篇):非阻塞 IO 的轮询机制与多路转接的高效实现》

**前引:**IO 是 Linux 系统性能的核心瓶颈之一,所有 IO 操作本质上都离不开 "等待" 与 "拷贝" 两个关键步骤。在五种经典 IO 模型中,非阻塞 IO 以 "轮询" 打破传统阻塞限制,多路转接 IO 凭 "多文件描述符监听" 实现高效等待,二者凭借独特的工作逻辑,成为高并发、低延迟场景的核心选择。本文将深入剖析两种模型的底层原理、工作流程、优劣势差异,以及实际开发中的落地要点,帮助开发者真正理解其设计思想并灵活运用!

目录

【一】IO介绍

【二】非阻塞IO模型

(1)函数接口介绍

(2)举例

(3)重点:fcntl()直接使用

【三】多路转接------select

(1)函数接口介绍

(1)第一个参数

(2)第二个参数(输入输出型)

(3)第三个参数(输入输出型)

(4)第四个参数(输入输出型)

(5)第五个参数(输入输出型)

(2)select使用说明

(3)多路转接使用教程

(1)基本结构

(2)判断listen套接字

(3)判断读端

(4)重新写入set

(5)完整代码

(4)整体思路讲解


【一】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_APPENDO_NONBLOCK(非阻塞)``O_ASYNC 等标志

第三个参数: 可选参数,当 cmd 需要额外参数时传入

返回值:

  • 成功:根据 cmd 不同返回不同结果
  • (非阻塞模式下:读写无数据时,立即返回 -1,且 errno 设为 EAGAINEWOULDBLOCK)
  • 失败:返回 -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中就绪的

相关推荐
鸠摩智首席音效师14 小时前
如何在 Ubuntu / Debian 上挂载 Amazon S3 Buckets ?
服务器·ubuntu·debian
感觉不怎么会14 小时前
ubuntu - 设备常见指令
linux·服务器·ubuntu
运维有小邓@1 天前
Active Directory服务账户是什么?
运维·服务器·网络
百万蹄蹄向前冲1 天前
2026云服务器从零 搭建与运维 指南
服务器·javascript·后端
HIT_Weston1 天前
84、【Ubuntu】【Hugo】搭建私人博客:文章目录(三)
linux·运维·ubuntu
moxiaoran57531 天前
使用docker安装myql 8.0
运维·docker·容器
qq_5470261791 天前
Linux 常用快捷键及文本编辑器
linux·运维·服务器
醇氧1 天前
【Linux】 安装 Azul Zulu JDK
java·linux·运维
一直跑1 天前
查看显卡驱动版本,查看哪个用户使用显卡(GPU)进程
linux·服务器