【Linux】五种 IO 模型与非阻塞 IO

发布于:2025-03-17 ⋅ 阅读:(15) ⋅ 点赞:(0)

🌈 个人主页:Zfox_
🔥 系列专栏:Linux

一:🔥 重新理解 IO

🦋 为什么说网络问题的本质是 I/O 问题?

🎀 从数据流动看网络通信

  • 网络通信的核心:数据在客户端与服务器之间**双向流动**。
    • 客户端发送请求 → 输出(Write)
    • 服务器接收请求 → 输入(Read)
    • 服务器返回响应 → 输出(Write)
    • 客户端接收响应 → 输入(Read)
  • 每个步骤均涉及 I/O 操作:数据通过网卡、内核缓冲区、用户程序传递,本质是 跨层数据搬运

🎀 网络 I/O 的瓶颈

  • 延迟(Latency):数据从一端到另一端的传输时间(如物理距离、路由跳数)。
  • 带宽(Bandwidth):单位时间内可传输的数据量上限。
  • 并发(Concurrency):同时处理的连接数影响资源分配效率。

总结
网络性能优化的核心是 减少 I/O 等待时间提升 I/O 吞吐量


🦋 如何理解 I/O 的本质?

🔬 IO 就是 input,output,参照物是计算机本身,是计算机系统内部和外部设备进行交互的过程。

