在这里插入图片描述

🔥草莓熊Lotso:个人主页

❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》

✨生活是默默的坚持,毅力是永久的享受!

🎬 博主简介:

在这里插入图片描述



前言:

做后端开发的朋友们应该都有一个共识:绝大多数服务的性能瓶颈,最终都会落到 IO 上。无论是网络请求的收发、磁盘文件的读写,还是数据库的交互,本质上都是在处理 IO 操作。很多人写代码时只会用默认的阻塞 IO,遇到高并发就只会无脑加线程,结果线程越开越多,上下文切换的开销反而拖垮了整个系统。其实问题的根源在于没有真正理解 IO 的本质,也不知道操作系统提供了哪些更高效的 IO 模型。本文将从最底层的 IO 本质讲起,用大家都能听懂的 “钓鱼” 类比,带你彻底搞懂 Linux 下的五种 IO 模型,最后通过实战代码演示非阻塞 IO 的实现。读完这篇文章,你不仅能清晰区分同步 / 异步、阻塞 / 非阻塞这些容易混淆的概念,还能理解为什么 IO 多路复用是高并发服务器的基石。

在这里插入图片描述


一. IO 的本质:等 + 拷贝

1.1 IO 是什么?

在讲具体的 IO 模型之前,我们必须先搞清楚一个最核心的问题:IO 到底是什么?

很多人会说 IO 就是输入输出,没错,但这只是表面。从操作系统的角度来看,任何一次 IO 操作,都可以拆分成两个不可分割的阶段:

  1. 等待:等待数据准备就绪(对于读操作,是等待网卡 / 磁盘把数据传到内核缓冲区;对于写操作,是等待内核发送缓冲区有空闲空间)
  2. 拷贝:将数据从内核缓冲区拷贝到用户空间(读),或者从用户空间拷贝到内核缓冲区(写)

也就是说,IO 的本质 = 等 + 拷贝

这是一个极其重要的结论,后面所有的 IO 模型,本质上都是在这两个阶段做文章。

在这里插入图片描述

1.2 为什么 IO 会慢?

知道了 IO 的本质,我们就能明白 IO 慢的根本原因:拷贝的速度是由硬件决定的,几乎无法优化;而等待的时间占比,才是决定 IO 效率的关键

尤其是网络 IO,数据需要经过千里之外的传输、路由器的转发,等待的时间往往是拷贝时间的成百上千倍。所以,高效 IO 的核心目标,就是在单位时间内,尽可能降低等待的比重

为了让大家更好地理解,我们用一个生活中最常见的例子 —— 钓鱼,来类比整个 IO 过程:

  • 钓鱼的人 = 应用进程
  • 鱼竿 = 文件描述符(fd)
  • 鱼漂 = 就绪事件(数据是否准备好)
  • 鱼 = 我们要读写的数据
  • 池塘 = 网络 / 磁盘

钓鱼的过程也分为两步:等鱼咬钩(等待),把鱼钓上来(拷贝)。钓鱼效率低,就是因为大部分时间都在等鱼咬钩;要提高钓鱼效率,就要想办法减少等待的时间占比。

在这里插入图片描述


二. 五种 IO 模型详解

基于上面的 “钓鱼” 类比,我们来逐一讲解 Linux 下的五种 IO 模型。这五种模型分别对应了五种不同的钓鱼方式,也代表了操作系统为我们提供的五种 IO 处理策略。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2.1 阻塞 IO(Blocking IO)

对应人物:张三 张三钓鱼的特点是:死盯着鱼漂,鱼漂不动我不动,鱼漂动了我才动。在鱼咬钩之前,他什么事都不干,就一直等着。

这就是最基础的阻塞 IO 模型,也是所有套接字默认的工作方式。

在这里插入图片描述

工作流程

  1. 应用进程调用recvfrom系统调用,向内核发起读请求
  2. 如果内核缓冲区中没有数据,进程会被挂起(阻塞),进入等待状态
  3. 当数据到达内核缓冲区后,内核将数据拷贝到用户空间
  4. 拷贝完成后,recvfrom返回成功,进程被唤醒,开始处理数据

阻塞 IO 的优点是简单、易理解,代码写起来最省事。但缺点也很明显:一个线程只能处理一个 IO 操作,在高并发场景下,会导致线程数量爆炸,系统性能急剧下降。

在这里插入图片描述

2.2 非阻塞 IO(Non-blocking IO)

