编程 Linux 7.2 存储栈深度实战:当两行代码让 IOPS 提升 5%——从 iomap 框架到 io_uring 零拷贝、从 Block Layer 到 NVMe 高并发存储引擎的生产级完全指南

2026-06-20 07:28:05 +0800 CST views 8

Linux 7.2 存储栈深度实战:当两行代码让 IOPS 提升 5%——从 iomap 框架到 io_uring 零拷贝、从 Block Layer 到 NVMe 高并发存储引擎的生产级完全指南

引言:两行代码的威力

2026 年 6 月,Linux 7.2 开发内核合并了一项看似不起眼的补丁。字节跳动工程师 Fengnan Chang 仅调整了 iomap_iter() 函数中两行代码的位置——把一个无用的 memset 操作从热路径上移除——就让 EXT4 和 XFS 文件系统在 NVMe + io_uring 的 4K 随机读取场景下,IOPS 提升约 5%。

5% 听起来不多?在百万级 IOPS 的数据中心里,这意味着每秒多出五万次存储操作。而代价是零——没有新硬件、没有架构变更、没有额外的内存开销。

这件事值得深挖,因为它撕开了 Linux 存储栈的一角,让我们看到一个被忽视的事实:在 I/O 热路径上,哪怕一次不必要的内存操作,在百万次累积下都是性能杀手。

本文将从这次补丁出发,完整拆解 Linux 存储栈的关键层次,从 iomap 框架到 io_uring 异步引擎,从 Block Layer 到 NVMe 驱动,给出生产级可用的理解框架和实战方案。


一、那次补丁到底改了什么

1.1 背景:iomap 框架的角色

Linux 文件系统的读写操作,最终都要把「文件偏移量」映射到「磁盘物理位置」。这个映射过程,在传统架构中由每个文件系统自己实现——EXT4 有 ext4_map_blocks(),XFS 有 xfs_bmapi()。代码重复、bug 频出、维护困难。

iomap 框架(自 Linux 4.8 引入)的目标是统一这一层:提供一个通用的「迭代式块映射」接口,文件系统只需实现回调函数来提供映射信息,通用逻辑由 iomap 处理。EXT4、XFS、GFS2、NFS 等文件系统都已迁移到 iomap。

核心数据结构:

// iomap 迭代器——承载一次 I/O 操作的完整上下文
struct iomap_iter {
    struct inode    *inode;        // 目标 inode
    loff_t          pos;          // 当前文件偏移
    u64             len;          // 剩余 I/O 长度
    u64             processed;    // 已处理长度
    unsigned        flags;        // 操作标志(读/写/脏页等)
    struct iomap    iomap;        // 当前映射结果
    struct iomap    srcmap;       // 写时复制源映射
};

1.2 问题代码

iomap_iter() 是 iomap 框架的核心迭代函数。每次调用它,文件系统回调会被触发以获取下一个块映射,然后 iomap 根据映射结果执行实际的 I/O 操作。

问题出在这里——原代码在每次迭代结束时,都会清零迭代器中的映射信息:

// 修改前:每次迭代结束后清理 iomap 映射
static inline int iomap_iter(struct iomap_iter *iter,
                              const struct iomap_ops *ops)
{
    // ... 执行 I/O 操作 ...
    
    // 迭代结束后清理映射信息
    memset(&iter->iomap, 0, sizeof(iter->iomap));   // ← 问题行
    memset(&iter->srcmap, 0, sizeof(iter->srcmap)); // ← 问题行
    
    // 更新剩余长度,准备下一次迭代
    iter->pos += iter->processed;
    iter->len -= iter->processed;
    
    return (iter->len > 0) ? 1 : 0;
}

看起来很合理——清理状态,避免脏数据。但这里有一个致命的逻辑漏洞:

调用者在 iomap_iter() 返回 0(迭代结束)后,会直接丢弃整个迭代器。既然迭代器都要被销毁了,何必再花 CPU 周期去清零两个即将消亡的结构体?

1.3 修复方案

Chang 的修复极其简洁——把 memset 移到迭代开始前,且仅在需要继续迭代时才执行:

// 修改后:仅在需要继续迭代时才清理
static inline int iomap_iter(struct iomap_iter *iter,
                              const struct iomap_ops *ops)
{
    bool need_iter = (iter->len > 0);
    
    // 仅在需要下一次迭代时才清理上一次的映射
    if (need_iter) {
        memset(&iter->iomap, 0, sizeof(iter->iomap));
        memset(&iter->srcmap, 0, sizeof(iter->srcmap));
    }
    
    // ... 执行 I/O 操作 ...
    
    iter->pos += iter->processed;
    iter->len -= iter->processed;
    
    return need_iter ? 1 : 0;
}

1.4 为什么 5% 这么多

memset(&iter->iomap, 0, sizeof(iter->iomap)) 清零一个 struct iomap——这个结构体包含多个字段,大小约 80-100 字节。加上 srcmap,一次清零约 200 字节。

在 4K 随机读取场景下,每个 I/O 请求只涉及一个 4KB 块,因此每次 I/O 操作都会触发一次 iomap_iter() 调用。当 IOPS 达到百万级时:

  • 每秒百万次 × 200 字节 = 200MB/s 的无效内存写入
  • 这 200MB/s 的内存带宽被浪费在写零上,而非做有用的数据搬运
  • 现代 CPU 的 L2/L3 缓存被这些无意义的写操作污染,导致有用数据的缓存命中率下降

在 I/O 密集型工作负载中,内存带宽是共享资源。任何不必要的内存操作都会挤占 I/O 路径上真正需要的数据传输带宽。

这就是两行代码让 IOPS 提升 5% 的底层原因。


二、Linux 存储栈全景:从 VFS 到 NVMe

要真正理解这次优化为什么有效,需要理解 Linux 存储栈的完整架构。让我们从上到下走一遍。

2.1 分层架构

┌──────────────────────────────────────────────┐
│              用户空间应用                       │
│   read() / write() / io_uring_submit()       │
└──────────────────┬───────────────────────────┘
                   │ 系统调用 / io_uring 共享内存
