返回文章

WRITING

第五章 存储子系统

第五章 存储子系统

5.1 操作系统原理:文件与 IO 的本质

5.1.1 为什么需要文件系统

磁盘等块设备提供的是原始的、按块编号的线性存储空间(如 LBA 0, 1, 2, …)。直接使用这种原始接口对应用程序很不方便:

  • 应用需要自己管理哪些块存了什么数据。

  • 应用需要自己处理数据的组织和索引。

  • 不同应用之间无法共享数据。

  • 数据的持久性、一致性、安全性无法保证。

**文件系统(File System)**在块设备之上提供了一层抽象,将原始的块存储组织为文件(File)和目录(Directory)的层次结构,为应用提供统一的、易用的接口。

5.1.2 文件的抽象

文件是操作系统提供的最重要的抽象之一。一个文件包含:

  • 数据(Data):文件的内容,存储在磁盘的数据块中。

  • 元数据(Metadata):文件的属性信息,如文件名、大小、创建时间、修改时间、权限、所有者、数据块位置等。元数据存储在 inode(索引节点)中。

关键概念:

  • inode(索引节点):文件系统中描述文件元数据的结构,在同一文件系统内,每个 inode 有相应的 inode 号。inode 不包含文件名,文件名由目录项(dentry)管理。

  • 目录项(dentry):文件名到 inode 的映射,目录本身也是一种文件,内容是目录项的列表。

  • 硬链接(Hard Link):多个文件名指向同一个 inode,通过 inode 的链接计数(i_nlink)管理。

  • 符号链接(Symbolic Link):一个特殊的文件,内容是另一个文件的路径字符串。

5.1.3 IO 的方式

操作系统提供多种 IO 方式:

  1. 阻塞 IO(Blocking IO):进程发起 IO 后睡眠,等待 IO 完成。简单但效率低(进程被阻塞无法做其他事)。

  2. 非阻塞 IO(Non-blocking IO):进程发起 IO 后立即返回,如果数据未就绪则返回错误。进程需要轮询,CPU 利用率低。

  3. IO 多路复用(IO Multiplexing):select/poll/epoll,一个进程同时监控多个文件描述符,等待其中任意一个就绪。高并发服务器的核心技术。

  4. 异步 IO(Asynchronous IO):进程发起 IO 后立即返回,IO 在后台完成,完成后通知进程(信号或回调)。Linux 的 io_uring 是现代异步 IO 的代表。

  5. 内存映射 IO(Memory-mapped IO):mmap() 将文件映射到进程的虚拟地址空间,通过读写内存来访问文件,无需 read/write 系统调用。

5.1.4 页缓存(Page Cache)

**页缓存(Page Cache)**是 Linux 存储性能的核心机制。内核将磁盘文件的内容缓存在内存页中:

  • 读操作:先查页缓存,命中则直接从内存返回(微秒级);未命中则从磁盘读取(毫秒级),同时将读入的页放入页缓存。

  • 写操作:先写页缓存(内存),标记为脏页(Dirty),立即返回;脏页由内核线程异步写回磁盘(write-back 机制)。

页缓存的好处:

  • 减少磁盘 IO:频繁访问的文件数据驻留在内存,避免重复读盘。

  • 提高写性能:写操作只写内存不写盘,延迟极低。

  • 预读(Read-ahead):检测到顺序读时,提前读取后续页面,提高命中率。

  • 共享:多个进程映射同一个文件时,共享页缓存,节省内存。

5.2 Linux 存储栈全景

图 5-1 Linux 存储 I/O 栈全景

Linux 存储栈从上层到下层依次为:

┌─────────────────────────────────────────────────────────┐
│                    用户态应用程序                            │
│         open()/read()/write()/mmap()/fsync()             │
├─────────────────────────────────────────────────────────┤
│              系统调用接口(SCI)                              │
├─────────────────────────────────────────────────────────┤
│  VFS 层(fs/)                                             │
│  统一文件操作接口,超级块/inode/dentry/file,函数表多态      │
├─────────────────────────────────────────────────────────┤
│  文件系统层(fs/ext4/, fs/xfs/, fs/btrfs/, ...)          │
│  inode 管理、数据块映射(extent/位图/间接块)、日志        │
├─────────────────────────────────────────────────────────┤
│  页缓存层(mm/filemap.c)                                   │
│  address_space + XArray,文件内容的内存缓存、预读、回写     │
├─────────────────────────────────────────────────────────┤
│  Block 层(block/)                                         │
│  bio 构造、IO 调度(mq-deadline/kyber/bfq/none)、        │
│  blk-mq 多队列、请求队列、流量控制                           │
├─────────────────────────────────────────────────────────┤
│  块设备驱动层(drivers/block/, drivers/nvme/, ...)        │
│  与硬件交互,提交 IO 命令到设备,处理完成中断               │
├─────────────────────────────────────────────────────────┤
│                    物理硬件                                  │
│         HDD / SSD / NVMe /  RAID /  SAN                    │
└─────────────────────────────────────────────────────────┘