对应人物:李四 李四钓鱼就聪明多了,他不会死盯着鱼漂。每隔一会儿就看一眼鱼漂,如果没动静,就去干别的事(玩手机、看书),过会儿再回来检查。看到鱼漂动了,就立刻把鱼钓上来。

这就是非阻塞 IO 模型

在这里插入图片描述
工作流程

  1. 应用进程调用recvfrom系统调用
  2. 如果内核缓冲区没有数据,系统调用不会阻塞,而是立即返回EWOULDBLOCK错误码
  3. 进程不会被挂起,可以去执行其他任务
  4. 进程需要轮询(反复调用recvfrom)检查数据是否就绪
  5. 当数据就绪后,内核将数据拷贝到用户空间,recvfrom返回成功

这里要特别注意一个误区:非阻塞 IO 并没有提高 IO 本身的效率,它只是改变了等待的方式。在钓鱼这件事上,李四和张三的效率是一样的,但是李四把等待的时间利用起来做了别的事,提高了整体的吞吐量。

非阻塞 IO 的缺点也很突出:轮询会大量消耗 CPU 资源。如果没有数据,进程会一直在空转,这在实际生产环境中是非常浪费的。所以非阻塞 IO 一般不会单独使用,而是配合 IO 多路复用一起工作。

在这里插入图片描述

2.3 信号驱动 IO(Signal-driven IO)

对应人物:王五 王五经验更老道,他在鱼竿顶部挂了一个铃铛。当鱼咬钩拉动鱼线时,铃铛就会响。王五不用一直盯着鱼漂,也不用每隔一会儿就检查,他可以放心地去干别的事,听到铃铛响再过来钓鱼就行。

这就是信号驱动 IO 模型

在这里插入图片描述

工作流程

  1. 应用进程提前注册SIGIO信号的处理函数
  2. 调用sigaction系统调用,告诉内核:当数据准备好时,给我发一个SIGIO信号
  3. 进程继续执行其他任务,不会被阻塞
  4. 当数据到达内核缓冲区后,内核向进程发送SIGIO信号
  5. 进程在信号处理函数中调用recvfrom,将数据从内核拷贝到用户空间
  6. 拷贝完成后,处理数据

信号驱动 IO 的优点是:在数据准备阶段,进程完全不需要等待,CPU 利用率很高。但它有一个致命的缺点:在大量并发连接的场景下,信号会被频繁触发,导致信号队列溢出,最终丢失信号。所以信号驱动 IO 在实际开发中用得很少。

在这里插入图片描述

2.4 IO 多路复用(IO Multiplexing)

对应人物:赵六 赵六是村里的钓鱼高手,他一次拉了一车鱼竿,把所有鱼竿都抛到水里。然后他来回巡视这些鱼竿,只要看到哪个鱼漂动了,就立刻过去把那条鱼钓上来。运气好的时候,他可以同时钓上来好几条鱼。

这就是IO 多路复用模型,也叫 IO 多路转接,是目前高并发服务器中使用最广泛的 IO 模型。

在这里插入图片描述

工作流程

  1. 应用进程调用select/poll/epoll等多路复用函数
  2. 内核会同时监视多个文件描述符(fd)的就绪状态
  3. 进程会阻塞在select调用上,直到有一个或多个 fd 就绪
  4. 当有 fd 就绪后,select返回,进程调用recvfrom将数据从内核拷贝到用户空间
  5. 处理数据,然后继续循环调用select

IO 多路复用的核心优势在于:一个线程可以同时处理成百上千个 IO 连接。它不需要为每个连接创建一个单独的线程,大大减少了线程创建和上下文切换的开销。这也是为什么 Nginx、Redis 等高性能中间件都采用 IO 多路复用模型的原因。

在这里插入图片描述
在这里插入图片描述

2.5 异步 IO(Asynchronous IO)

对应人物:田七 田七是村里的首富,他根本不想自己钓鱼。他找了个司机小王,对小王说:“我想吃鱼了,你帮我去钓,钓满一桶了给我打电话。” 然后田七就回公司开会去了,小王怎么钓鱼他完全不管,等小王打电话通知他,他直接过来拿鱼就行。

这就是异步 IO 模型,是五种模型中最彻底的非阻塞 IO。

在这里插入图片描述