IO = 等+拷贝(等是主要矛盾) \colorbox{#FF7F00}{IO = 等+拷贝(等是主要矛盾)} IO = +拷贝(等是主要矛盾)

🐳 等待外部设备就绪,当外部设备准备好了以后,通过 CPU 的针脚发送中断信号告知操作系统。操作系统转入内核态,进行拷贝工作。

  1. 等待(Waiting)等待数据就绪(如网络数据到达内核缓冲区、磁盘数据加载到内存)。
  2. 拷贝(Copying)将数据从内核缓冲区复制到用户空间(或反向)。

高效IO \colorbox{turquoise}{高效IO} 高效IO
上面说的IO=等待+拷贝

在大多数情况下,时间都浪费在等待上面,因为和等待相比,拷贝要花的时间比等待的时间少的多。

  • 高效 IO 的核心减少等待时间的浪费,而非单纯优化拷贝速度。

等待的分类

  • 主动等待:进程阻塞直到数据就绪(如阻塞式 read())。
  • 被动等待:进程通过轮询或事件通知检查状态(如非阻塞 IO + epoll)。

示例

  • 网络请求:客户端等待服务器响应的 RTT(Round-Trip Time)属于被动等待。
  • 数据库查询:从磁盘读取数据时,CPU 因 IO 阻塞而空闲属于主动等待。

🦋 什么是高效的 I/O?

⚡🧙 任何通信场景,IO 通信效率一定是有上限的,毕竟 花盆里长不出参天大树(受硬件限制)

IO效率低 的原因主要有以下几点:

  1. 等待时间:IO操作通常涉及与外部设备的交互,这些设备的速度远低于CPU和内存。例如,硬盘的读写速度比内存慢几个数量级,网络传输的速度也受带宽和延迟的限制。因此,程序在等待IO操作完成时会浪费大量时间。
  2. 上下文切换:在阻塞IO中,操作系统需要将等待IO的进程挂起,并切换到其他进程执行。这种上下文切换会消耗额外的CPU资源,降低整体效率,
  3. 资源竞争:在高并发环境下,多个进程或线程可能同时请求IO操作,导致资源竞争和排队,进一步增加等待时间。

🎀 高效 I/O 的目标

  1. 最大化 CPU 利用率:减少进程因等待 I/O 而阻塞的时间。
  2. 最小化延迟:快速响应每个 I/O 请求。
  3. 最大化吞吐量:单位时间内处理更多 I/O 操作。

🎀 实现高效 I/O 的策略

策略 1:减少阻塞等待

  • 非阻塞 I/O:轮询检查数据是否就绪,避免进程挂起。
    • 代价:频繁轮询可能导致 CPU 空转。
  • 多路复用(如 epoll:单线程监控多个 I/O 事件,仅处理就绪的描述符。
    • 优势:适合高并发网络服务(如 Web 服务器)。

策略 2:批量处理 I/O

  • 缓冲(Buffering):累积多个小数据包后一次性处理,减少系统调用次数。
    • 示例:TCP 协议的 Nagle 算法合并小数据包。

策略 3:异步化与并行化

  • 异步 I/O(如 io_uring:内核全程处理 I/O,完成后通知进程。
  • 多线程/进程:为每个连接分配独立执行单元(但需权衡上下文切换开销)

由于 IO 大部分时间花在了 等待上,因此高效的 IO 本质:单位时间内,等待的比重越低, IO 效率越高 \colorbox{cyan}{由于 IO 大部分时间花在了 等待上,因此高效的 IO 本质:单位时间内,等待的比重越低, IO 效率越高} 由于 IO 大部分时间花在了 等待上,因此高效的 IO 本质:单位时间内,等待的比重越低, IO 效率越高

二:🔥 五种 IO 模型

在了解相关知识之前,我们先来看个例子,方便我们对其的理解

🦋 生动例子:餐厅点餐

角色定义

  • 进程:顾客(发起 I/O 请求的主体)。
  • 文件描述符:订单号(标识一个 I/O 请求)。
  • 数据:顾客点的餐(需要处理的内容)。

1.1 阻塞 I/O

  • 场景
    顾客下单后,一直坐在餐桌前等待,直到服务员端上菜才能做其他事(如玩手机)。
  • 关键点
    • 顾客(进程)在等待期间完全被阻塞。
    • 订单号(文件描述符)对应唯一的请求。

1.2 非阻塞 I/O

  • 场景
    顾客下单后,每隔 5 秒去厨房问一次“我的菜好了吗?”,期间可以喝水、聊天。
  • 关键点
    • 顾客(进程)需要主动轮询状态。
    • 若厨房(内核)回答“没好”,顾客继续做其他事。

1.3 信号驱动 I/O

  • 场景
    顾客下单后,留下手机号给服务员,继续聊天。厨房准备好菜时,服务员打电话通知顾客取餐。
  • 关键点
    • 数据就绪时内核(服务员)通过信号(电话)通知进程(顾客)。
    • 顾客仍需自己从厨房端走菜(同步拷贝数据)。

1.4 多路转接 I/O

  • 场景
    顾客同时点了咖啡和蛋糕,告诉大堂经理“两样都好了叫我”。经理一直监听多个订单,任一就绪时通知顾客。
  • 关键点
    • 一个进程(顾客)通过多路复用接口(经理)监控多个文件描述符(订单)。
    • 仍需顾客自己取餐(同步拷贝数据)。

1.5 异步 I/O

  • 场景
    顾客下单后,继续办公。厨房准备好菜后,服务员直接将菜端到顾客桌上,并说“您的菜齐了”。
  • 关键点
    • 数据准备和端菜(拷贝)全程由内核(服务员)完成。
    • 顾客(进程)无需参与任何等待或操作。

🦋 专业术语介绍

  1. 阻塞 I/O (Blocking I/O)

    • 进程发起 I/O 操作后,立即进入阻塞状态,(在内核将数据准备好之前,系统调用一直等待)直到内核将数据准备好并拷贝到用户空间后,进程才恢复执行。
    • 所有的套接字默认是阻塞方式
    • 同步 I/O:进程全程需要等待数据就绪和拷贝完成。
      在这里插入图片描述
  2. 非阻塞 I/O (Non-blocking I/O)

    • 进程发起 I/O 操作后,内核立即返回一个状态值(未就绪),进程通过轮询(Polling) 反复检查数据是否就绪,期间可以执行其他任务。
    • 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回 EWOULDBLOCK 错误码.
    • 轮询:意指程序员循环的方式反复尝试读写文件描述符。这对 CPU 来说是较大的浪费, 一般只有特定场景下才使用.
    • 同步 I/O:进程需要主动检查数据状态并完成拷贝。
      在这里插入图片描述
  3. 信号驱动 I/O (Signal-driven I/O)

    • 进程发起 I/O 操作后,内核在数据就绪时发送信号(如 SIGIO 通知进程,进程随后执行数据拷贝。
    • 同步 I/O:数据拷贝阶段仍需进程主动完成。
      在这里插入图片描述
  4. 多路转接 I/O (Multiplexing I/O)

    • 进程通过 selectpollepoll 同时监控多个文件描述符,当任一描述符数据就绪时,内核通知进程进行处理。
    • 同步 I/O:数据就绪后仍需进程主动拷贝数据。

在这里插入图片描述

虽然从流程图上看起来和阻塞 IO 类似实际上最核心在于 IO 多路转接 能够同时等待多个文件描述符的就绪状态.

  1. 异步 I/O (Asynchronous I/O)
    • 进程发起 I/O 操作后,内核 全程负责数据准备和拷贝,完成后通过回调(如信号或回调函数)通知进程。
    • 异步 I/O进程无需参与数据准备或拷贝

在这里插入图片描述


🦋 总结表

  • 同步 I/O
    • 阻塞 I/O非阻塞 I/O信号驱动 I/O多路转接 I/O
    • 共同点:数据拷贝阶段需进程主动完成(即使通过信号或轮询触发)。
  • 异步 I/O
    • 数据准备和拷贝全程由内核处理,进程无需参与。
I/O 模型 同步/异步 例子类比 进程角色
阻塞 I/O 同步 干等上菜 全程阻塞
非阻塞 I/O 同步 轮询询问厨房 主动轮询
信号驱动 I/O 同步 电话通知取餐 被动响应信号
多路转接 I/O 同步 经理监听多个订单 批量监听
异步 I/O 异步 服务员直接端菜到桌 完全无需参与

三:🔥 思考

🦋 阻塞 vs 非阻塞,非阻塞效率效率一定高吗?

答案:
不一定,非阻塞 I/O 的效率取决于具体场景。

  • 非阻塞 I/O 的优势
    进程在等待数据就绪期间可以执行其他任务(避免完全阻塞),适合需要同时处理多任务的场景。
    例子:餐厅顾客边等餐边聊天(非阻塞)比干等的顾客(阻塞)更高效。
  • 非阻塞 I/O 的劣势
    如果频繁轮询(如每秒检查 1000 次),会导致 CPU 资源浪费,甚至比阻塞 I/O 效率更低。
    例子:顾客每隔 1 秒就去厨房问一次,导致自己无法专心聊天,服务员也被频繁打扰。

结论

  • 低并发场景:阻塞 I/O 更简单高效(避免轮询开销)。
  • 高并发场景:非阻塞 I/O + 多路复用(如 epoll)效率更高(避免大量线程阻塞)

🦋 五种模型中,谁的 I/O 效率最高?

答案:
异步 I/O(如 Linux 的 io_uring)理论效率最高,但实际中 多路复用 I/O(如 epoll 在同步模型中更常用。

  • 异步 I/O
    • 优势:内核全程处理数据准备和拷贝,进程完全无需等待(服务员直接端菜到桌)。
    • 限制:依赖操作系统和硬件的支持(如 Linux 的异步 I/O 实现复杂)。
  • 多路复用 I/O(epoll
    • 优势:单线程监控大量文件描述符,避免进程/线程频繁切换(大堂经理统一管理订单)。
    • 场景:高并发网络服务器(如 Nginx、Redis)的核心模型。

总结

  • 异步 I/O 理论最优,但实际中多路复用 I/O 因兼容性和成熟度更常用
  • 异步 I/O 的高效主要和其特点无关,还是得依靠程序员自己,而多路复用 I/O 可以用于处理大批网络数据,降低了等的比重

因此总的来说,我们更认为 多路复用的 I/O 效率更高

🦋 同步通信 vs 异步通信

同步 和 异步 关注的是消息通信机制.

  • 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回. 但是一旦调用返回,就得到返回值了;
    • 换句话说,就是由调用者主动等待这个调用的结果;
  • 异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果;
    • 换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果; 而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用.

另外:之前我们在讲 多进程多线程 的时候,也提到同步和互斥。
注意:这里的同步通信和进程之间的同步是完全不相干的概念.

  • 进程/线程同步 也是进程/线程之间直接的制约关系
  • 是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调
    他们的工作次序而等待、传递信息所产生的制约关系。尤其是在访问临界资源的时候.

因此以后在看到 “同步” 这个词,一定要先搞清楚大背景是什么。这个同步是同步通信异步通信的同步, 还是同步与互斥的同步

四:🔥 非阻塞 IO

🦋 fcntl

一个文件描述符, 默认都是阻塞 IO.

函数原型如下.

NAME
       fcntl - manipulate file descriptor

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

       int fcntl(int fd, int cmd, ... /* arg */ );

cmd 是命令,是要操作的类型。主要的操作类型有:

  • 获取,设置文件状态信息:cmd=F_GETFL,F_SETFL。
  • 复制现有的描述符,cmd=F_DUPFD。
  •  获取,设置文件描述符标识, ,cmd=F_GETFD,F_SETFD  \colorbox{pink}{ 获取,设置文件描述符标识, ,cmd=F\_GETFD,F\_SETFD }  获取,设置文件描述符标识cmd=F_GETFDF_SETFD 
  • 获取,设置异步IO所有权,cmd=F_GETOWN,F_SETOWN。
  • 获取、设置记录锁,cmd=F_GETLK,F_SETLK,F_SETLKW。

🧊 我们此处只是用第三种功能, 获取/设置文件状态标记 , 就可以将一个文件描述符设置为非阻塞. 文件状态标志包括 O_APPENDO_NONBLOCK

🦋 实现函数 SetNonBlock

⚙️ 基于 fcntl , 我们实现一个 SetNoBlock 函数, 将文件描述符设置为非阻塞.

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

// 让文件描述符非阻塞
void SetNonBlock(int fd)
{
    int f1 = fcntl(fd, F_GETFL);
    if(f1 < 0)
    {
        perror("fcntl");
        return ;
    }
    fcntl(fd, F_SETFL, f1 | O_NONBLOCK);    //  O_NONBLOCK 让fd 以非阻塞的方式进行工作
}
  • 使用 F_GETFL 将当前的文件描述符的属性取出来 (这是一个位图).
  • 然后再使用 F_SETFL 将文件描述符设置回去. 设置回去的同时, 加上一个 O_NONBLOCK 参数.

🦋 非阻塞方式读取标准输入

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

// 让文件描述符非阻塞
void SetNonBlock(int fd)
{
    int f1 = fcntl(fd, F_GETFL);
    if(f1 < 0)
    {
        perror("fcntl");
        return ;
    }
    fcntl(fd, F_SETFL, f1 | O_NONBLOCK);    //  O_NONBLOCK 让fd 以非阻塞的方式进行工作
}

int main()
{
    std::string tips = "Please Enter# ";
    char buffer[1024];

    SetNonBlock(0);

    while(true)
    {
        write(0, tips.c_str(), tips.size());
        // 非阻塞,如果我们不做输入,数据不就绪,以出错形式返回!!
        // read 不是有读取失败(-1)吗?失败vs底层数据没就绪 -> 底层数据没就绪,不算失败
        // 如果是 -1, 失败vs底层数据没就绪我们后续的做法是不同的!
        // read -> -1, 失败vs底层数据没就绪 -> 需要区分的必要性的!
        // errno 表示:更详细的出错原因, 最近一次调用,出错的时候的出错码
        int n = read(0, buffer, sizeof(buffer));
        if(n > 0)
        {
            buffer[n] = 0;
            std::cout << "echo# " << buffer << std::endl;
        }
        else if(n == 0)
        {
            std::cout << "read file end" << std::endl;
            break;
        }
        else
        {
            // EAGAIN		11	/* Try again */
            // EWOULDBLOCK	EAGAIN	/* Operation would block */
            if(errno == EAGAIN || errno == EWOULDBLOCK)
            {
                // 做其他事情呢?

                std::cout << "底层数据,没有就绪" << std::endl;
                sleep(1);

                continue;
            }
            else if (errno == EINTR)
            {
                std::cout << "被中断, 重新来" << std::endl;
                sleep(1);

                continue;
            }
            else
            {
                std::cout << "read error: " << n << ", errno: " << errno << std::endl;
            }
        }
    }

    return 0;
}

五:🔥 共勉

😋 以上就是我对 【Linux】五种 IO 模型与阻塞 IO 的理解, 觉得这篇博客对你有帮助的,可以点赞收藏关注支持一波~ 😉
在这里插入图片描述