5.3 VFS 虚拟文件系统

5.3.1 VFS 的设计思想

图 5-2 VFS 四大核心对象关系

**VFS(Virtual File System,虚拟文件系统)**是 Linux 文件系统的核心抽象层。它的目标是:

  • 为用户态提供统一的文件操作接口(open/read/write/close 等)。

  • 支持多种具体文件系统(ext4、xfs、btrfs、vfat、nfs 等)共存。

  • 通过面向对象的函数表实现多态:VFS 定义通用接口,具体文件系统实现函数表。

VFS 定义了四个核心对象:

对象

作用

对应结构

函数表

超级块(Super Block)

一个已挂载的文件系统实例

struct super_block

struct super_operations

索引节点(inode)

一个文件的元数据

struct inode

struct inode_operations

目录项(dentry)

路径组件(文件名→inode 映射)

struct dentry

struct dentry_operations

文件(file)

一个打开的文件实例

struct file

struct file_operations

5.3.2 struct super_block — 超级块

源码位置:include/linux/fs.h

超级块代表一个已挂载的文件系统实例,存储文件系统的全局信息:

struct super_block {
    struct list_head s_list;        /* 全局超级块链表 */
    dev_t s_dev;                     /* 设备号 */
    unsigned long s_blocksize;       /* 块大小 */
    unsigned char s_blocksize_bits;  /* 块大小的位数(log2) */
    unsigned long long s_maxbytes;   /* 最大文件大小 */
    struct file_system_type *s_type; /* 文件系统类型(如 ext4) */
    const struct super_operations *s_op;  /* 超级块操作函数表 */
    unsigned long s_flags;           /* 挂载标志(MS_RDONLY 等) */
    unsigned long s_magic;           /* 文件系统魔数 */
    struct dentry *s_root;           /* 根目录的 dentry */
    struct rw_semaphore s_umount;    /* 卸载信号量 */
    int s_count;                      /* 引用计数 */
    int s_active;                     /* 活跃引用计数 */
    struct list_head s_inodes;        /* 所有 inode 链表 */
    struct list_head s_mounts;        /* 挂载点链表 */
    void *s_fs_info;                  /* 文件系统私有数据(ext4 的 ext4_sb_info) */
    /* ... */
};

超级块操作函数表 super_operations:

struct super_operations {
    struct inode *(*alloc_inode)(struct super_block *sb);  /* 分配 inode */
    void (*destroy_inode)(struct inode *);                  /* 销毁 inode */
    void (*dirty_inode)(struct inode *, int flags);         /* 标记 inode 脏 */
    int (*write_inode)(struct inode *, struct writeback_control *);  /* 写 inode 到磁盘 */
    int (*drop_inode)(struct inode *);                       /* 释放 inode */
    void (*evict_inode)(struct inode *);                     /* 清除 inode */
    void (*put_super)(struct super_block *);                 /* 释放超级块 */
    int (*sync_fs)(struct super_block *sb, int wait);        /* 同步文件系统 */
    int (*freeze_fs)(struct super_block *);                  /* 冻结文件系统 */
    int (*unfreeze_fs)(struct super_block *);                /* 解冻 */
    int (*statfs)(struct dentry *, struct kstatfs *);        /* 文件系统统计 */
    /* ... */
};

5.3.3 struct inode — 索引节点

源码位置:include/linux/fs.h

inode 是文件系统中描述一个文件的元数据结构(不包含文件名):

struct inode {
    umode_t i_mode;              /* 文件类型和权限(S_IFREG/S_IFDIR/S_IFLNK 等 + rwx) */
    unsigned short i_opflags;
    kuid_t i_uid;                 /* 所有者 UID */
    kgid_t i_gid;                 /* 所属组 GID */
    unsigned int i_flags;         /* inode 标志(S_SYNC/S_DIRSYNC 等) */

    const struct inode_operations *i_op;  /* inode 操作函数表 */
    struct super_block *i_sb;             /* 所属超级块 */
    struct address_space *i_mapping;      /* 页缓存地址空间 */
    struct address_space i_data;           /* 内嵌的地址空间(通常 i_mapping = &i_data) */

    unsigned long i_ino;          /* inode 号 */
    __u32 i_nlink;                 /* 硬链接数 */
    off_t i_size;                  /* 文件大小(字节) */
    struct timespec64 i_atime;     /* 访问时间 */
    struct timespec64 i_mtime;     /* 修改时间 */
    struct timespec64 i_ctime;     /* 状态改变时间 */