工作流程

  1. 应用进程调用aio_read等异步 IO 系统调用
  2. 内核立即返回,进程继续执行其他任务
  3. 内核负责等待数据准备,并且自动将数据从内核缓冲区拷贝到用户指定的缓冲区
  4. 拷贝完成后,内核向进程发送信号,或者调用预先注册的回调函数
  5. 进程直接处理已经准备好的数据

异步 IO 和前面四种模型最大的区别在于:前面四种都是同步 IO,而异步 IO 是真正的非阻塞

在这里插入图片描述

2.6 同步 IO vs 异步 IO:核心区别

很多人容易把同步 / 异步和阻塞 / 非阻塞搞混,这里我们给出最清晰的定义:

  • 同步 IO:应用进程需要亲自参与 IO 的任何一个阶段(等待或拷贝)。也就是说,当数据准备好后,需要应用进程自己调用read/recv来完成数据拷贝。
  • 异步 IO:应用进程完全不参与 IO 的任何阶段。内核会自动完成等待和拷贝两个步骤,完成后通知应用进程直接使用数据。

根据这个定义,阻塞 IO、非阻塞 IO、信号驱动 IO、IO 多路复用都属于同步 IO。因为它们虽然在等待阶段的方式不同,但最终都需要应用进程自己来完成数据拷贝的工作。只有异步 IO,才是真正意义上的异步。

2.7 一张表搞懂五种 IO 模型

为了方便大家对比和记忆,我把五种 IO 模型的特点整理成了下面这张表:

IO 模型 等待方式 拷贝方式 同步 / 异步 并发能力 适用场景
阻塞 IO 进程阻塞等待 进程自己拷贝 同步 极低 简单的、并发量小的程序
非阻塞 IO 进程轮询检查 进程自己拷贝 同步 一般不单独使用
信号驱动 IO 信号通知 进程自己拷贝 同步 并发量不大的 UDP 程序
IO 多路复用 内核同时监视多个 fd 进程自己拷贝 同步 高并发服务器(Nginx、Redis)
异步 IO 内核等待 内核自动拷贝 异步 极高 对性能要求极高的场景(目前应用较少)

三. 非阻塞 IO 实战:基于 fcntl 实现

讲完了理论,我们来看一下如何在代码中实现非阻塞 IO。Linux 下,我们可以通过fcntl函数来修改文件描述符的属性,将其设置为非阻塞模式。

3.1 fcntl 函数简介

fcntl函数是 Linux 下用来操作文件描述符的万能函数,它的原型如下:

#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ );
  • fd:要操作的文件描述符
  • cmd:要执行的操作命令
  • arg:可选参数,根据不同的 cmd 传入不同的值

fcntl函数有很多功能,我们这里只用到其中一种:获取 / 设置文件状态标记,对应的 cmd 是F_GETFLF_SETFL

3.2 实现 SetNoBlock 函数

我们可以封装一个通用的SetNoBlock函数,用来将任意文件描述符设置为非阻塞模式:

#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>

void SetNoBlock(int fd) 
{
    // 获取当前文件描述符的状态标记
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0) 
    {
        perror("fcntl F_GETFL failed");
        return;
    }
    // 在原有标记的基础上,添加非阻塞标记
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}

代码解读:

  1. 首先调用fcntl(fd, F_GETFL)获取文件描述符当前的状态标记,这是一个位图
  2. 然后使用按位或操作,将O_NONBLOCK(非阻塞标记)添加到原有标记中
  3. 最后调用fcntl(fd, F_SETFL, fl)将新的标记设置回去

3.3 轮询读取标准输入示例

下面我们用非阻塞 IO 来实现一个轮询读取标准输入的程序:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>

void SetNoBlock(int fd) 
{
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0) 
    {
        perror("fcntl F_GETFL failed");
        return;
    }
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}

int main() 
{
    // 将标准输入(fd=0)设置为非阻塞模式
    SetNoBlock(0);
    printf("请输入内容,程序会非阻塞读取:\n");

    while (1) 
    {
        char buf[1024] = {0};
        ssize_t read_size = read(0, buf, sizeof(buf) - 1);
        
        if (read_size > 0) 
        {
            // 正常读取到数据
            printf("读取成功:%s\n", buf);
        } 
        else if (read_size == 0) 
        {
            // 对端关闭(标准输入一般不会出现)
            printf("输入结束\n");
            break;
        } 
        else 
        {
            // 出错,需要区分是真错误还是数据未就绪
            if (errno == EWOULDBLOCK || errno == EAGAIN) 
            {
                // 数据未就绪,这是正常情况
                printf("当前没有数据,1秒后重试...\n");
                sleep(1);
                continue;
            } 
            else 
            {
                // 真的发生了错误
                perror("read failed");
                break;
            }
        }
    }

    return 0;
}

