跳转至

深入理解Linux IO模型

基本概念

在计算机操作系统中,所谓的I/O就是输入(input)和输出(output),也可以理解为读(read)和写(write)。针对不同的对象,I/O模式可以划分为**磁盘IO模型**和**网络IO模型**

通常情况下,我们的运行程序存在内存中,当程序运行时,CPU就会从内存中读数据,这个时候就涉及到数据交换,就需要IO接口。

IO操作是相对于内存来说的,从内存中读数据,就是output,将数据存入内存,就是input。

Linux中,普通的业务进程无法直接操作IO设备,必须通过**系统调用syscall**,请求kernel来协助完成IO操作。内核会为每一个IO设备维护一个缓冲区。

对于一个输入操作来说,进程IO系统调用后,内核首先会看缓冲区中有无数据,如果有数据,就将数据复制到内存中,如果没有数据,就需要等待设备发送数据过来后再存入内存,所以IO设备一般速度较慢,需要等待。

大多数UNIX文件I/O只需用到5个函数:open、read、write、lseek 以及close

术语——**不带缓存**指的是每个read和write都调用内核中的一个系统调用。这些不带缓存的I/O函数不是ANSI C的组成部分,但是是POSIX.1和XPG3的组成部分。

只要涉及在多个进程间共享资源,原子操作的概念就变成非常重要。

IO是Linux内核里面除了进程管理、内存管理之外另一个比较重要的概念。

IO涉及的知识覆盖面会非常宽泛,从用户态编程框架模型、到内核系统调用、文件系统、io调度、磁盘驱动、网络驱动等等。

很多程序都要读数据或写数据(通常是都要),glibc提供了大量的IO函数,比如我们常用的 read, write, fread, fwrite 等等。

文件这个词,恐怕只要是现代人都不会陌生。但是在不同行业中,文件有着不同的意义。在计算机科学领域,文件是什么呢?文件是可以在目录中看的见的图标么?

当然不是。文件在存储设备时,是个N长的字节序列。

而在一个计算机使用者的角度而言,文件是对所有I/O设备的抽象。每个I/O设备都可以视为文件,包括磁盘、键盘和网络等。

文件这个简单而精致的概念其内涵是十分丰富的,它向应用程序提供了一个统一的视角,来看待系统中可能含有的各式各样的I/O设备。

基本概念

在你进程要读写一个文件中的内容之前,需要对文件建立一个连接信道,这个过程通常称为**打开文件(opening the file)**,打开可以是为了读或写(当然也可以都要)。

这种连接通常被表述为一个**流(steam)或**文件描述符(a file descriptor)

当完成读写任务后,通常需要**关闭文件(closing the file)**,一旦关闭流或文件描述符,就不能对其进行任何读写操作。

数据写入流程

我们从上到下来看看一个C语言包编写的IO程序是如何调用内核方法的:

Linux C App -> glibc(C 库)-> VFS(虚拟文件系统)-> kernel function(内核方法)

1、**用户程序 (C App):**调用高级 I/O 函数,例如 fopen()fprintf()fread() 等。

2、glibc (C 标准库):作为用户态的接口,它封装了复杂的系统调用。它提供了**缓冲 I/O (Buffered I/O)** 和易用的函数。glibc 最终负责发起真正的系统调用。

libc.so.6 就是 GNU C Library (glibc) 在 Linux 系统下的核心动态链接库文件。你在 C 语言里调用的绝大多数基础函数(如 printfmallocfreadopenpthread_create 等),其最终的机器码实现都躺在这个文件里。

3、系统调用 (System Call):这是一个特殊的软件中断**,将 CPU 执行模式**从用户态**切换到**内核态,并将控制权交给内核。

4、**VFS (Virtual File System):**内核中的抽象层。它提供统一的文件系统接口(如 open()read()write()),屏蔽了底层不同文件系统(如 ext4, XFS, NFS 等)的差异。