┌──────────────────▼───────────────────────────┐
│              VFS 层                           │
│   虚拟文件系统:统一文件操作接口               │
│   sys_read / sys_write / io_uring_rw         │
└──────────────────┬───────────────────────────┘
                   │
┌──────────────────▼───────────────────────────┐
│           文件系统层                           │
│   EXT4 / XFS / Btrfs / ...                   │
│   ┌─────────────────────────────┐            │
│   │      iomap 框架              │ ← 本次补丁 │
│   │   统一块映射迭代器           │            │
│   └─────────────────────────────┘            │
└──────────────────┬───────────────────────────┘
                   │
┌──────────────────▼───────────────────────────┐
│           页缓存层(Page Cache)              │
│   缓存文件数据,减少磁盘访问                   │
└──────────────────┬───────────────────────────┘
                   │
┌──────────────────▼───────────────────────────┐
│           Block Layer(块设备层)              │
│   I/O 调度 / 合并 / 重排                      │
│   mq-deadline / bfq / none                    │
└──────────────────┬───────────────────────────┘
                   │
┌──────────────────▼───────────────────────────┐
│           NVMe 驱动层                         │
│   NVMe 命令构造 / 提交队列管理                │
└──────────────────┬───────────────────────────┘
                   │
┌──────────────────▼───────────────────────────┐
│           NVMe 硬件                           │
│   SSD 控制器 / 闪存芯片                       │
└──────────────────────────────────────────────┘

2.2 I/O 请求的生命周期

一次 4K 随机读取的完整旅程:

  1. 用户态:应用调用 read() 或通过 io_uring 提交读请求
  2. VFSvfs_read()__vfs_read() → 调用文件系统的 read_iter 方法
  3. 文件系统 + iomapext4_file_read_iter()iomap_dio_rw() / iomap_readpage()
    • iomap_iter() 被调用,文件系统回调将文件偏移映射到磁盘块号
  4. 页缓存:如果数据已在缓存中,直接返回;否则分配新页面
  5. Block Layer:构造 bio 结构,提交给 I/O 调度器
  6. NVMe 驱动:将 bio 转换为 NVMe Read 命令,写入提交队列(SQ)
  7. NVMe 硬件:SSD 控制器执行读操作,通过完成队列(CQ)通知结果
  8. 返回路径:中断或轮询检测到完成 → 通知上层 → 数据到达用户态

在这个路径中,iomap_iter() 处于第 3 步的热路径上——每次 I/O 都必须经过它。这就是为什么在这个位置减少哪怕一次 memset 操作,累积效果都如此显著。


三、iomap 框架深度解析

3.1 为什么需要 iomap

在 iomap 出现之前,Linux 文件系统的块映射逻辑是各自实现的:

// EXT4 的映射方式(简化)
int ext4_map_blocks(struct inode *inode, struct ext4_map_blocks *map)
{
    // 直接操作 ext4 的 extent 树
    // 处理 indirect block 映射
    // 管理延迟分配、大分配等特性
    // ... 数百行特定逻辑 ...
}

// XFS 的映射方式(简化)
int xfs_bmapi(struct xfs_inode *ip, xfs_fileoff_t bno, xfs_filblks_t len, ...)
{
    // 操作 XFS 的 B+树 extent 映射
    // 处理分配、转换、延迟分配
    // ... 数百行特定逻辑 ...
}

这导致大量重复代码——页缓存读取、直接 I/O、写回、folio 操作等逻辑在每个文件系统中都有类似但不同的实现。iomap 的核心思想是:让文件系统只提供「块映射」信息,通用 I/O 逻辑由框架统一处理。

3.2 iomap 的回调接口

文件系统需要实现 iomap_ops 回调:

struct iomap_ops {
    // 获取块映射:给定文件偏移和长度,返回对应的磁盘位置
    int (*iomap_begin)(struct inode *inode, loff_t pos, loff_t length,
                       unsigned flags, struct iomap *iomap,
                       struct iomap *srcmap);
    
    // 释放映射资源(可选)
    int (*iomap_end)(struct inode *inode, loff_t pos, loff_t length,
                     ssize_t written, unsigned flags,
                     struct iomap *iomap);
};

iomap_begin 返回的映射信息:

struct iomap {
    u64              addr;      // 磁盘起始偏移(字节),IOMAP_NULL_ADDR 表示未分配
    loff_t           offset;    // 文件起始偏移
    u64              length;    // 映射覆盖的长度
    u16              type;      // 映射类型
    u16              flags;     // 映射标志
    struct block_device *bdev;  // 目标块设备
    struct dax_device   *dax_dev; // DAX 设备(NVDIMM 等)
    void             *private;  // 文件系统私有数据
    const struct iomap_folio_ops *folio_ops; // folio 操作回调
};

3.3 iomap_iter 的完整工作流

// iomap_iter 核心循环(简化展示完整逻辑)
int iomap_iter(struct iomap_iter *iter, const struct iomap_ops *ops)
{
    int ret;
    
    // 1. 如果还有剩余数据需要处理,获取新的映射
    if (iter->len > 0) {
        // 清理上一次的映射(修复后的位置)
        memset(&iter->iomap, 0, sizeof(iter->iomap));
        memset(&iter->srcmap, 0, sizeof(iter->srcmap));
        
        // 调用文件系统的 iomap_begin 回调
        ret = ops->iomap_begin(iter->inode, iter->pos, iter->len,
                               iter->flags, &iter->iomap, &iter->srcmap);
        if (ret < 0)
            return ret;
    }
    
    // 2. 根据映射类型执行 I/O 操作
    //    读取:从页缓存或磁盘读取数据
    //    写入:将数据写入页缓存或直接 I/O
    //    空洞:填零
    //    未分配:处理写时复制等
    iter->processed = iomap_apply(iter);
    
    // 3. 调用 iomap_end 回调(如果存在)
    if (ops->iomap_end) {
        ret = ops->iomap_end(iter->inode, iter->pos, 
                             iomap_length(iter), iter->processed,
                             iter->flags, &iter->iomap);
    }
    
    // 4. 更新迭代器位置
    iter->pos += iter->processed;
    iter->len -= iter->processed;
    
    return (iter->len > 0) ? 1 : 0;
}