代码关键点解读

  1. 非阻塞设置SetNoBlock(0)将标准输入设置为非阻塞模式,这个设置是永久有效的,不需要每次调用 read 都设置
  2. read 返回值处理
    1. read_size > 0:正常读取到数据
    2. read_size == 0:表示对端已经关闭连接(对于标准输入来说,就是用户按下了 Ctrl+D)
    3. read_size < 0:需要进一步判断errno的值
      • 如果errnoEWOULDBLOCKEAGAIN:表示当前没有数据就绪,这是非阻塞 IO 下的正常情况,不是错误
      • 其他值:表示真的发生了 IO 错误

运行效果

编译运行这个程序,你会看到:

  • 当你没有输入任何内容时,程序会每隔 1 秒打印一次 “当前没有数据,1 秒后重试…”
  • 当你输入内容并按下回车后,程序会立即打印 “读取成功:xxx”

这就是非阻塞 IO 的典型表现:调用 read 不会阻塞,没有数据就立即返回,有数据就正常读取。

在这里插入图片描述
在这里插入图片描述

3.4 代码实战演示(更加详细的解读)

// 非阻塞 I/O 读取测试程序
// 功能:演示如何以非阻塞方式从标准输入读取数据
// 特点:当数据未就绪时不会阻塞,而是执行其他任务后继续轮询

#include <cerrno>
#include <cstdio>
#include <unistd.h>
#include <iostream>
#include <fcntl.h>

// ============================================================
// 函数功能:将指定文件描述符设置为非阻塞模式
// 参数:fd - 要设置为非阻塞的文件描述符
// 说明:通过 fcntl 获取并修改文件状态标志,添加 O_NONBLOCK
// ============================================================
void SetNoBlock(int fd)
{
    // 获取当前文件状态标志
    int flag = fcntl(fd, F_GETFL);
    if(flag < 0)
    {
        perror("fcntl");
        return;
    }
    // 设置非阻塞标志(保留原有标志,添加 O_NONBLOCK)
    fcntl(fd, F_SETFL, flag | O_NONBLOCK);
}

// ============================================================
// 函数功能:执行"其他任务"(模拟在数据未就绪时做其他工作)
// 说明:在实际应用中,这里可以处理其他 I/O 或业务逻辑
// ============================================================
void doOtherThing()
{
    std::cout << "0 not ready, do other thing!" << std::endl;
}

// ============================================================
// 主函数:非阻塞读取标准输入
// 流程:设置非阻塞 -> 循环读取 -> 处理各种返回值情况
// ============================================================
int main()
{
    // 定义输入缓冲区,大小为 128 字节
    char inbuffer[128];
    
    // 将标准输入(fd=0)设置为非阻塞模式
    SetNoBlock(0);
    
    // 持续轮询直到遇到 EOF 或发生错误
    while(true)
    {
        // 将缓冲区第一个字节设为 0(常量时间初始化)
        // 即使 read 失败,缓冲区也有确定状态
        inbuffer[0] = 0;
        
        // 尝试非阻塞读取,最多读取 sizeof(inbuffer) - 1 字节
        // 预留 1 字节空间用于 null 终止符
        ssize_t n = read(0, inbuffer, sizeof(inbuffer) - 1);
        
        // -----------------------
        // 情况1:成功读取到数据
        // -----------------------
        if(n > 0)
        {
            // 添加字符串终止符,确保可以安全打印
            inbuffer[n] = 0;
            std::cout << "inbuffer: " << inbuffer << std::endl;
        }
        // -----------------------
        // 情况2:读到 EOF(对方关闭了写端)
        // -----------------------
        else if(n == 0)
        {
            std::cout << "enter done, break" << std::endl;
            break;  // 正常退出循环
        }
        // -----------------------
        // 情况3:read 返回 -1(出错)
        // -----------------------
        else
        {
            // 1. 非阻塞模式下,只能以出错形式返回
            // 2. 底层没有数据,read返回,算不算出错?不算!!
            // 3. 如果返回值<0,你下来关系什么??因为什么原因出错的??错误码更详细的标识出错原因!
            // 4. errno == 11, EAGAIN || EWOULDBLOCK 不是真正的出错,只是表明fd没有就绪!
            
            // --- 假错误:数据未就绪 ---
            if(errno == EAGAIN || errno == EWOULDBLOCK)
            {
                // 执行其他任务,避免空轮询浪费 CPU
                doOtherThing();
                sleep(1);  // 暂停 1 秒再试(降低 CPU 占用)
                
                std::cout << "read 0, 0 fd data not ready!" << std::endl;
                continue;  // 继续下一次轮询
            }
            // --- 信号中断:被信号打断 ---
            else if(errno == EINTR)
            {
                // 系统调用被信号中断,可重试
                continue;
            }
            // --- 真错误:其他未知错误 ---
            else 
            {
                std::cout << "read error: n=" << n << ", errno=" << errno << std::endl;
                break;  // 发生未知错误,退出循环
            }
        }
        // 每次成功读取后暂停 1 秒
        sleep(1);
    }
    return 0;
}

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述