    spinlock_t i_lock;             /* inode 锁 */
    atomic_t i_count;               /* 引用计数 */
    struct hlist_node i_hash;       /* inode 哈希表节点(通过 inode 号快速查找) */
    struct list_head i_lru;         /* LRU 链表节点(用于 inode 缓存回收) */
    struct list_head i_sb_list;     /* 超级块的 inode 链表节点 */
    struct list_head i_wb_list;     /* 回写链表节点 */

    /* 块设备相关 */
    sector_t i_blocks;              /* 占用的块数(512 字节为单位) */
    unsigned short i_bytes;         /* 最后一个块的有效字节数 */

    void *i_private;                /* 文件系统私有数据(ext4 的 ext4_inode_info) */
    /* ... */
};

inode 操作函数表 inode_operations:

struct inode_operations {
    struct dentry *(*lookup)(struct inode *, struct dentry *, unsigned int);  /* 目录查找 */
    int (*create)(struct inode *, struct dentry *, umode_t, bool);             /* 创建文件 */
    int (*link)(struct dentry *, struct inode *, struct dentry *);             /* 硬链接 */
    int (*unlink)(struct inode *, struct dentry *);                             /* 删除文件 */
    int (*symlink)(struct inode *, struct dentry *, const char *);              /* 符号链接 */
    int (*mkdir)(struct inode *, struct dentry *, umode_t);                     /* 创建目录 */
    int (*rmdir)(struct inode *, struct dentry *);                              /* 删除目录 */
    int (*mknod)(struct inode *, struct dentry *, umode_t, dev_t);             /* 创建设备文件 */
    int (*rename)(struct inode *, struct dentry *, struct inode *, struct dentry *, unsigned int);  /* 重命名 */
    int (*setattr)(struct user_namespace *, struct dentry *, struct iattr *);  /* 设置属性(chmod/chown 等) */
    int (*getattr)(const struct path *, struct kstat *, u32, unsigned int);    /* 获取属性 */
    ssize_t (*listxattr)(struct dentry *, char *, size_t);                      /* 列出扩展属性 */
    /* ... */
};

设计要点:

  • i_mode 的高 4 位表示文件类型(S_IFREG 普通文件、S_IFDIR 目录、S_IFLNK 符号链接、S_IFCHR 字符设备、S_IFBLK 块设备、S_IFIFO 管道、S_IFSOCK socket),低 12 位是权限位(rwxrwxrwx + setuid/setgid/sticky)。

  • i_mapping 指向该文件的页缓存地址空间。对于普通文件,i_mapping = &i_data(内嵌的 address_space)。对于设备文件,i_mapping 指向块设备的地址空间。

  • i_private 是文件系统私有数据指针,ext4 用它指向 ext4_inode_info(包含 ext4 特有的 extent 树、预分配信息等)。这是面向对象的"继承":ext4_inode_info 内嵌 struct inode,通过 container_of 从 inode 找到 ext4_inode_info。

5.3.4 struct file — 打开的文件

struct file {
    struct path f_path;            /* 文件路径(dentry + vfsmount) */
    struct inode *f_inode;         /* 关联的 inode(f_path.dentry->d_inode 的缓存) */
    const struct file_operations *f_op;  /* 文件操作函数表 */
    unsigned int f_flags;           /* 打开标志(O_RDONLY/O_WRONLY/O_APPEND/O_NONBLOCK 等) */
    fmode_t f_mode;                 /* 访问模式(FMODE_READ/FMODE_WRITE) */
    loff_t f_pos;                   /* 当前文件偏移(读写位置) */
    struct fown_struct f_owner;     /* 所有者(用于 SIGIO 异步通知) */
    void *private_data;             /* 驱动/文件系统私有数据 */
    struct address_space *f_mapping; /* 页缓存(通常等于 inode->i_mapping) */
    /* ... */
};

文件操作函数表 file_operations:

struct file_operations {
    struct module *owner;
    loff_t (*llseek)(struct file *, loff_t, int);            /* 定位(lseek) */
    ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);  /* 读 */
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);  /* 写 */
    ssize_t (*read_iter)(struct kiocb *, struct iov_iter *);  /* 迭代读(现代接口) */
    ssize_t (*write_iter)(struct kiocb *, struct iov_iter *);  /* 迭代写(现代接口) */
    int (*iterate)(struct file *, struct dir_context *);      /* 读目录 */
    unsigned int (*poll)(struct file *, struct poll_table_struct *);  /* poll/select/epoll */
    long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long);  /* ioctl */
    long (*compat_ioctl)(struct file *, unsigned int, unsigned long);    /* 32位兼容 ioctl */
    int (*mmap)(struct file *, struct vm_area_struct *);       /* 内存映射 */
    int (*open)(struct inode *, struct file *);                /* 打开 */
    int (*flush)(struct file *, fl_owner_t id);                /* 刷新 */
    int (*release)(struct inode *, struct file *);             /* 关闭 */
    int (*fsync)(struct file *, loff_t, loff_t, int datasync);  /* 同步到磁盘 */
    int (*aio_fsync)(struct kiocb *, int datasync);            /* 异步 fsync */
    int (*fadvise)(struct file *, loff_t, loff_t, int);       /* 预读建议 */
    /* ... */
};

