《Linux 网络编程》深入理解五种 IO 模型:从 IO 本质到非阻塞 IO 实战

🔥小叶-duck:个人主页

❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》

《Linux系统从入门到实践》《Linux网络从入门到实践》

《Qt 方寸极境》 《MySQL》

✨未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游


目录

前言

[一、IO 的本质:等待 + 拷贝](#一、IO 的本质:等待 + 拷贝)

[1.1 IO 是什么](#1.1 IO 是什么)

[1.2 为什么 IO 比较慢](#1.2 为什么 IO 比较慢)

[1.3 如何理解高效 IO](#1.3 如何理解高效 IO)

[二、五种 IO 模型详解](#二、五种 IO 模型详解)

[2.1 钓鱼故事:五位人物对应五种 IO 模型](#2.1 钓鱼故事:五位人物对应五种 IO 模型)

[2.1.1 阻塞 IO(Blocking IO)](#2.1.1 阻塞 IO(Blocking IO))

[2.1.2 非阻塞 IO(Non-blocking IO)](#2.1.2 非阻塞 IO(Non-blocking IO))

[2.1.3 信号驱动 IO(Signal-driven IO)](#2.1.3 信号驱动 IO(Signal-driven IO))

[2.1.4 IO 多路复用(IO Multiplexing)](#2.1.4 IO 多路复用(IO Multiplexing))

[2.1.5 异步 IO(Asynchronous IO)](#2.1.5 异步 IO(Asynchronous IO))

[2.2 同步 IO vs 异步 IO](#2.2 同步 IO vs 异步 IO)

[2.3 五种 IO 模型图表总结](#2.3 五种 IO 模型图表总结)

[三、非阻塞 IO](#三、非阻塞 IO)

[3.1 fcntl 系统调用](#3.1 fcntl 系统调用)

[3.1.1 函数原型](#3.1.1 函数原型)

[3.1.2 fcntl 五大核心功能](#3.1.2 fcntl 五大核心功能)

[3.2 非阻塞 IO 实战:基于 fcntl 实现](#3.2 非阻塞 IO 实战:基于 fcntl 实现)

[3.2.1 封装实现 SetNoBlock 函数](#3.2.1 封装实现 SetNoBlock 函数)

[3.2.2 read 函数返回值详解](#3.2.2 read 函数返回值详解)

[3.2.3 非阻塞 IO 实战:轮询读取标准输入](#3.2.3 非阻塞 IO 实战:轮询读取标准输入)

结束语


前言

在 Linux 网络编程开发中,IO 模型是支撑高并发服务的底层基石,也是面试高频考点。很多同学在学习 socket 编程时,只会调用接口,却不清楚不同 IO 模型背后的等待与拷贝逻辑,难以理解高并发方案选型的底层原因。

本文会从 IO 的本质入手,先拆解 IO 的两个核心阶段:等待数据就绪、内核数据拷贝到用户空间,解释 IO 性能瓶颈的来源,以及高效 IO 的设计思路。随后借助钓鱼的通俗案例,逐一讲解阻塞 IO、非阻塞 IO、信号驱动 IO、IO 多路复用、异步 IO 这五种经典 IO 模型,区分同步与异步 IO 的核心差异。

最后落地到代码实战,讲解fcntl系统调用,动手封装非阻塞工具函数,结合标准输入的轮询案例,完整演示非阻塞 IO 的编码写法与返回值处理。通过原理 + 案例 + 代码的方式,帮你吃透 Linux 下各类 IO 模型的底层逻辑。

一、IO 的本质:等待 + 拷贝

1.1 IO 是什么

在系统与网络编程场景中,IO 就是 Input 输入、Output 输出。我们平时写代码调用read、recv、write、send这类 IO 接口时,大部分耗时其实都消耗在等待数据的阶段。

从操作系统视角来看,一次完整的 IO 操作,可以拆成两个不可分割的阶段:

  1. **等待:**等待数据准备就绪。读操作是等待网卡或者磁盘,把数据送到内核缓冲区;写操作则是等待内核发送缓冲区拥有空闲空间。
  2. **拷贝:**将数据在内核缓冲区和用户空间之间来回搬运。读操作是把内核缓冲区的数据拷贝到用户空间;写操作是把用户空间的数据拷贝到内核缓冲区。

由此得到核心结论:IO = 等待 + 拷贝 。

很多人狭义上只把数据拷贝理解成 IO,但完整的 IO 流程,等待和拷贝两个环节缺一不可。网卡、路由器等硬件负责长距离的数据转发,是数据抵达内核缓冲区的物理基础。后续所有 IO 模型 ,本质都是在这两个阶段做优化。

1.2 为什么 IO 比较慢

结合IO = 等待 + 拷贝 这个公式分析效率: 拷贝是硬件执行动作 ,硬件速率固定,软件层面几乎很难提升拷贝本身的速度。

IO 低效的根本原因,是等待环节在整个 IO 流程里的时间占比太高。尤其是网络 IO,数据要经过远距离传输、路由器转发,等待耗时往往是拷贝耗时的成百上千倍。

高效 IO 的核心目标:在单位时间内,尽可能降低等待环节的时间占比。

1.3 如何理解高效 IO

我们用钓鱼的生活化例子来类比 IO 全过程,这个类比会贯穿后面所有 IO 模型的讲解:

  • 钓鱼的人 = 应用进程
  • 鱼竿 = 文件描述符(fd)
  • 鱼漂 = 就绪事件,标记数据是否准备完成
  • 鱼 = 需要读写的数据
  • 池塘 = 网络或者磁盘

钓鱼同样分为两步:等鱼咬钩(等待) + 提竿捞鱼(拷贝)

  • **低效钓鱼:**绝大部分时间都在原地等待鱼咬钩,等待时间占比极高。
  • **高效钓鱼:**减少无效等待,或者在等待的间隙去处理其他事务。

提升 IO 效率的核心思路就和钓鱼一致:降低 IO 流程中等待环节的时间占比。

二、五种 IO 模型详解

2.1 钓鱼故事:五位人物对应五种 IO 模型

我们继续沿用前面钓鱼的生活化类比,五位钓鱼者不同的行为模式,分别对应操作系统里五种经典 IO 模型。

  • 张三 --- 阻塞 IO
  • 李四 --- 非阻塞 IO
  • 王五 --- 信号驱动 IO
  • 赵六 --- IO 多路复用
  • 田七 --- 异步 IO

核心公式:IO = 等待 + 拷贝。下面所有模型,都围绕这两个阶段做不同的设计。

2.1.1 阻塞 IO(Blocking IO)

对应人物:张三 张三是新手钓鱼者,全程死死盯着鱼漂。鱼漂不动就原地等待,不做任何其他事情;只有鱼漂晃动(鱼咬钩)时,才会执行提竿捞鱼的动作。

**核心特征:**被等待动作阻塞,等待期间无法执行其他事务。

工作流程

  1. 应用进程调用recvfrom这类系统调用,向内核发起读请求。
  2. 如果内核缓冲区中没有数据,进程会被操作系统挂起,进入阻塞等待状态,什么都无法执行。
  3. 数据到达内核缓冲区就绪后,内核把数据拷贝到用户空间。
  4. 拷贝完成,recvfrom调用返回成功,进程解除阻塞,开始处理业务数据。

优缺点:

优点:逻辑简单,代码编写方便,所有 Socket 套接字默认都是阻塞模式。

缺点:一个线程同一时间只能处理一个 IO。高并发场景下,需要大量线程,会带来巨大的线程创建与上下文切换开销,系统性能下降。

2.1.2 非阻塞 IO(Non-blocking IO)

对应人物:李四 李四有多年钓鱼经验,不会一直盯着鱼漂死等。他会间歇性查看鱼漂状态,两次查看的间隙可以聊天、处理其他琐事;一旦发现鱼漂晃动,立刻提竿捞鱼。

**核心特征:**不会被等待动作阻塞,轮询检查状态,等待间隙处理其他工作。

工作流程

  1. 应用进程调用recvfrom系统调用。
  2. 如果内核缓冲区没有数据,系统调用不会阻塞挂起进程,直接返回EWOULDBLOCK错误码。
  3. 进程不会休眠,可以继续执行其他业务代码。
  4. 进程需要循环反复调用recvfrom轮询,持续检查数据是否就绪。
  5. 某次调用发现数据就绪,内核执行数据拷贝,recvfrom返回成功。

问题 1:阻塞 vs 非阻塞,核心差别是什么?

阻塞 IO 和 非阻塞 IO,单纯 IO 本身的执行效率没有区别 。

二者处理数据(捞鱼)的速度完全一样,差异仅仅是等待阶段的处理方式 。

非阻塞 IO 并没有加快 IO 拷贝的速度,只是把原本无效等待的时间,拿来执行其他任务,提升了程序整体吞吐量。

**注意:**非阻塞 IO 的致命缺陷:频繁轮询会大量消耗 CPU 资源,空转浪费算力,一般不会单独使用,通常搭配 IO 多路复用一起使用。

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

对应人物:王五 王五经验更加老道,在鱼竿顶端悬挂铃铛,随后将鱼竿插在岸边,自己离开鱼竿去做其他事。当鱼咬钩拉动鱼线,触发铃铛响(信号通知),王五收到声音信号后,再回来提竿捞鱼。

**核心特征:**依靠信号通知事件就绪,无需主动轮询等待。

工作流程

  1. 应用进程提前通过sigaction注册SIGIO信号处理函数,告诉内核:数据就绪后发送信号通知我。
  2. 系统调用立刻返回,进程继续执行其他业务,不会阻塞等待。
  3. 当数据到达内核缓冲区,内核主动向进程递送SIGIO信号。
  4. 进程收到信号,在信号处理函数中调用recvfrom,完成内核到用户空间的数据拷贝。
  5. 拷贝结束,处理数据。

关键点辨析

问题 3:王五有没有等待?

这个问题本身存在一定歧义。从现象上看,王五不需要持续盯着鱼漂轮询检测;但是王五依然要亲自参与捞鱼(拷贝)这个环节 。 根据 IO 判定规则:只要进程参与 IO 的等待或者拷贝任意一个阶段,就属于同步 IO。所以信号驱动 IO 属于同步 IO。

优缺点:

优点:数据就绪前进程完全自由,CPU 利用率高。

缺点:高并发场景下信号会频繁触发,容易造成信号队列溢出、丢失事件,实际工程中使用很少。

2.1.4 IO 多路复用(IO Multiplexing)

对应人物:赵六 赵六拥有大量鱼竿,一次性在岸边摆放多根鱼竿,来回巡视所有鱼竿的鱼漂状态。只要任意一根鱼竿的鱼漂晃动,就立刻对应捞鱼,甚至可以同时处理多条上钩的鱼。

核心特征: 统一监听多个目标,一次等待多个就绪事件。常用函数:select、poll、epoll。

工作流程

  1. 应用进程调用select/poll/epoll多路复用函数,把一批文件描述符交给内核。
  2. 内核同时监视多个 fd 的就绪状态。
  3. 进程阻塞在多路复用函数调用上,直到其中一个或多个 fd 数据就绪。
  4. 当 fd 就绪,多路复用函数返回,进程调用recvfrom,把数据从内核拷贝到用户空间。
  5. 处理完成后,继续循环监听。

问题 2:谁的钓鱼效率最高?

站在鱼上钩的概率角度,假设鱼咬任意一个鱼钩概率均等,赵六一次性监听大量鱼竿,单位时间内等待的时间占比会大幅降低。这就是 IO 多路复用的核心优势,也是 Nginx、Redis 等高并发服务的底层核心。

优缺点:

优点:单个线程可以同时监听成千上万个 IO 连接,不需要为每一条连接创建独立线程,极大减少线程创建、上下文切换的开销,是高并发网络开发最主流模型。

缺点:本质依旧属于同步 IO,就绪后仍然需要进程主动调用接口完成数据拷贝。

2.1.5 异步 IO(Asynchronous IO)

对应人物:田七 田七本身不参与钓鱼,他只想要最终的鱼。他把鱼竿、水桶全部交给下属小王,全权委托小王完成「等待鱼咬钩 + 捞鱼」的全部流程;小王完成钓鱼之后再通知田七,田七全程只处理自己的业务。

**核心特征:**仅发起任务,完全不参与等待、拷贝等任何 IO 流程,由操作系统全权执行。

工作流程

  1. 应用进程发起aio_read异步 IO 系统调用,向内核提交 IO 请求,调用立刻返回,进程继续执行其他业务。
  2. 内核在后台独立完成两件事:等待数据就绪,并且自动把数据从内核缓冲区拷贝到用户指定缓冲区。
  3. 全部 IO 流程(等待 + 拷贝)执行完毕后,内核通过信号或者回调函数通知应用进程。
  4. 进程直接读取已经拷贝完成的数据。

关键点:

异步 IO 是五种模型里唯一的异步 IO。进程只负责发起 IO 请求,等待、拷贝两个阶段完全由内核完成,进程全程不参与。

2.2 同步 IO vs 异步 IO

基于**IO = 等待 + 拷贝** 公式,给出判定规则:

  1. 同步 IO:只要进程参与 IO 流程中的任意一个环节(等待数据 或 数据拷贝),就属于同步 IO。 阻塞 IO、非阻塞 IO、信号驱动 IO、IO 多路复用,全部属于同步 IO。
  2. 异步 IO:进程完全不参与等待、拷贝任何环节,仅仅发起 IO 请求,全部 IO 流程由操作系统内核完成,完成之后主动通知进程。只有异步 IO 属于这一类。

核心结论: 只要有人参与了 IO 的等待或者拷贝任意阶段,就是同步 IO;

只发起 IO 任务,后续 IO 流程和进程无关,才是异步 IO。

2.3 五种 IO 模型图表总结

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

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

三、非阻塞 IO

非阻塞 IO 是 Linux 网络开发中高频使用的 IO 模型。在 Linux 环境中,open 调用无法永久修改 已打开 fd 的非阻塞属性,我们一般依靠**fcntl系统调用** ,动态修改 已经打开的文件描述符状态。

3.1 fcntl 系统调用

fcntl 全称 File Control(文件控制) ,是 Linux/Unix 体系下的核心系统调用。

作用是:动态修改已经打开的文件描述符(fd)属性。当文件、套接字已经完成打开操作后,无法再次调用open修改属性,这时fcntl就是调整 fd 属性的核心通道。

3.1.1 函数原型

cpp 复制代码
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );

参数说明:

  1. **fd:**目标文件描述符,可以是普通文件、套接字、管道等;
  2. cmd: 操作指令,决定fcntl要执行的功能,是核心参数;
  3. **可变参数arg:**可选参数,根据不同的 cmd 传入不同的值,可传入整数、结构体等,部分场景不需要传入。

3.1.2 fcntl 五大核心功能

根据cmd参数的取值,fcntl一共分为五类功能:

  1. 复制文件描述符 (cmd = F_DUPFD):等价于dup函数,生成新 fd,两个 fd 指向同一个底层文件;
  2. 获取 / 设置文件描述符标记 (重点) (cmd = F_GETFD / F_SETFD),最常用标记FD_CLOEXEC,执行 exec 系列函数时自动关闭 fd,防止文件描述符泄露;
  3. 获取 / 设置文件状态标记(重点) (cmd = F_GETFL / F_SETFL):网络编程最常用,用来修改文件读写模式、开启非阻塞、设置同步 IO 等;
  4. 获取 / 设置异步 IO 所有权 (cmd = F_GETOWN / F_SETOWN):指定 SIGIO 信号发送的目标进程或进程组;
  5. 获取 / 设置文件记录锁 (cmd = F_GETLK / F_SETLK / F_SETLKW):多进程读写同一个文件时做加锁控制,分为共享读锁、独占写锁,避免并发读写造成数据错乱。

实现非阻塞 IO 时,我们只用到F_GETFL和F_SETFL这一组命令,用来读取、修改文件状态标记。

3.2 非阻塞 IO 实战:基于 fcntl 实现

了解完理论,下面看代码层面如何开启非阻塞 IO。我们通过**fcntl读取当前 fd 的状态标记** ,追加O_NONBLOCK标志,再写回内核,完成非阻塞模式的设置。

核心思路: 不能直接覆盖原有 flags,需要先F_GETFL拿到旧标记,或上 O_NONBLOCK,再F_SETFL写回,防止覆盖掉原有配置。

3.2.1 封装实现 SetNoBlock 函数

基于fcntl的文件状态标记功能,我们可以封装通用工具函数,将任意文件描述符设置为非阻塞模式。该函数仅调用一次,fd 就会永久保持非阻塞状态。

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <fcntl.h>

void Set_NonBlock(int fd)
{
    // 1.获取fd当前的状态标志
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0)
    {
        // 获取fl失败
        perror("fcntl");
        return;
    }
    // 2.按位或叠加O_NONBLOCK,保留原有标志位
    fcntl(fd, F_SETFL, fl | O_NONBLOCK); // O_NONBLOCK:将fd设置成非阻塞
}

代码解读:

  1. 调用fcntl(fd, F_GETFL)获取文件描述符当前的状态标记,标记本质是一个位图;
  2. 通过按位或|操作,追加O_NONBLOCK非阻塞标记,保留 fd 原有的所有属性,不会覆盖原有标志;
  3. 调用fcntl(fd, F_SETFL, fl),将更新后的标记写回内核,完成非阻塞设置。

核心思路:不能直接覆盖原有 flags,必须先获取旧标记,叠加O_NONBLOCK,再写回。避免直接赋值清空文件描述符原本的配置。

3.2.2 read 函数返回值详解

函数原型:

cpp 复制代码
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);

结合阻塞、非阻塞两种场景,read一共有三类返回值,是非阻塞代码编写的重点:

  1. **返回值 > 0:**成功读取到有效数据,返回值为实际读取的字节数;
  2. **返回值 == 0:**文件 / 数据流关闭(管道断开、文件末尾、客户端断开网络连接);
  3. 返回值 < 0: 分两种场景
    阻塞模式: 返回负数代表真实 IO 错误(硬件故障、fd 非法等)
    非阻塞模式: 无数据就绪是正常情况,返回负数并设置errno=EWOULDBLOCK,不属于程序异常,代码必须区分「真错误」和「数据未就绪」。

核心易错点: 非阻塞场景下,不能直接把read_size < 0判定为故障,必须结合错误码判断状态。

3.2.3 非阻塞 IO 实战:轮询读取标准输入

我们使用标准输入(fd=0)演示非阻塞 IO,循环轮询调用 read,没有数据时立刻返回,不阻塞进程。

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <fcntl.h>

void Set_NonBlock(int fd)
{
    // 1.获取fd当前的状态标志
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0)
    {
        // 获取fl失败
        perror("fcntl");
        return;
    }
    // 2.按位或叠加O_NONBLOCK,保留原有标志位
    fcntl(fd, F_SETFL, fl | O_NONBLOCK); // O_NONBLOCK:将fd设置成非阻塞
}

int main()
{
    // 设置标准输入为非阻塞
    Set_NonBlock(0);

    char buffer[1024];
    while (true)
    {
        int n = read(0, buffer, sizeof(buffer));
        // 系统调用read不同于recv、recvfrom,read会读取到用户在键盘中输入的任何字符包括回车键\n
        if (n > 0)
        {
            // 有数据就绪了
            buffer[n - 1] = 0; // 这里不是buffer[n]就是因为我们需要手动把回车键\n忽略掉
            std::cout << buffer << std::endl;
        }
        else if (n < 0)
        {
            // 关键:针对非阻塞read,如果数据还没有就绪,返回值小于0;数据读取错误,返回值也是0
            // 数据没有就绪算不算数据读取错误?不算
            // 但是两者的结果都是read返回值小于0,如何区分?错误码
            if (errno == EAGAIN || errno == EWOULDBLOCK)
            {
                // 返回的错误码是EAGAIN或者EWOULDBLOCK说明是数据没有就绪
                std::cout << "数据还没有就绪..." << std::endl;

                // 处理自己的事情
                std::cout << "0 not ready, do other thing!" << std::endl;
                sleep(1);
                continue;
            }
            else if (errno == EINTR)
            {
                // 当程序正在读取数据的时候,突然收到一个 SIGINT(Ctrl+C)或其他信号,
                // read会返回-1并且errno 设置为 EINTR
                continue; //重新尝试读取
            }
            else
            {
                // 说明是数据读取错误
                break;
            }
        }
        else
        {
            // ctrl + d:表示从标准输入当前输入结束(类似read读取到文件结尾,返回0)
            break;
        }
    }
}

代码关键点解读:

  • 非阻塞设置: Set_NonBlock(0)将标准输入设置为非阻塞模式,该设置永久生效,不需要每次 read 都重复设置。
  • read 返回值处理:
    • **n > 0:**成功读到数据
    • n == 0: 对端关闭连接,标准输入场景就是按下Ctrl+D
    • n < 0: 检查errno
      • **EWOULDBLOCK / EAGAIN:**无数据就绪,是非阻塞 IO 正常现象,不是错误
      • **EINTR:**调用被信号中断,重新尝试读取
      • **其他错误码:**真实 IO 故障,终止读取

运行效果:

编译运行程序后:

  • 没有输入任何内容时,程序每隔 1 秒打印数据还没有就绪...,不会卡住等待输入;
  • 输入内容并回车后,程序立刻读取并打印输入的文本。

这就是非阻塞 IO 最典型的特征:调用 read 不会阻塞进程,没有数据就立刻返回,有数据就正常读取。

结束语

本篇我们从 IO 的本质「等待 + 拷贝」出发,依次讲解了 Linux 下五种经典 IO 模型,通过钓鱼的类比理清各自的工作流程,区分同步 IO 与异步 IO 的核心边界,最后落地到非阻塞 IO 的代码实战。

我们学习了如何利用fcntl修改文件描述符属性,封装非阻塞工具函数,并且掌握非阻塞场景下read返回值与错误码的处理逻辑,明白EAGAIN/EWOULDBLOCK只是数据未就绪的正常状态,并非程序故障。

非阻塞 IO 虽然可以做到不阻塞进程,但单纯轮询的方式会带来大量无效系统调用,CPU 资源消耗较高。下一章我们将学习 IO 多路复用,解决多文件描述符轮询带来的性能问题,进一步理解 Linux 高并发网络服务的核心实现方案。

相关推荐
CHENKONG_CK7 小时前
RFID 赋能汽车零部件涂装产线,构建全流程数字化追溯体系
网络·人工智能·单片机·网络协议·tcp/ip·汽车
可涵不会debug7 小时前
C++ std::bind 实战教程:线程池、回调场景全覆盖
linux·开发语言·c++
优化Henry7 小时前
BBU与RRU光链路详解:从双纤一收一发到单纤双向
网络·笔记·学习·5g·tdd
实点科技7 小时前
远程IO模块的柜外安装:装在什么位置、防尘罩怎么选配
运维·服务器·网络
青梅味猪大肠8 小时前
【操作系统-26】进程互斥软件实现-Peterson算法
linux·运维·服务器
qq_40999093?8 小时前
VyOS 入门指南:从概念认知到基础配置
网络
wdfk_prog8 小时前
Wi-Fi Direct教程 01:在 Ubuntu 搭建双 mac80211_hwsim + wpa_supplicant 2.12
linux·运维·服务器·ubuntu·ros·wifi-direct
瞬间记忆8 小时前
汽车CAN基础知识
网络
wusam9 小时前
计算机网络第二章习题解答
服务器·网络·计算机网络
Vcaker9 小时前
Linux学习36-k8s集群升级及kube-vip高可用
linux·运维·学习