四. 其他 IO 模型代码示例(了解即可,想看就看看)

为了让大家对其他 IO 模型也有一个直观的认识,这里再给出两个简单的代码示例。

4.1 信号驱动 IO 示例

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>

int fd;

// SIGIO信号处理函数
void io_handler(int sig) 
{
    char buf[100];
    int n = read(fd, buf, sizeof(buf) - 1);
    if (n > 0) 
    {
        buf[n] = '\0';
        printf("收到输入:%s", buf);
    }
}

int main() 
{
    fd = STDIN_FILENO;
    // 注册SIGIO信号的处理函数
    signal(SIGIO, io_handler);
    // 设置信号的接收进程为当前进程
    fcntl(fd, F_SETOWN, getpid());
    // 开启文件描述符的异步IO模式
    fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_ASYNC);

    printf("请输入内容(程序会在后台等待信号)\n");
    // 主进程循环等待信号
    while (1) 
    {
        pause();
    }

    return 0;
}

4.2 异步 IO(POSIX AIO)示例

注意:编译时需要链接实时库librt,即gcc demo.c -o demo -lrt

#include <aio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <errno.h>

int main() 
{
    char buf[1024] = {0};
    // 初始化异步IO控制块
    struct aiocb cb = 
    {
        .aio_fildes = STDIN_FILENO,
        .aio_buf = buf,
        .aio_nbytes = sizeof(buf)
    };

    // 发起异步读取请求
    aio_read(&cb);
    printf("异步读取请求已发起,程序继续执行...\n");

    // 轮询等待读取完成(实际项目中一般用信号或回调)
    while (aio_error(&cb) == EINPROGRESS) 
    {
        printf("正在读取数据...\n");
        sleep(1);
    }

    // 获取读取结果
    ssize_t read_size = aio_return(&cb);
    if (read_size > 0) 
    {
        printf("读取成功:%s\n", buf);
    } 
    else 
    {
        perror("aio_read failed");
    }

    return 0;
}

本文核心要点总结:

  1. IO 的本质是等 + 拷贝,高效 IO 的核心是降低等待的比重
  2. 五种 IO 模型中,IO 多路复用是目前高并发场景下的最优解,也是我们需要重点掌握的
  3. 前四种都是同步 IO,因为都需要应用进程自己完成数据拷贝;只有异步 IO 是真正的非阻塞
  4. 非阻塞 IO 一般不单独使用,而是配合 IO 多路复用一起工作
  5. 注意区分同步 / 异步和阻塞 / 非阻塞:同步异步关注的是消息通信机制,阻塞非阻塞关注的是程序等待时的状态

结尾:

🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!

结语:本文从 IO 的本质出发,用 “钓鱼” 的类比详细讲解了 Linux 下的五种 IO 模型,并通过实战代码演示了非阻塞 IO 的实现。随着技术的发展,现在协程(C++20 coroutine)逐渐成为了 IO 编程的新趋势。协程在保留同步 IO 代码简洁性的同时,又能达到异步 IO 的性能,是未来 IO 编程的发展方向。但无论技术怎么发展,IO 的本质永远不会变,理解了这五种 IO 模型,你就能更好地理解和使用这些新技术。下一篇文章,我会带大家深入讲解 IO 多路复用的三种实现:select、poll 和 epoll,分析它们的优缺点和底层原理。如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、关注,我们下期再见!

✨把这些内容吃透超牛的!放松下吧✨
ʕ˘ᴥ˘ʔ
づきらど

在这里插入图片描述

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