关键设计:

  • f_pos 是文件偏移,每次 read/write 后自动更新。lseek() 可以修改它。O_APPEND 模式下每次写都定位到文件末尾。

  • private_data 是驱动/文件系统的私有数据,设备驱动常用它存储设备上下文。

  • 现代内核推荐使用 read_iter/write_iter 代替传统的 read/write,因为迭代器接口支持分散/聚集 IO(多个缓冲区)和异步 IO(io_uring)。

5.3.5 struct address_space — 页缓存地址空间

struct address_space {
    struct inode *host;            /* 所属 inode */
    struct xarray i_pages;         /* 页缓存(XArray,替代了原来的 radix_tree) */
    gfp_t gfp_mask;                /* 分配页的标志 */
    atomic_t i_mmap_writable;      /* 可写映射计数 */
    struct rb_root_cached i_mmap;  /* 映射该文件的所有 VMA 的区间树(优先搜索树) */
    struct rw_semaphore i_mmap_rwsem; /* 保护 i_mmap */
    unsigned long nrpages;          /* 缓存页数 */
    const struct address_space_operations *a_ops;  /* 地址空间操作函数表 */
    unsigned long flags;            /* 标志(AS_EIO/AS_ENOSPC 等) */
    /* ... */
};

地址空间操作函数表 address_space_operations:

struct address_space_operations {
    int (*writepage)(struct page *, struct writeback_control *);       /* 写一页 */
    int (*readpage)(struct file *, struct page *);                     /* 读一页 */
    int (*writepages)(struct address_space *, struct writeback_control *); /* 批量回写 */
    int (*set_page_dirty)(struct page *);                              /* 标记页脏 */
    int (*readpages)(struct file *, struct address_space *,
                     struct list_head *, unsigned int);                /* 旧式预读接口 */
    void (*readahead)(struct readahead_control *);                     /* 预读接口 */
    int (*write_begin)(struct file *, struct address_space *,
                       loff_t, unsigned int, unsigned int,
                       struct page **, void **);
    int (*write_end)(struct file *, struct address_space *,
                     loff_t, unsigned int, unsigned int,
                     struct page *, void *);
    void (*invalidatepage)(struct page *, unsigned int, unsigned int);
    int (*releasepage)(struct page *, gfp_t);
    sector_t (*bmap)(struct address_space *, sector_t);
    void (*freepage)(struct page *);
    /* ... */
};

页缓存的核心数据结构 XArray:

  • 早期内核使用 radix tree(基数树)管理页缓存,5.15 版本已替换为 XArray(更高效、更简洁的实现)。

  • XArray 是一个稀疏数组,索引是文件内的页偏移(pgoff_t),值是 struct page 指针。

  • 支持标记(mark):如 PAGECACHE_TAG_DIRTY(脏页)、PAGECACHE_TAG_WRITEBACK(正在回写),可以快速遍历所有脏页。

  • 查找会沿 XArray 的分层节点按索引逐级下降;树高受索引位宽限制,并随当前索引范围变化。它不是“无论规模都只访问一两次”的固定常数操作。

5.4 读文件完整流程

以 read() 系统调用为例,跟踪从用户态到磁盘的完整路径:

5.4.1 系统调用入口

用户态 read(fd, buf, count)
  │
  ▼ 系统调用
sys_read() → ksys_read()
  │
  ▼
vfs_read()  —— VFS 层
  │  1. 检查文件描述符 fd 是否有效
  │  2. 检查文件访问模式(是否可读)
  │  3. 调用 file->f_op->read 或 read_iter
  │
  ▼

5.4.2 VFS 层:vfs_read()

ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
    /* 检查访问权限 */
    if (!(file->f_mode & FMODE_READ))
        return -EBADF;

    /* 检查文件位置 */
    if (unlikely(!access_ok(buf, count)))
        return -EFAULT;

    /* 调用文件系统的读操作 */
    if (file->f_op->read)
        ret = file->f_op->read(file, buf, count, pos);
    else if (file->f_op->read_iter)
        ret = new_sync_read(file, buf, count, pos);
    else
        ret = -EINVAL;

    /* 通知监控、更新统计 */
    if (ret > 0) {
        fsnotify_access(file);
        add_rchar(current, ret);
    }
    return ret;
}

5.4.3 通用读:generic_file_read_iter() → filemap_read()

对于普通文件,ext4 的 file_operations.read_iter 指向 generic_file_read_iter(),最终调用 filemap_read():