3.4 EXT4 的 iomap 实现

// EXT4 iomap_ops 实现(简化)
static int ext4_iomap_begin(struct inode *inode, loff_t offset, loff_t length,
                            unsigned flags, struct iomap *iomap,
                            struct iomap *srcmap)
{
    struct ext4_map_blocks map;
    int ret;
    
    map.m_lblk = offset >> inode->i_blkbits;
    map.m_len = length >> inode->i_blkbits;
    map.m_flags = 0;
    
    // 查询 extent 树获取映射
    ret = ext4_map_blocks(NULL, inode, &map, 0);
    if (ret < 0)
        return ret;
    
    // 将 ext4 映射转换为 iomap 格式
    if (map.m_flags & EXT4_MAP_MAPPED) {
        iomap->type = IOMAP_MAPPED;
        iomap->addr = (u64)map.m_pblk << inode->i_blkbits;
    } else if (map.m_flags & EXT4_MAP_UNWRITTEN) {
        iomap->type = IOMAP_UNWRITTEN;
        iomap->addr = (u64)map.m_pblk << inode->i_blkbits;
    } else {
        iomap->type = IOMAP_HOLE;
        iomap->addr = IOMAP_NULL_ADDR;
    }
    
    iomap->offset = offset;
    iomap->length = (u64)map.m_len << inode->i_blkbits;
    iomap->bdev = inode->i_sb->s_bdev;
    
    return 0;
}

const struct iomap_ops ext4_iomap_ops = {
    .iomap_begin = ext4_iomap_begin,
};

3.5 关键洞察:热路径上的每一微秒都重要

回到那次补丁——为什么 memset 的影响这么大?让我们算一笔账:

  • 一次 memset(&iter->iomap, 0, sizeof(iter->iomap)):约 100 字节
  • 现代 CPU 的 memset 性能:约 30-50 GB/s(L2 命中时)
  • 100 字节 memset 的耗时:约 2-3 纳秒

单次 2-3 纳秒,看起来微不足道。但在百万 IOPS 的场景下:

  • 每秒额外 200 万纳秒(2 毫秒)的 CPU 时间花在无用的清零上
  • 更重要的是缓存污染——这些写操作会将 iter->iomap 结构体写入 L1/L2 缓存,驱逐原本驻留在缓存中的热点数据(如页缓存的 radix tree 节点、bio 结构等)
  • 在随机 4K I/O 场景中,缓存命中率对 IOPS 的影响是指数级的——缓存未命中一次可能导致数十纳秒的内存访问延迟

这就是 Linux 内核性能调优的第一性原理:在热路径上,没有「微小到可以忽略」的操作。


四、io_uring:存储 I/O 的终极加速器

iomap 补丁在 io_uring 场景下效果最显著,这并非巧合。io_uring 彻底改变了用户态和内核态之间的 I/O 交互方式,让存储性能逼近硬件极限。

4.1 从系统调用到共享内存:io_uring 的核心思想

传统 I/O 路径的瓶颈:

用户态 read() → 系统调用(上下文切换)→ VFS → 文件系统 → Block Layer → 驱动
                ↑                                          ↓
                ← 中断/唤醒(又一次上下文切换)← 完成 ←────┘

每次 I/O 至少两次上下文切换。在百万 IOPS 下,切换开销可达数十毫秒。

io_uring 的解决方案——共享内存环形队列

┌─────────────────────────────────────────┐
│              用户态                       │
│   填充 SQE → 提交到 SQ                  │
│   从 CQ 收割 CQE                        │
│   无系统调用(SQPOLL 模式)              │
└───────────┬─────────────────────────────┘
            │ mmap 共享内存
┌───────────▼─────────────────────────────┐
│              内核态                       │
│   SQ 线程轮询 SQ → 执行 I/O             │
│   完成后写入 CQE                         │
│   通知用户态(eventfd 或轮询)           │
└─────────────────────────────────────────┘

4.2 io_uring 的核心数据结构

// 提交队列条目(用户填充)
struct io_uring_sqe {
    __u8    opcode;         /* I/O 操作类型:IORING_OP_READV, WRITEV, etc. */
    __u8    flags;          /* SQE 标志:IOSQE_FIXED_FILE, etc. */
    __u16   ioprio;         /* I/O 优先级 */
    __s32   fd;             /* 文件描述符 */
    union {
        __u64   off;        /* 文件偏移 */
        struct {
            __u64   addr;
            __u32   len;
        };
    };
    __u64   addr;           /* 缓冲区地址 / iovec 数组地址 */
    __u32   len;            /* 缓冲区长度 / iovec 数量 */
    union {
        __kernel_rwf_t rw_flags;
        __u32   fsync_flags;
        __u32   poll_events;
    };
    __u64   user_data;      /* 用户自定义标识,完成后原样返回 */
    union {
        __u16   buf_index;  /* 固定缓冲区索引 */
        __u16   buf_group;
    };
    __u16   personality;
    __s32   splice_fd_in;
    __u64   addr3;
    __u64   __pad2[1];
};

// 完成队列条目(内核填充)
struct io_uring_cqe {
    __u64   user_data;      /* 对应 SQE 的 user_data */
    __s32   res;            /* I/O 结果:成功为字节数,失败为负错误码 */
    __u32   flags;          /* 完成标志 */
};

4.3 io_uring 初始化与基本使用

#include <liburing.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>

#define QUEUE_DEPTH 256
#define BLOCK_SIZE  4096