5、**内核方法 (Kernel Function):**VFS 根据文件实际所在的文件系统,调用对应的驱动程序或文件系统模块中的具体实现方法,最终完成 I/O 操作。

这只是以 C 程序为例,Linux 的 C 程序现在使用的是 GNU C Libary, glibc,但是 Linux 也支持不同的语言,通过类比可以知道,每种语言也应该都提供了相应的类库。

更加简单的理解可以说成:

操作系统内核提供功能的实现,上层类库将这些实现封装成 API 库供上层调用,如果某个库需要跨平台,那么这个库的接口就需要符合一定的规则

标准C库 ANSI C 就是跨平台的,它的接口标准由**国际标准化组织**(ISO)制定,你在Linux上用 ANSI C 写的C程序在Windows上也能运行,因为Windows也支持 ANSI C

流和文件描述符

当需要向文件输入或输出时,有两种基本机制表示程序和文件之间的连接:文件描述符(file descriptor)**和**流(stream)

  • 文件描述符表示为int类型的对象,流表示为 FILE * 类型的对象(文件指针)。
  • 文件描述符的操作通常会更底层,文件流的操作更高级且更丰富。
  • 文件流是基于文件描述符来实现的,所以可以从文件流中提取并操作文件描述符,比如“int fileno(FILE*); fileno(file_stream)”。

文件描述符

文件描述符**为输入和输出操作提供了**更原始的底层接口

文件描述符和流都可以作为终端与设备进行连接,也可以用于普通文件与进程之间的通信,通信方法包括管道和socket。针对某种特定类型的设备,如果要执行某个具体的控制操作,则必须使用文件描述符,这种情况下设备不提供流的工作方式。

如果程序需要在某种特殊模式下输入输出,比如非阻塞(或轮询)输入,同样需要使用文件描述符。

**文件描述符**是一个小整数(small integer),表示进程可以读取或写入的由内核管理的对象。

进程可以通过打开一个文件、目录、设备,或创建一个管道,或复制一个已存在的描述符来获得一个文件描述符。

为了简单起见,我们通常将文件描述符所指的对象称为”文件”;文件描述符接口将文件、管道和设备之间的差异抽象出来,使它们看起来都像字节流。我们将输入和输出称为 I/O。

#include <stdio.h>
#include <unistd.h>
int main() {

    // fopen函数,以可写的方式打开一个文件test_buffer.txt , 赋给一个文件指针
    FILE *fp = fopen("test_buffer.txt", "w");

    // 1. 写入数据到缓冲区
    fprintf(fp, "这条消息正在缓冲区里睡觉,还没进硬盘呢。");

    // 在终端打印提示字符
    printf("写入指令已发出,现在程序将进入 10 秒休眠...\n");
    printf("请在这 10 秒内,手动打开 test_buffer.txt 看看,里面应该是空的!\n");

    // 2. 休眠 10 秒
    sleep(10);

    // 3. 只有执行到fclose之后,或者进程结束时,数据才会真正刷入操作系统的内核页缓存(Page Cache)中。
    // 注意:此时在应用程序视角看来,写操作已经“成功”了。
    // 但对操作系统来说,这些数据只是变成了“脏页”(Dirty Pages),其实依然驻留在内核页内存里还没真正落盘。如果此时断电,其实数据没有写入落盘存在丢失风险。
    printf("休眠结束,正在关闭文件并刷新缓冲区...\n");
    fclose(fp);  // 当你调用 fflush 或 fclose 时,数据被推送到操作系统的内核管理区域。

    printf("现在再去查看文件,内容已经出现了。\n");
    return 0;


    /* 4.Linux 内核真正将这些脏页同步到物理磁盘(落盘)的时机,主要由以下四大机制决定:
            4.1. 定期刷新:Linux 内核有专门的后台线程(在较新内核中为 wb_workfn 工作队列,老版本为 pdflush 或 flush 线程)在持续监控脏页,并根据 /proc/sys/vm/ 中的内核参数定期刷盘
            4.2. 内存压力:当系统内存紧张,或者应用程序写入极其猛烈,导致脏页占内存的比例达到设定阈值时,会触发同步。 dirty_background_ratio 和 dirty_ratio 参数
            4.3. 应用指定:比如MySQL的redolog这些,不能容忍掉电,就不能等操作系统的自动机制,必须在代码中主动调用系统调用fsync(int fd)
            4.4. 文件系统:像 Ext4 或 XFS 文件系统,为了保证崩溃恢复,自己也会有定时的日志刷盘周期(通常是 5 秒左右)
    */
}
#include <stdio.h>
#include <unistd.h>