generic_file_read_iter()
  → filemap_read()  —— 页缓存层(mm/filemap.c)
    │
    ├─ 1. 计算文件偏移对应的起始页索引和结束页索引
    │
    ├─ 2. 遍历需要读取的每一页:
    │     ├─ 在页缓存(XArray)中查找页
    │     ├─ 命中且页有效(Uptodate)→ 直接从内存页复制数据到用户态
    │     ├─ 未命中 → 分配页,发起磁盘读
    │     │     → page_cache_sync_readahead() / page_cache_async_readahead()
    │     │     → 预读算法:检测顺序读模式,提前读取后续页面
    │     │     → 单页补读走 mapping->a_ops->readpage();成批预读走 a_ops->readahead()
    │     │     → 构造 bio,提交给块层
    │     │     → 等待 IO 完成(页锁)
    │     └─ 数据从页缓存复制到用户态缓冲区
    │
    └─ 3. 更新文件偏移 f_pos,返回读取字节数

5.4.4 预读(Read-ahead)机制

预读是提高读性能的关键技术:

  • 当检测到进程在顺序读文件时(连续访问的页号递增),内核会提前读取后续页面(通常一次预读 128KB~2MB)。

  • 预读是异步的:预读请求提交后,进程只需等待当前需要的页,后续页在后台读取。

  • 预读窗口动态调整:顺序读时窗口逐渐增大(最大 2MB),随机读时窗口缩小甚至禁用预读。

  • 预读的核心函数:ondemand_readahead()(mm/readahead.c),根据访问模式计算预读窗口大小。

5.4.5 文件系统层:ext4_readpage() / ext4_readahead()

在 Linux 5.15.220 中,ext4 仍使用 page 接口而不是后续版本的 folio 读接口:单页读取进入 ext4_readpage(),预读进入 ext4_readahead();两者都复用 ext4_mpage_readpages() 完成普通文件数据读取。主要工作包括:

  1. 通过 ext4 的 extent 树查找文件逻辑块对应的磁盘物理块号。

  2. 构造 bio(块 IO 请求),将连续的物理块合并为一个 bio。

  3. 调用 submit_bio() 提交给块层。

extent 映射:ext4 使用 extent(区段)代替传统的间接块映射。一个 extent 描述一段连续的物理块(起始块号 + 长度),比间接块更高效(大文件只需要少量 extent,而间接块需要多级索引块)。extent 存储在 inode 的 i_block 字段中(内联 extent,最多 4 个),超过 4 个 extent 时使用 extent 树(B+ 树)存储。

5.4.6 块层:submit_bio() → blk_mq_submit_bio()

submit_bio()  —— block/blk-core.c
  │  1. IO 统计(PGPGIN/PGPGOUT)
  │  2. cgroup IO 节流检查
  │
  ▼
submit_bio_noacct()
  → __submit_bio()
    → blk_mq_submit_bio()  —— 多队列块层
      │
      ├─ 1. 为 bio 分配请求标签(tag)
      ├─ 2. 将 bio 转换为 request(或直接 bio-based)
      ├─ 3. 进入 IO 调度器(如果启用了调度器)
      │     - mq-deadline:按 LBA 排序 + 截止时间保证
      │     - kyber:基于延迟的调度
      │     - bfq:预算公平队列,按进程分配 IO 带宽
      │     - none:不调度,直接提交(NVMe 默认)
      ├─ 4. 调用驱动的 queue_rq() 将请求提交到硬件队列
      └─ 5. 触发硬件 DMA 传输

5.4.7 驱动与硬件

块设备驱动(如 NVMe 驱动):

  1. 将 block 层的 request 转换为设备特定的命令(如 NVMe 的 SQE,Submission Queue Entry)。

  2. 将命令写入设备的提交队列(Submission Queue),通过门铃寄存器(Doorbell)通知设备。

  3. 设备执行 DMA,将数据从磁盘传输到内存(读)或从内存传输到磁盘(写)。

  4. 完成后,设备将完成队列条目(CQE,Completion Queue Entry)写入完成队列,触发中断。

  5. 驱动中断处理程序处理 CQE,调用 bio_endio() 通知 bio 完成。

  6. bio_endio() 调用 bio 的 bi_end_io 回调(对于读,是 mpage_end_io,标记页为 Uptodate,解锁页,唤醒等待的进程)。

5.4.8 完成与返回

IO 完成后:

  1. 页被标记为 PG_uptodate(数据有效)。

  2. 页锁被释放,等待在页上的进程被唤醒。

  3. filemap_read() 将数据从页缓存复制到用户态缓冲区。

  4. 更新文件偏移 f_pos。

  5. read() 返回复制的字节数。

5.5 写文件完整流程

5.5.1 Write-back 机制

图 5-4 write-back 写回机制