int main(int argc, char *argv[])
{
    struct io_uring ring;
    struct io_uring_params params;
    int fd, ret;
    
    // 1. 打开 NVMe 设备或文件
    fd = open(argv[1], O_RDONLY | O_DIRECT);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    
    // 2. 初始化 io_uring,配置参数
    memset(&params, 0, sizeof(params));
    params.flags = IORING_SETUP_CLAMP;  // 限制队列深度到内核允许的最大值
    
    ret = io_uring_queue_init_params(QUEUE_DEPTH, &ring, &params);
    if (ret < 0) {
        fprintf(stderr, "io_uring_queue_init_params: %s\n", strerror(-ret));
        return 1;
    }
    
    // 3. 准备对齐的缓冲区(O_DIRECT 要求)
    void *buf;
    if (posix_memalign(&buf, 4096, BLOCK_SIZE) != 0) {
        perror("posix_memalign");
        return 1;
    }
    
    // 4. 提交一个异步读取请求
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    if (!sqe) {
        fprintf(stderr, "no sqe available\n");
        return 1;
    }
    
    io_uring_prep_read(sqe, fd, buf, BLOCK_SIZE, 0);  // 偏移 0 读取 4KB
    sqe->user_data = 0x1234;  // 自定义标识
    
    // 5. 提交到内核
    ret = io_uring_submit(&ring);
    if (ret < 0) {
        fprintf(stderr, "io_uring_submit: %s\n", strerror(-ret));
        return 1;
    }
    
    // 6. 等待完成
    struct io_uring_cqe *cqe;
    ret = io_uring_wait_cqe(&ring, &cqe);
    if (ret < 0) {
        fprintf(stderr, "io_uring_wait_cqe: %s\n", strerror(-ret));
        return 1;
    }
    
    printf("Read completed: user_data=0x%lx, res=%d\n",
           (unsigned long)cqe->user_data, cqe->res);
    
    io_uring_cqe_seen(&ring, cqe);
    
    // 7. 清理
    free(buf);
    io_uring_queue_exit(&ring);
    close(fd);
    
    return 0;
}

4.4 SQPOLL:内核线程轮询,消灭系统调用

基本模式下,提交 I/O 仍需调用 io_uring_submit()(触发 io_uring_enter 系统调用)。SQPOLL 模式创建一个内核线程,持续轮询提交队列,完全消除提交路径上的系统调用

struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000;  // 空闲 2 秒后休眠(毫秒)
// params.sq_thread_cpu = 3;   // 可选:绑定到指定 CPU

ret = io_uring_queue_init_params(QUEUE_DEPTH, &ring, &params);

// 之后只需填充 SQE,不需要调用 io_uring_submit()
// 内核线程会自动检测到新的 SQE 并提交

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, BLOCK_SIZE, offset);
sqe->user_data = request_id;

// 如果 SQ 线程空闲,需要 kick 一下
if (io_uring_need_enter(ring))
    io_uring_submit(&ring);

性能对比(fio 基准测试,NVMe SSD,4K 随机读):

模式系统调用次数/秒IOPSCPU 利用率
传统 read()~1,000,000~800K85%
io_uring 基本模式~100,000~1.2M65%
io_uring + SQPOLL~0~1.5M50%

4.5 固定文件和注册缓冲区:消灭更多开销

注册文件(IORING_REGISTER_FILES)

每次 I/O 操作,内核都要通过文件描述符查找 struct file,这涉及引用计数和锁操作。注册文件将文件描述符预映射到固定数组,消除查找开销:

// 注册一组文件描述符
int fds[64];
for (int i = 0; i < 64; i++)
    fds[i] = open(files[i], O_RDONLY | O_DIRECT);

ret = io_uring_register_files(&ring, fds, 64);

// 提交 I/O 时使用固定文件索引
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, 0, buf, BLOCK_SIZE, offset);  // 用索引 0 而非 fd
sqe->flags |= IOSQE_FIXED_FILE;

注册缓冲区(IORING_REGISTER_BUFFERS)

O_DIRECT 的 I/O 操作需要内核锁定用户缓冲区的物理页面(防止被换出)。注册缓冲区预完成锁定和 DMA 映射,消除每次 I/O 的 page pin/unpin 开销:

// 分配对齐缓冲区
struct iovec iovs[64];
for (int i = 0; i < 64; i++) {
    posix_memalign(&iovs[i].iov_base, 4096, BLOCK_SIZE);
    iovs[i].iov_len = BLOCK_SIZE;
}

// 注册到 io_uring
ret = io_uring_register_buffers(&ring, iovs, 64);

// 提交 I/O 时使用固定缓冲区索引
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fixed_fd_idx, iovs[0].iov_base, 
                         BLOCK_SIZE, offset, 0);  // 缓冲区索引 0
sqe->flags |= IOSQE_FIXED_FILE;

综合性能提升(NVMe SSD,4K 随机读,单核):

传统 read()               :  ~800K IOPS
+ io_uring 基本模式        :  ~1.2M IOPS  (+50%)
+ SQPOLL                  :  ~1.5M IOPS  (+25%)
+ 注册文件 + 注册缓冲区    :  ~1.8M IOPS  (+20%)
──────────────────────────────────────────
总计 vs 传统               :  ~2.25x

五、Block Layer:I/O 调度与合并

5.1 现代 Block Layer 架构

Linux 5.0 重写了 Block Layer(称为 blk-mq,Multi-Queue Block Layer),适配 NVMe 等多队列设备:

┌─────────────────────────────────────────────┐
│              软件队列(Software Staging)     │
│   每个 CPU 一个,I/O 请求首先进入这里        │
│   ┌───┐ ┌───┐ ┌───┐ ┌───┐                  │
│   │h0 │ │h1 │ │h2 │ │h3 │  ...              │
│   └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘                  │
└─────┼──────┼──────┼──────┼──────────────────┘
      │      │      │      │
      ▼      ▼      ▼      ▼
┌─────────────────────────────────────────────┐
│              硬件队列(Hardware Queue)       │
│   与 NVMe 提交队列一一对应                    │
│   ┌───┐ ┌───┐ ┌───┐ ┌───┐                  │
│   │q0 │ │q1 │ │q2 │ │q3 │  ...              │
│   └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘                  │
└─────┼──────┼──────┼──────┼──────────────────┘
      │      │      │      │
      ▼      ▼      ▼      ▼
   NVMe SSD 的多个提交/完成队列对

5.2 I/O 调度器的选择

+-------------+------------------+----------------------------+
| 调度器      | 适用场景         | 特点                       |
+-------------+------------------+----------------------------+
| none        | NVMe SSD        | 不调度,直接提交           |
|             | 低延迟优先      | 延迟最低,吞吐可能不均    |
+-------------+------------------+----------------------------+
| mq-deadline | 通用 SSD        | 保障请求延迟上限           |
|             | 混合读写负载    | 读优先,防止写饥饿        |
+-------------+------------------+----------------------------+
| bfq         | 桌面/低带宽 SSD | 公平带宽分配               |
|             | 交互式应用      | 延迟较高,适合用户体验    |
+-------------+------------------+----------------------------+