int main() {
    // 打开文件
    FILE *fp = fopen("example_wt.txt", "w");

    // 1、写数据:此时数据写到《Glibc缓冲区》
    fprintf(fp, "这是一行 Write-Through 的数据。");

    // 2、模拟 Write-Through 行为:将 C 库缓冲区的数据推送到操作系统的
    fflush(fp);

    // 2. 强制操作系统将内核缓冲区的数据写入物理硬件
    int fd = fileno(fp); // 获取文件描述符
    fsync(fd);           // fsync(int fd) 是一个系统调用函数。它是一个阻塞(Blocking)调用。
                         // 内核收到指令后,会立刻把该文件在 Page Cache 里的所有脏页数据,以及文件的元数据(Metadata,比如文件大小、修改时间、访问权限等)一股脑全部刷入物理磁盘。

    printf("数据已确保写入物理磁盘(Write-Through 效果)。\n");

    fclose(fp);
    return 0;
}

对于MySQL来说,innodb_flush_method 参数控制 MySQL 的 InnoDB 存储引擎如何将数据和日志刷新到磁盘。

生产环境(Linux)强烈推荐使用 O_DIRECT,以避免操作系统的页缓存(OS Cache)与 InnoDB 缓冲池产生双重缓冲,从而提升 I/O 性能并节省内存。此参数为全局只读参数,必须在 my.cnf 中配置后重启 MySQL 实例才能生效。

所谓**双重缓冲**就是MySQL进程要写数据落盘时,先在它自己的InnoDB Buffer区缓存,然后写到操作系统的 Page Cache

innodb_flush_method 选项 数据文件走 Page Cache? 写入后调 fsync? 安全评级 适用推荐环境
fsync (双重缓冲) ⭐⭐⭐⭐ 默认兜底,普通机械盘或老系统
O_DIRECT (直达块设备) ⭐⭐⭐⭐⭐ 现代 SSD / 本地 NVMe 生产首选
O_DIRECT_NO_FSYNC (仅刷元数据) ⭐⭐⭐ 带电池保护的 RAID 卡 / 高端 SAN
O_DSYNC 数据调,日志靠直写标志 ⭐⭐⭐⭐ 特定 Unix 或某些特殊网络存储
littlesync 部分跳过 ⭐⭐ 仅限技术研究/魔改源码
nosync 完全不调 ❌(极度危险) 纯内存型 CI 跑分 / 临时 disposable 测试

相比于文件描述符,流提供了**更高层次的接口**。其实是基于文件描述符接口之上的。

流接口对各种文件的处理非常相似,唯一的例外是三种缓冲(buffer)样式有所不同。三种缓冲样式分别为:无缓冲流行缓冲流**和**全缓冲流

无缓冲流的特点是字符写入文件时独立传输,字符之间没有联系;当遇到换行符时,写入行缓冲流的字符会以块的形式传输到文件;全缓冲流的字符能够组成任意大小的块写入文件。

由于流是基于文件描述符实现的更高层次接口,所以也可以从流中提取文件描述符,并基于文件描述符进行更底层的操作。

通常,一般都应该使用流而不是文件描述符,除非是有某些特定操作只能在文件描述符上完成。如果是新手,建议集中关注格式化IO函数。