Linux 默认使用 write-back(写回) 机制:

  • 写操作只写页缓存(内存),标记页为脏(Dirty),立即返回。

  • 脏页由内核线程(flusher/bdi_writeback)异步批量写回磁盘。

  • 缓冲写把多数前台写入的等待从设备完成路径移到页缓存更新路径;实际加速幅度取决于介质、队列、写入模式以及是否要求同步持久化,不能用固定倍数概括。

  • 代价是异常断电时可能丢失最近的写入(未刷盘的脏页)。

脏页回写由一组阈值共同控制(默认值见 mm/page-writeback.c,参数语义见 Documentation/admin-guide/sysctl/vm.rst):

  1. 周期性回写:flusher 线程每 dirty_writeback_centisecs(默认 500,即 5 秒)唤醒一次,把脏龄超过 dirty_expire_centisecs(默认 3000,即 30 秒)的脏页写回。要区分这两个值:5 秒是唤醒周期,30 秒是"多脏才算老、本次需要回写"的年龄门槛。

  2. 后台比例阈值:脏页占可用内存的比例达到 dirty_background_ratio(默认 10%)时,后台 flusher 开始异步回写,写进程本身不被阻塞。

  3. 硬比例阈值:达到 vm_dirty_ratio(默认 20%)时,正在写的进程自己进入同步回写、被限流(throttle),直到脏页比例降下来,以此给磁盘回写留出追赶时间。

  4. 内存压力:内存不足时,页面回收路径遇到脏页也会触发回写。

  5. 主动同步:fsync()/fdatasync()/sync() 系统调用主动回写。

5.5.2 写流程

用户态 write(fd, buf, count)
  │
  ▼
sys_write() → ksys_write() → vfs_write()
  │
  ▼
ext4_file_write_iter() → ext4_buffered_write_iter()
  → generic_perform_write()  —— 页缓存层
  │
  ├─ 1. ext4_write_checks() 检查写入范围、追加等条件
  ├─ 2. 按页循环:
  │     ├─ a_ops->write_begin() 准备并锁定页缓存页,必要时读取部分页旧数据
  │     ├─ copy_page_from_iter_atomic() 把用户数据复制到页
  │     ├─ a_ops->write_end() 完成文件系统记账,更新 i_size 并设置页脏状态
  │     └─ balance_dirty_pages_ratelimited() 在脏页过多时限流写进程
  │
  ├─ 3. 更新 ki_pos;若是 O_SYNC/O_DSYNC,再由 generic_write_sync() 等待持久化
  └─ 4. 普通缓冲写通常返回已复制的字节数,不等待数据真正落盘
        │
        ▼ (异步,稍后由 flusher 线程回写)
  writeback_sb_inodes() → writeback_single_inode()
    → do_writepages() → mapping->a_ops->writepages()
      → ext4_writepages()
        → mpage_prepare_extent_to_map() 收集待写区段
        → mpage_map_and_submit_extent() / mpage_map_and_submit_buffers() 映射并提交页面
        → submit_bio() → 块层 → 驱动 → 磁盘
        → 完成后清除页的 Dirty 标志

5.5.3 fsync():强制同步

fsync(fd) 强制将文件的脏页和元数据(inode)同步到磁盘:

  1. 调用 filemap_fdatawrite() 提交文件所有脏页的写回。

  2. 等待所有写回完成(filemap_fdatawait())。

  3. 调用 file->f_op->fsync() 同步 inode 元数据到磁盘。

  4. 对于支持 journal(日志)的文件系统(ext4/xfs),还需要等待日志提交。

fdatasync() 是 fsync() 的轻量版本,只同步数据和必要的元数据(如文件大小),不同步访问时间等非必要元数据,性能更好。

5.6 块层与 IO 调度

5.6.1 struct bio — 块 IO 请求

图 5-5 bio 的 scatter-gather 结构

源码位置:include/linux/blk_types.h

bio(block IO)是块层的核心数据结构,描述一次块 IO 请求:

struct bio {
    struct bio *bi_next;            /* 请求队列链表 */
    struct block_device *bi_bdev;   /* 目标块设备 */
    unsigned int bi_opf;            /* 操作类型和标志(REQ_OP_READ/WRITE/DISCARD 等) */
    unsigned short bi_flags;        /* BIO_* 标志 */
    unsigned short bi_ioprio;       /* IO 优先级 */
    blk_status_t bi_status;         /* 完成状态 */
    atomic_t __bi_remaining;        /* 剩余未完成的分段数(bio 被拆分时使用) */

    struct bvec_iter bi_iter;       /* 当前迭代位置(扇区号、段索引、偏移) */

    bio_end_io_t *bi_end_io;        /* IO 完成回调函数 */
    void *bi_private;                /* 回调函数的私有数据 */