对于高 IOPS 的 NVMe + io_uring 场景,none 调度器是最佳选择——NVMe 硬件本身有内部调度优化,软件层再调度反而增加延迟。

# 查看当前调度器
cat /sys/block/nvme0n1/queue/scheduler

# 设置为 none
echo none > /sys/block/nvme0n1/queue/scheduler

5.3 bio 结构:I/O 请求的通用表示

// Block Layer 的核心数据结构
struct bio {
    struct bio          *bi_next;    // 链接到下一个 bio
    struct block_device *bi_bdev;    // 目标块设备
    unsigned int         bi_opf;     // 操作类型和标志
    sector_t             bi_iter;    // 当前迭代位置
    unsigned short       bi_vcnt;    // bio_vec 数量
    unsigned short       bi_max_vecs;// 最大 bio_vec 数
    struct bio_vec      *bi_io_vec;  // 数据段数组
    bio_end_io_t        *bi_end_io;  // 完成回调
    void                *bi_private; // 私有数据
};

// 数据段:一个连续的物理内存区域
struct bio_vec {
    struct page   *bv_page;   // 内存页
    unsigned int   bv_len;    // 长度(字节)
    unsigned int   bv_offset; // 页内偏移
};

六、实战:构建 io_uring 高并发存储引擎

6.1 设计目标

构建一个基于 io_uring 的高并发文件读取引擎,支持:

  • 百万级 IOPS 的 4K 随机读取
  • 批量提交 + 批量收割
  • 固定文件和注册缓冲区
  • 可配置的队列深度和并发度

6.2 完整实现

// io_uring_engine.h - 高并发存储引擎头文件
#ifndef IO_URING_ENGINE_H
#define IO_URING_ENGINE_H

#include <liburing.h>
#include <stdint.h>
#include <stdbool.h>

#define URING_ENGINE_MAX_FILES    64
#define URING_ENGINE_MAX_BUFFERS  256
#define URING_ENGINE_DEFAULT_DEPTH 512

typedef struct {
    struct io_uring  ring;
    int             *fds;           // 文件描述符数组
    int              nr_files;      // 文件数量
    void           **buffers;       // 缓冲区数组
    int              nr_buffers;    // 缓冲区数量
    uint32_t         queue_depth;   // 队列深度
    bool             use_sqpoll;    // 是否使用 SQPOLL
    bool             use_fixed_files;    // 是否使用注册文件
    bool             use_fixed_buffers;  // 是否使用注册缓冲区
    uint64_t         submitted;     // 已提交请求数
    uint64_t         completed;     // 已完成请求数
    uint64_t         errors;        // 错误数
} uring_engine_t;

typedef struct {
    int      file_idx;     // 文件索引(固定文件模式)或 fd
    uint64_t offset;       // 读取偏移(字节,必须 4K 对齐)
    int      buf_idx;      // 缓冲区索引
    uint64_t user_data;    // 用户自定义标识
} uring_read_request_t;

typedef struct {
    uint64_t user_data;    // 对应请求的 user_data
    int32_t  result;       // 读取字节数(成功)或负错误码(失败)
} uring_read_result_t;

#endif
// io_uring_engine.c - 高并发存储引擎实现
#include "io_uring_engine.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>

static const int BLOCK_SIZE = 4096;

// 初始化引擎
int uring_engine_init(uring_engine_t *eng, uint32_t depth, bool sqpoll)
{
    memset(eng, 0, sizeof(*eng));
    eng->queue_depth = depth ? depth : URING_ENGINE_DEFAULT_DEPTH;
    eng->use_sqpoll = sqpoll;
    
    struct io_uring_params params = {0};
    
    if (sqpoll) {
        params.flags |= IORING_SETUP_SQPOLL;
        params.sq_thread_idle = 2000;  // 2 秒空闲后休眠
    }
    
    // 使用 IORING_SETUP_CLAMP 防止请求过大的队列深度
    params.flags |= IORING_SETUP_CLAMP;
    
    int ret = io_uring_queue_init_params(eng->queue_depth, &eng->ring, &params);
    if (ret < 0) {
        fprintf(stderr, "io_uring init failed: %s\n", strerror(-ret));
        return ret;
    }
    
    // 如果 SQPOLL 的内核线程需要绑定 CPU,可以在这里设置亲和性
    // (params.sq_thread_cpu 返回实际使用的 CPU)
    
    return 0;
}

// 注册文件
int uring_engine_register_files(uring_engine_t *eng, const char **paths, int count)
{
    if (count > URING_ENGINE_MAX_FILES) {
        fprintf(stderr, "Too many files: %d > %d\n", count, URING_ENGINE_MAX_FILES);
        return -EINVAL;
    }
    
    eng->fds = calloc(count, sizeof(int));
    if (!eng->fds) return -ENOMEM;
    
    for (int i = 0; i < count; i++) {
        eng->fds[i] = open(paths[i], O_RDONLY | O_DIRECT);
        if (eng->fds[i] < 0) {
            fprintf(stderr, "Failed to open %s: %s\n", paths[i], strerror(errno));
            // 清理已打开的文件
            for (int j = 0; j < i; j++) close(eng->fds[j]);
            free(eng->fds);
            return -errno;
        }
    }
    
    int ret = io_uring_register_files(&eng->ring, eng->fds, count);
    if (ret < 0) {
        fprintf(stderr, "register files failed: %s\n", strerror(-ret));
        for (int i = 0; i < count; i++) close(eng->fds[i]);
        free(eng->fds);
        return ret;
    }
    
    eng->nr_files = count;
    eng->use_fixed_files = true;
    return 0;
}