流接口的主要优点是:用于输入输出的函数集比文件描述符的相应功能更丰富,更强大,如格式化输入输出功能(printf 和 scanf函数)、面向字符和面向行的输入输出功能。

文件描述符接口仅提供用于传输字符块的简单功能,而且文件描述符的可移植性不如流,非GNU系统可能根本不支持文件描述符。

如前面所述,流是一个相当抽象的高级概念,表示与对象的通信通道文件、设备或进程。

文本流和二进制流

打开一个流时,可以指定使用文本流(text stream)或二进制流(binary stream)

IO任务

IO任务即程序从文件中读取数据,或者向文件中写入数据的过程,这个过程一般是需要CPU来控制的。例如一个网络IO任务:

int s = socket(AF_INET, SOCK_STREAM, 0);    //创建socket
bind(s, ...)                                //绑定网络地址
listen(s, ...)                              //监听端口号
int c = accept(s, ...)                      //接受客户端连接
recv(c, ...);                               //接收客户端数据
printf(...)                                 //将数据打印出来

有些情况下,IO操作无法立即完成。例如网络IO中,进程调用 accept 来尝试接收客户端连接,但是此时并没有客户端发起连接,进程怎么处理 accept 函数的执行呢?此时就涉及到了阻塞和非阻塞的概念。

阻塞

CPU在执行进程任务时,会将计算资源按时间片划分,需要使用CPU的进程在工作队列中排队,CPU从队列中轮流取出进程,分配时间片并执行任务。

假设此时进程A执行了 accept 函数,但是客户端并没有发起连接,那么如果不采取措施,进程A就会一直在工作队列中,一直卡在 accept 函数执行的地方,CPU的使用率为 100%。

所以此时系统需要采取一些策略,以避免无意义的计算资源消耗。

当执行 accept 函数,但是客户端没有发起连接时,系统将进程A从工作队列中移除,放到 accept 的 socketfd 的等待队列中,此时CPU执行任务时,不会再将时间片分配给进程A。

我们称进程A进入了 阻塞状态。那么什么时候恢复进程A的执行呢?

中断

在客户端发起连接,网卡收到数据后,会执行一个中断程序,打断当前CPU正在执行的任务,主要做两件事情:

1.将网卡收到的数据拷贝到内核缓冲区(即socket的接收缓冲区)

2.将进程A放回执行队列。CPU再次执行进程任务时,accept 函数就能拿到客户端连接数据了,程序继续正常执行。

现在再来理解一下阻塞:

  • 对单个进程而言是低效的,一旦进入阻塞状态,进程就无法执行其它任务了
  • 对整个系统而言想高效的,让不必要继续执行的进程进入阻塞状态,让出CPU,提升整个系统的处理能力

涉及阻塞的操作:accept函数、read函数、write函数、connect函数

Linux AIO

Linux Native AIO 是 Linux 支持的原生 AIO,为什么要加原生这个词呢?因为Linux存在很多第三方的异步 IO 库,如 libeioglibc AIO。所以为了加以区别,Linux 的内核提供的异步 IO 就称为原生异步 IO。

很多第三方的异步 IO 库都不是真正的异步 IO,而是使用多线程来模拟异步 IO,如 libeio 就是使用多线程来模拟异步 IO 的。

io_uring

io_uring 是 2019 年 Linux 5.1 内核首次引入的高性能 异步 I/O 框架,能显著加速 I/O 密集型应用的性能。

但如果你的应用**已经在使用** 传统 Linux AIO 了,并且使用方式恰当, 那 io_uring 并不会带来太大的性能提升 —— 根据原文测试(以及我们 自己的复现),即便打开高级特性,也只有 5%。

除非你真的需要这 5% 的额外性能,否则 切换**成 io_uring **代价可能也挺大,因为要 **重写应用**来适配 io_uring(或者让依赖的平台或框架去适配,总之需要改代码)