    unsigned short bi_vcnt;          /* bio_vec 数量 */
    unsigned short bi_max_vecs;      /* 最大 bio_vec 数 */
    struct bio_vec *bi_io_vec;       /* 散列表(指向内存页的数组) */
    struct bio_vec bi_inline_vecs[]; /* 内联的 bio_vec(小请求避免二次分配) */
};

struct bio_vec — 散列表项:

struct bio_vec {
    struct page *bv_page;    /* 指向内存页 */
    unsigned int bv_len;     /* 数据长度 */
    unsigned int bv_offset;  /* 页内偏移 */
};

关键设计:

  • bi_io_vec 是一个 bio_vec 数组,每个元素描述一个内存页中的一段数据。这使得 bio 可以描述分散在多个物理页中的数据(scatter-gather DMA),无需数据拷贝。

  • bi_iter 记录当前处理到哪个扇区、哪个 bio_vec,支持 bio 被设备驱动分段处理(如设备一次只能处理一定大小的 IO)。

  • bi_end_io 是完成回调,IO 完成后由驱动调用,通知上层(如标记页 Uptodate、唤醒等待进程)。

  • bi_inline_vecs 是内联的 bio_vec 数组,对于小 IO(少于内联数量)不需要额外分配 bio_vec 数组,减少内存分配开销。

5.6.2 submit_bio() 详解

源码位置:block/blk-core.c

blk_qc_t submit_bio(struct bio *bio)
{
    /* cgroup IO 节流:如果 bio 属于被限流的 cgroup,延迟提交 */
    if (blkcg_punt_bio_submit(bio))
        return BLK_QC_T_NONE;

    /* IO 统计 */
    if (bio_has_data(bio)) {
        unsigned int count;
        if (op_is_write(bio_op(bio)))
            count_vm_events(PGPGOUT, count);  /* 写扇区数 */
        else {
            task_io_account_read(bio->bi_iter.bi_size);
            count_vm_events(PGPGIN, count);   /* 读扇区数 */
        }
    }

    /* 如果是读操作且属于 workingset(活跃工作集),标记内存压力 */
    if (bio_op(bio) == REQ_OP_READ && bio_flagged(bio, BIO_WORKINGSET)) {
        psi_memstall_enter(&pflags);
        ret = submit_bio_noacct(bio);
        psi_memstall_leave(&pflags);
        return ret;
    }

    return submit_bio_noacct(bio);
}

submit_bio_noacct() → __submit_bio() → blk_mq_submit_bio(),进入多队列块层。

5.6.3 blk-mq 多队列块层

图 5-6 blk-mq 多队列块层

传统的单队列块层(Single Queue)有一个全局请求队列和锁,在多 CPU 高并发下成为瓶颈。blk-mq(Multi-Queue)从 3.13 版本引入,5.15 已成为默认(单队列已移除):

┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│  CPU 0   │ │  CPU 1   │ │  CPU 2   │ │  CPU 3   │
│ 提交 bio  │ │ 提交 bio  │ │ 提交 bio  │ │ 提交 bio  │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
     │              │              │              │
     ▼              ▼              ▼              ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 软件队列0 │ │ 软件队列1 │ │ 软件队列2 │ │ 软件队列3 │  per-CPU,无锁
│(blk_mq_ctx)│ │(blk_mq_ctx)│ │(blk_mq_ctx)│ │(blk_mq_ctx)│
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
     │              │              │              │
     └──────────────┴──────┬───────┴──────────────┘
                           │ 映射(CPU → 硬件队列)
                           ▼
                    ┌─────────────┐
                    │  硬件队列 0  │  → 驱动 queue_rq() → 设备
                    │  硬件队列 1  │  → 驱动 queue_rq() → 设备
                    │  硬件队列 2  │  → 驱动 queue_rq() → 设备
                    │  硬件队列 3  │  → 驱动 queue_rq() → 设备
                    └─────────────┘

关键设计:

  • 软件队列(Software Queue):每 CPU 一个,提交 bio 时无需加锁(per-CPU 数据)。

  • 硬件队列(Hardware Queue):对应设备的硬件提交队列(如 NVMe 的 Submission Queue),数量由设备决定(NVMe 通常支持 64 个队列)。

  • 映射:软件队列到硬件队列的映射(默认是 CPU 到硬件队列的轮询或亲和映射),减少硬件队列的锁竞争。

  • IO 调度器:可以在软件队列和硬件队列之间插入 IO 调度器(mq-deadline/kyber/bfq),对请求排序合并。对于 NVMe 等高性能设备,通常使用 none(不调度),直接提交。

5.6.4 IO 调度器

调度器

特点

适用场景

none

不调度,按提交顺序直接提交

NVMe SSD(低延迟、高 IOPS)

mq-deadline

按 LBA 排序合并,同时保证每个请求有截止时间(读 500ms/写 5s),避免饥饿

SSD(默认)

kyber