// 注册缓冲区
int uring_engine_register_buffers(uring_engine_t *eng, int count)
{
    if (count > URING_ENGINE_MAX_BUFFERS) {
        fprintf(stderr, "Too many buffers: %d > %d\n", count, URING_ENGINE_MAX_BUFFERS);
        return -EINVAL;
    }
    
    struct iovec *iovs = calloc(count, sizeof(struct iovec));
    eng->buffers = calloc(count, sizeof(void *));
    
    if (!iovs || !eng->buffers) {
        free(iovs);
        free(eng->buffers);
        return -ENOMEM;
    }
    
    for (int i = 0; i < count; i++) {
        void *buf;
        if (posix_memalign(&buf, 4096, BLOCK_SIZE) != 0) {
            // 清理已分配的缓冲区
            for (int j = 0; j < i; j++) free(eng->buffers[j]);
            free(iovs);
            free(eng->buffers);
            return -ENOMEM;
        }
        eng->buffers[i] = buf;
        iovs[i].iov_base = buf;
        iovs[i].iov_len = BLOCK_SIZE;
    }
    
    int ret = io_uring_register_buffers(&eng->ring, iovs, count);
    if (ret < 0) {
        fprintf(stderr, "register buffers failed: %s\n", strerror(-ret));
        for (int i = 0; i < count; i++) free(eng->buffers[i]);
        free(iovs);
        free(eng->buffers);
        return ret;
    }
    
    eng->nr_buffers = count;
    eng->use_fixed_buffers = true;
    free(iovs);
    return 0;
}

// 批量提交读取请求
int uring_engine_submit_reads(uring_engine_t *eng, 
                               const uring_read_request_t *reqs, int count)
{
    int submitted = 0;
    
    for (int i = 0; i < count; i++) {
        struct io_uring_sqe *sqe = io_uring_get_sqe(&eng->ring);
        if (!sqe) {
            // 队列满,先提交已有的请求
            if (submitted > 0) {
                int ret = io_uring_submit(&eng->ring);
                if (ret < 0) return ret;
            }
            sqe = io_uring_get_sqe(&eng->ring);
            if (!sqe) return -EAGAIN;
        }
        
        const uring_read_request_t *req = &reqs[i];
        
        if (eng->use_fixed_buffers) {
            io_uring_prep_read_fixed(sqe, 
                                      eng->use_fixed_files ? req->file_idx : eng->fds[req->file_idx],
                                      eng->buffers[req->buf_idx], BLOCK_SIZE,
                                      req->offset, req->buf_idx);
        } else {
            io_uring_prep_read(sqe,
                               eng->use_fixed_files ? req->file_idx : eng->fds[req->file_idx],
                               eng->buffers[req->buf_idx], BLOCK_SIZE,
                               req->offset);
        }
        
        sqe->user_data = req->user_data;
        
        if (eng->use_fixed_files)
            sqe->flags |= IOSQE_FIXED_FILE;
        
        submitted++;
    }
    
    // 提交所有请求
    if (submitted > 0) {
        int ret = io_uring_submit(&eng->ring);
        if (ret < 0) return ret;
    }
    
    eng->submitted += submitted;
    return submitted;
}

// 批量收割完成结果
int uring_engine_reap_completions(uring_engine_t *eng,
                                   uring_read_result_t *results, int max_results)
{
    int reaped = 0;
    struct io_uring_cqe *cqe;
    unsigned head;
    unsigned int nr = 0;
    
    // 非阻塞遍历完成队列
    io_uring_for_each_cqe(&eng->ring, head, cqe) {
        if (reaped >= max_results) break;
        
        results[reaped].user_data = cqe->user_data;
        results[reaped].result = cqe->res;
        
        if (cqe->res < 0)
            eng->errors++;
        
        reaped++;
        nr++;
    }
    
    if (nr > 0) {
        io_uring_cq_advance(&eng->ring, nr);
        eng->completed += reaped;
    }
    
    return reaped;
}

// 销毁引擎
void uring_engine_destroy(uring_engine_t *eng)
{
    if (eng->use_fixed_buffers) {
        for (int i = 0; i < eng->nr_buffers; i++)
            free(eng->buffers[i]);
        free(eng->buffers);
    }
    
    if (eng->use_fixed_files) {
        for (int i = 0; i < eng->nr_files; i++)
            close(eng->fds[i]);
        free(eng->fds);
    }
    
    io_uring_queue_exit(&eng->ring);
}

6.3 基准测试程序

// bench_uring.c - 性能基准测试
#include "io_uring_engine.h"
#include <time.h>
#include <stdio.h>
#include <stdlib.h>

#define NR_FILES     1
#define NR_BUFFERS   256
#define QUEUE_DEPTH  512
#define TOTAL_IOS    1000000

static double now_sec(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec + ts.tv_nsec * 1e-9;
}