基于设备实际延迟动态调整读写队列深度,目标是控制延迟

对延迟敏感的 SSD

bfq

Budget Fair Queueing,按进程分配 IO 预算(带宽),类似 CFS 的思想,提供更好的交互体验

机械硬盘、桌面系统、需要 QoS 的场景

IO 调度的核心目标:

  1. 排序:按磁盘 LBA(逻辑块地址)排序,减少机械硬盘的寻道时间(对 SSD 意义不大但仍有合并作用)。

  2. 合并:将相邻的 IO 请求合并为一个大请求,减少设备交互次数。

  3. 公平性:保证每个进程/请求都能获得合理的 IO 带宽,不饿死。

  4. 延迟控制:保证请求在可接受的时间内完成。

5.7 文件系统实例:ext4 简介

ext4(Fourth Extended File System)是 Linux 最常用的文件系统,是 ext3 的后继者。关键特性:

  1. Extent(区段)映射:用 extent(起始块号 + 长度)代替间接块映射,大文件更高效。extent 存储在 inode 的 i_block 中(内联 4 个 extent),超过则用 extent 树(B+ 树)。

  2. 日志(Journaling):元数据日志(默认 ordered 模式),崩溃后快速恢复,无需漫长的 fsck。日志可以是文件系统内的 inode,也可以是外部设备。

  3. 延迟分配(Delayed Allocation):写文件时不立即分配磁盘块,而是延迟到脏页回写时才分配。这样可以积累更多数据,分配更大的连续块,减少碎片。

  4. 多块分配(Multiblock Allocator):一次调用分配多个连续块,而不是逐块分配,提高分配效率和连续性。

  5. 大文件支持:最大文件大小 16TB(4KB 块),最大文件系统大小 1EB。

  6. 时间戳精度:纳秒级时间戳(ext3 是秒级)。

  7. 快速 fsck:未使用的块组跳过检查,fsck 速度大幅提升。

ext4 的磁盘布局:

┌──────────────────────────────────────────────┐
│  Block Group 0        Block Group 1   ...    │
│ ┌──────────────────┐ ┌──────────────────┐     │
│ │ Super Block      │ │ Super Block(备份)│     │
│ │ Group Descriptors│ │ Group Desc(备份) │     │
│ │ Block Bitmap     │ │ Block Bitmap     │     │
│ │ Inode Bitmap     │ │ Inode Bitmap     │     │
│ │ Inode Table      │ │ Inode Table      │     │
│ │ Data Blocks      │ │ Data Blocks      │     │
│ └──────────────────┘ └──────────────────┘     │
└──────────────────────────────────────────────┘

文件系统被划分为多个块组(Block Group),每个块组包含自己的块位图、inode 位图、inode 表和数据块。块组的设计减少了文件数据和元数据之间的寻道距离(同一文件的 inode 和数据块尽量在同一个块组)。

5.8 存储子系统总结

用户态 open/read/write/mmap/fsync
        │
        ▼
VFS(统一接口,超级块/inode/dentry/file,函数表多态)
        │
        ▼
具体文件系统(ext4/xfs/btrfs)
  inode 元数据 + extent/块映射 + 日志
        │
        ▼
Page Cache(address_space + XArray)
  读:命中即返回,未命中读磁盘 + 预读
  写:写缓存标记 Dirty,异步回写(write-back)
        │
        ▼
Block 层(bio 构造 + IO 调度 + blk-mq 多队列)
  bio:scatter-gather 描述分散内存页
  IO 调度:排序/合并/公平性/延迟控制
  blk-mq:per-CPU 软件队列 + 硬件队列,减少锁竞争
        │
        ▼
块设备驱动(NVMe/SATA/SCSI)
  命令构造 + DMA + 中断完成
        │
        ▼
存储硬件(HDD/SSD/NVMe)

本章要点:

  1. 分层抽象:VFS → 文件系统 → 页缓存 → 块层 → 驱动,每层解决不同问题,通过函数表多态支持多种实现。

  2. 页缓存 + write-back:用内存缓存磁盘数据,写操作只写内存异步回写,将磁盘级延迟(毫秒)降低到内存级(微秒)。

  3. 预读与延迟分配:顺序读预取后续页,写延迟分配磁盘块,通过预测和批处理提高效率。

  4. bio scatter-gather:用 bio_vec 数组描述分散内存页,支持 DMA 直接传输,无需数据拷贝。

  5. blk-mq 多队列:per-CPU 软件队列 + 多硬件队列,消除全局锁瓶颈,适配现代多核 CPU 和高性能 NVMe 设备。

  6. 日志与一致性:文件系统日志(journal)保证崩溃后元数据一致性,快速恢复。


文章目录

DISCUSSION

评论

0 条已通过评论

留下评论

评论提交后需要审核。邮箱仅用于联系,不会公开。