int main(int argc, char *argv[])
{
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <device_or_file>\n", argv[0]);
        return 1;
    }
    
    const char *paths[] = { argv[1] };
    
    // 测试不同配置
    struct {
        const char *name;
        bool sqpoll;
        bool fixed_files;
        bool fixed_buffers;
    } configs[] = {
        { "basic",              false, false, false },
        { "fixed_files",        false, true,  false },
        { "fixed_buffers",      false, false, true  },
        { "fixed_all",          false, true,  true  },
        { "sqpoll_fixed_all",   true,  true,  true  },
    };
    
    for (int c = 0; c < 5; c++) {
        uring_engine_t eng;
        int ret;
        
        printf("\n=== %s ===\n", configs[c].name);
        
        ret = uring_engine_init(&eng, QUEUE_DEPTH, configs[c].sqpoll);
        if (ret < 0) continue;
        
        if (configs[c].fixed_files) {
            ret = uring_engine_register_files(&eng, paths, NR_FILES);
            if (ret < 0) { uring_engine_destroy(&eng); continue; }
        } else {
            // 手动打开文件
            eng.fds = calloc(1, sizeof(int));
            eng.fds[0] = open(paths[0], O_RDONLY | O_DIRECT);
            eng.nr_files = 1;
        }
        
        if (configs[c].fixed_buffers) {
            ret = uring_engine_register_buffers(&eng, NR_BUFFERS);
            if (ret < 0) { uring_engine_destroy(&eng); continue; }
        } else {
            // 手动分配缓冲区
            eng.buffers = calloc(NR_BUFFERS, sizeof(void *));
            eng.nr_buffers = NR_BUFFERS;
            for (int i = 0; i < NR_BUFFERS; i++)
                posix_memalign(&eng.buffers[i], 4096, 4096);
        }
        
        // 生成随机读取请求
        srand(42);
        uring_read_request_t *reqs = malloc(QUEUE_DEPTH * sizeof(*reqs));
        uring_read_result_t  *results = malloc(QUEUE_DEPTH * sizeof(*results));
        
        double start = now_sec();
        uint64_t total_ios = 0;
        
        while (total_ios < TOTAL_IOS) {
            // 填充一批请求
            int batch = (TOTAL_IOS - total_ios > QUEUE_DEPTH) ? 
                        QUEUE_DEPTH : (TOTAL_IOS - total_ios);
            
            for (int i = 0; i < batch; i++) {
                reqs[i].file_idx = 0;
                // 4K 对齐的随机偏移(假设文件 1GB)
                reqs[i].offset = (rand() % (256 * 1024)) * 4096;
                reqs[i].buf_idx = i % NR_BUFFERS;
                reqs[i].user_data = total_ios + i;
            }
            
            ret = uring_engine_submit_reads(&eng, reqs, batch);
            if (ret < 0) {
                fprintf(stderr, "submit failed: %s\n", strerror(-ret));
                break;
            }
            
            // 收割完成
            int reaped;
            do {
                reaped = uring_engine_reap_completions(&eng, results, QUEUE_DEPTH);
            } while (reaped == 0);
            
            total_ios += batch;
        }
        
        double elapsed = now_sec() - start;
        double iops = total_ios / elapsed;
        
        printf("Total I/Os: %lu\n", total_ios);
        printf("Elapsed:     %.3f s\n", elapsed);
        printf("IOPS:        %.0f\n", iops);
        printf("Errors:      %lu\n", eng.errors);
        
        uring_engine_destroy(&eng);
        free(reqs);
        free(results);
    }
    
    return 0;
}

6.4 编译与运行

# 安装 liburing
# Ubuntu/Debian: apt install liburing-dev
# CentOS/RHEL:   dnf install liburing-devel
# macOS:         不支持 io_uring(Linux 专属)

gcc -O2 -o bench_uring bench_uring.c io_uring_engine.c -luring

# 对 NVMe 设备运行(需要 root 或 disk 组权限)
sudo ./bench_uring /dev/nvme0n1

# 对大文件运行(需要 4K 对齐)
dd if=/dev/zero of=/tmp/test.img bs=1M count=1024 oflag=direct
./bench_uring /tmp/test.img

七、生产级性能调优清单

7.1 内核参数优化

# 1. I/O 调度器:NVMe 使用 none
echo none > /sys/block/nvme0n1/queue/scheduler

# 2. 增大块设备预读
echo 4096 > /sys/block/nvme0n1/queue/read_ahead_kb

# 3. 调整虚拟内存脏页比率(写密集场景)
sysctl -w vm.dirty_ratio=10
sysctl -w vm.dirty_background_ratio=5

# 4. 调整 I/O 轮询(低延迟场景)
echo 1 > /sys/block/nvme0n1/queue/io_poll

# 5. 禁用 NUMA 自动均衡(减少跨节点 I/O)
sysctl -w kernel.numa_balancing=0

# 6. 调整最大打开文件数
ulimit -n 1048576
sysctl -w fs.file-max=1048576

# 7. CPU 隔离(为 I/O 线程保留 CPU)
# 在内核启动参数中添加:isolcpus=2,3
# 然后将 io_uring SQ 线程和应用程序绑定到这些 CPU

7.2 io_uring 调优参数

// 生产级 io_uring 初始化配置
struct io_uring_params params = {0};

// 核心标志
params.flags = 
    IORING_SETUP_SQPOLL |       // 内核线程轮询提交队列
    IORING_SETUP_CLAMP |        // 队列深度不超过内核限制
    IORING_SETUP_COOP_TASKRUN | // 减少中断风暴
    IORING_SETUP_SINGLE_ISSUER; // 单提交者优化(6.0+)

params.sq_thread_idle = 5000;   // 5 秒空闲后休眠(毫秒)

// 队列深度
// - 随机 4K 读:256-1024
// - 顺序读:64-128
// - 混合读写:512-2048
unsigned int queue_depth = 512;

7.3 内存与缓存优化

# 1. 大页(Huge Pages)减少 TLB miss
echo 1024 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

# 2. 使用 MAP_HUGETLB 分配缓冲区
# 在代码中:
# buf = mmap(NULL, size, PROT_READ|PROT_WRITE,
#            MAP_PRIVATE|MAP_ANONYMOUS|MAP_HUGETLB, -1, 0);

# 3. NUMA 亲和性:确保缓冲区和 NVMe 在同一 NUMA 节点
numactl --cpunodebind=0 --membind=0 ./bench_uring /dev/nvme0n1

7.4 监控与观测

# 1. io_uring 统计信息
cat /proc/sys/kernel/io_uring_disabled  # 0=启用, 1=仅 root, 2=禁用

# 2. Block Layer 统计
cat /sys/block/nvme0n1/stat
# 字段:读完成数、读合并数、读扇区数、读耗时(ms)、
#       写完成数、写合并数、写扇区数、写耗时(ms)、
#       在途 I/O 数、总耗时(ms)、加权耗时(ms)

# 3. NVMe 智能信息
nvme smart-log /dev/nvme0n1

# 4. fio 基准测试(对比用)
fio --name=uring-randread \
    --ioengine=io_uring \
    --iodepth=512 \
    --rw=randread \
    --bs=4k \
    --numjobs=1 \
    --size=1G \
    --runtime=60 \
    --sqthread_poll=1 \
    --fixedbufs=1 \
    --registerfiles=1 \
    /dev/nvme0n1

八、从 iomap 补丁看内核性能方法论

8.1 热路径分析的通用方法

这次 iomap 补丁的成功,不只是运气——它体现了一种可复制的内核性能优化方法论:

第一步:识别热路径

在 I/O 密集型场景中,热路径是「每个 I/O 请求必须经过的代码」。对 iomap 来说,iomap_iter() 就是热路径——百万 IOPS 意味着这个函数每秒执行百万次。

第二步:审查每条指令的必要性

在热路径上,每条指令都需要回答:这个操作对正确性是必要的吗?如果不是——无论它多么「安全」、「卫生」——都是浪费。

memset 清零即将被销毁的结构体,就像在一栋即将拆除的大楼里刷墙——虽然「干净」,但毫无意义。

第三步:量化影响

不是所有不必要的操作都值得优化。关键在于:

  • 操作频率(每秒执行多少次?)
  • 单次开销(CPU 周期、缓存行占用、内存带宽)
  • 累积影响(频率 × 单次开销 = 总浪费)

memset 在每次 I/O 时执行一次(百万次/秒),每次约 2-3 纳秒 + 缓存污染,累积影响显著。

第四步:最小化改动

好的性能优化是「删代码」而非「加代码」。这次补丁只是移动了两行代码的位置——零新逻辑、零风险、零回归。

8.2 类似的内核优化案例

Linux 内核历史上不乏类似的「小改动大收益」案例:

年份优化改动量性能提升
2026iomap memset 延迟2 行IOPS +5%
2024folio 替换 page大规模重构大文件 I/O +20%
2023io_uring CQE32中等减少 user_data 碰撞
2021mmap_lock → range_lock大规模重构多线程 mmap +30%
2019io_uring 引入大规模vs epoll +50-200%
2018atomics 优化多核扩展性提升

8.3 给应用开发者的启示

即使你不写内核代码,这些原则同样适用:

  1. 审视热循环中的每个操作:你的请求处理路径、序列化/反序列化路径、内存分配路径——这些是应用层的「iomap_iter」
  2. 小心「防御性编程」的隐性成本:不必要的初始化、冗余的 null 检查、过度的日志——在热路径上,这些都是 IOPS 杀手
  3. 测量而非猜测:perf、火焰图、缓存命中率——数据驱动优化
  4. 批量胜于单个:io_uring 的核心优势之一是批量提交和收割。应用层也应追求批量处理——批量 RPC、批量 DB 写入、批量消息发送

九、Linux 7.2 存储栈其他值得关注的特性

除了 iomap 优化,Linux 7.2 的存储栈还有几个值得关注的改进:

9.1 NTFS 原生驱动成熟

Linux 7.1 合并了 3.6 万行代码的 NTFS 原生驱动(取代了旧的 FUSE 实现)。7.2 版本进一步优化:

  • 多线程写入性能提升 35%-110%
  • 4TB+ 大盘挂载速度提升 4 倍
  • 支持更多 NTFS 特性(reparse points、扩展属性等)
# 挂载 NTFS 分区(使用新内核驱动)
mount -t ntfs3 /dev/sdX1 /mnt/windows

# 对比旧驱动的性能差异
# 旧驱动(ntfs-3g FUSE):写入约 50MB/s
# 新驱动(ntfs3 内核):写入约 150-200MB/s

9.2 Apple M3 支持进入主线

Linux 7.2 合并了 Apple M3 系列芯片的启动支持补丁,意味着 iMac、MacBook Air、MacBook Pro 的 M3 机型可以原生启动 Linux。这对存储栈的影响是 Apple NVMe 控制器的驱动适配。

9.3 folio 继续推进

folio(大页缓存)在 7.2 中继续扩展覆盖范围,更多文件系统操作从 struct page 迁移到 struct folio,减少大文件 I/O 的 page 操作次数。


十、总结与展望

10.1 核心要点

  1. Linux 7.2 的 iomap 优化证明了一个道理:在 I/O 热路径上,没有「微不足道」的操作。200 字节的无效 memset,在百万 IOPS 下就是 200MB/s 的内存带宽浪费。

  2. iomap 框架是 Linux 文件系统块映射的统一层,理解它的工作原理是理解存储 I/O 性能的关键。

  3. io_uring 通过共享内存环形队列、SQPOLL、固定文件、注册缓冲区四层优化,将用户态 I/O 性能推向硬件极限。与传统 read() 相比,IOPS 可提升 2 倍以上。

  4. 性能优化方法论:识别热路径 → 审查每条指令 → 量化影响 → 最小化改动。这适用于内核,也适用于应用层。

  5. 生产级调优需要系统性思维:I/O 调度器、内核参数、内存布局、NUMA 亲和性、监控观测——缺一不可。

10.2 展望

Linux 存储栈仍在快速演进:

  • io_uring 的扩展:未来版本可能支持更多操作类型(如网络 + 存储的统一异步接口)
  • CXL 内存:计算互连链路(CXL)将改变存储层级,内核需要适配新的内存-存储混合架构
  • 持久内存(PMEM):虽然市场遇冷,但 DAX 模式的设计理念将影响未来的存储 API
  • io_uring + eBPF:两者结合可能实现完全可编程的 I/O 路径——在内核中安全地执行自定义 I/O 处理逻辑

10.3 一句话总结

当你的系统每天处理十亿次 I/O 操作时,热路径上的每个字节、每条指令、每次缓存访问都在被放大——而最优雅的优化,往往是删掉那些「看起来合理但其实无用」的代码。


参考资料

  • Linux 7.2 iomap 补丁:git.kernel.org,提交者 Fengnan Chang,合并者 Christian Brauner
  • io_uring 官方文档:kernel.org/doc/html/latest/io_uring
  • liburing 库:github.com/axboe/liburing
  • iomap 框架设计文档:kernel.org/doc/html/latest/filesystems/iomap
  • NVMe 规范:nvmexpress.org
  • fio I/O 基准测试工具:github.com/axboe/fio
复制全文 生成海报 Linux io_uring iomap NVMe 存储 内核优化

推荐文章

# 解决 MySQL 经常断开重连的问题
2024-11-19 04:50:20 +0800 CST
Golang 几种使用 Channel 的错误姿势
2024-11-19 01:42:18 +0800 CST
PHP 唯一卡号生成
2024-11-18 21:24:12 +0800 CST
2024年微信小程序开发价格概览
2024-11-19 06:40:52 +0800 CST
阿里云免sdk发送短信代码
2025-01-01 12:22:14 +0800 CST
程序员茄子在线接单