引言

Linux 块层是文件系统、应用程序与存储设备之间的关键中介层。每一个 read()、write() 和 fsync() 最终都要经过这个子系统的处理才能到达磁盘。几十年来,单队列(SQ)块层一直是性能瓶颈——由一个全局请求队列和一个自旋锁保护的架构,无法扩展以适应现代多核服务器和数百万 IOPS 级别的 NVMe SSD。Linux 3.13(2014 年)引入的 blk-mq(多队列块层)以及后续 4.x/5.x/6.x 内核版本的不断成熟,将存储 I/O 路径转变为高度并行、可扩展的架构,能够充分利用硬件性能。

本文对 Linux 块层和 I/O 调度子系统进行全面的工程分析。我们从块层核心抽象(bio、request、request_queue)开始,深入剖析 blk-mq 架构从硬件 dispatch 队列到多队列 plugs 的详细设计,深度研究每个主流 I/O 调度器(mq-deadline、Kyber、BFQ、bfq-group),探究 NVMe Passthrough(io_uring 固定文件和 uring_cmd),最后以生产环境性能调优技巧和真实基准数据收尾。

1. 块层核心架构

在 blk-mq 出现之前,块层采用单一请求队列模型。理解这一历史沿革很重要,因为许多概念(合并、plugging、请求分配)在 blk-mq 中得以延续。

bio 结构:块 I/O 单元

贯穿整个块层的基本数据结构是 struct bio。一个 bio 表示要从块设备读取或写入的一段或多段数据:

struct bio {
    struct bio *bi_next;          // 请求队列链接
    struct block_device *bi_bdev; // 目标块设备
    blk_opf_t bi_opf;             // 操作标志(READ、WRITE、FLUSH、DISCARD等)
    unsigned short bi_ioprio;     // I/O 优先级(映射到class/level)
    blk_status_t bi_status;       // 完成状态
    struct bvec_iter bi_iter;     // 段迭代器
    bio_end_io_t *bi_end_io;      // 完成回调
    void *bi_private;             // 提交者私有数据
    struct bio_vec *bi_io_vec;    // (page, offset, length) 段数组
    struct bio_set *bi_pool;      // 分配池引用
    atomic_t __bi_cnt;            // 引用计数
}

bio_vec 数组描述了分散的内存页面及其在 bio 中的布局。单个 bio 可以引用多个不连续的内存页——这对于支持向量 I/O(readv/writev)和 O_DIRECT 页对齐 I/O 至关重要,无需缓冲区在物理内存中连续。

从 bio 到 request:提交路径

当文件系统(如 ext4、XFS、btrfs)或原始块设备访问提交 I/O 时,通过 submit_bio()(经过 bio_alloc()、bio_add_page()、设置 bi_opf)创建一个 bio。提交流程经过以下阶段:

  1. bio 合并:I/O 调度器/合并器检查新 bio 是否能与现有请求合并(前向或后向追加),条件是连续的扇区和同一设备。前/后合并可减少总请求数。
  2. 请求分配:如果无法合并,则从请求内存池或 slab 缓存中分配新请求。
  3. I/O 调度:I/O 调度器(或 blk-mq 硬件队列)决定请求何时被派发到驱动。
  4. 派发:派发函数(如 blk_mq_dispatch_rq_list())通过 blk_mq_start_request() 将请求交给驱动程序。
  5. 完成:驱动完成后调用 blk_mq_complete_request(),触发 bi_end_io 回调并释放资源。

2. blk-mq 多队列架构

单队列的瓶颈

3.13 版本之前的内核使用 struct request_queue,包含单个 struct request_list 和一个自旋锁(queue_lock)。这造成了三个根本性的瓶颈:

  • 锁竞争:多核同时提交 I/O 时争夺同一个队列锁
  • 单硬件队列:传统驱动(SCSI、旧版 SATA)只向操作系统呈现一个 DMA 引擎
  • 缓存行抖动:请求队列、完成处理程序和统计计数器位于同一缓存行,在 NUMA 节点间反复跳变

在配备 NVMe 驱动器、可超过 100 万 IOPS 的现代 64 核服务器上,仅锁竞争就使 SQ 模型上限徘徊在 20-30 万 IOPS。

blk-mq 队列层级

blk-mq 引入两级队列结构:


┌────────────────────────────────────────────────────┐
│                 struct request_queue               │
│  ┌──────────────────────────────────────────────┐  │
│  │ struct blk_mq_ctx[] (per-CPU 软件队列)        │  │
│  │  - Dispatch list (待处理请求)                  │  │
│  │  - Dispatched list (硬件执行中)                │  │
│  │  - hctx_idx (映射到的硬件上下文)                │  │
│  └──────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────┐  │
│  │ struct blk_mq_hw_ctx[] (硬件队列)             │  │
│  │  - 驱动拥有的请求池                            │  │
│  │  - Tags (位图追踪 in-flight 请求)              │  │
│  │  - Dispatch queue (按调度器排序)               │  │
│  │  - Run work (延迟派发的工作队列)                │  │
│  └──────────────────────────────────────────────┘  │
│                                                   │
│  队列映射:                                        │
│    software queues ──── map ──── hardware queues   │
│    [CPU 0] ──┐              ┌── [HCTX 0]        │
│    [CPU 1] ──┼──────────────┤                    │
│    [CPU 2] ──┤    1:N 或    ├── [HCTX 1]        │
│    [CPU 3] ──┘    N:N        └── [HCTX 2]        │
└────────────────────────────────────────────────────┘

关键数据结构及其关系:

  • struct blk_mq_ctx:每 CPU 软件暂存区。当任务提交 bio 时,当前 CPU 的 blk_mq_ctx 用于初始处理(合并、重新排队)。持有 pending 和 dispatched 链表。
  • struct blk_mq_hw_ctx:硬件队列上下文——每个硬件派发通道一个。驱动提供 queue_rq 回调来实际编程硬件。包含 tags(共享位图,追踪 in-flight 请求)和 dispatch 队列(等待发往硬件的请求)。
  • struct blk_mq_queue_map:定义 CPU 到硬件上下文的映射。常见拓扑:
    • PCIe NVMe:N CPU → N 硬件队列(1:1 映射)。
    • SAS/SATA:N CPU → 1 硬件队列(N:1 映射)。
    • 混合 NVMe:N CPU → 2-N 队列(NUMA 感知映射)。

blk-mq 标签分配

使用标签位图追踪 in-flight 请求。请求派发到硬件时,从 struct blk_mq_tags 位图中分配标签。完成后,标签被释放。此机制实现了:

  • 请求排序:请求层级中的父子请求可以被追踪
  • 错误恢复:失败标签可以超时重试
  • 标签集共享:多个 hw_queue 共享一个标签集用于全局预留(reserved_tags)
  • io_uring 集成:固定缓冲区和固定文件使用标签进行持久化资源绑定

多队列 Plugging 机制

Plugging(阻塞)是 blk-mq 最重要的吞吐量优化之一。不是立即将每个 bio 派发到硬件,"plug" 机制先将多个 bio 在 per-CPU 派发列表中批量聚合,然后一次性批量刷写:

// 简化的 plug/flush 流程:
blk_bio_plug_init(bio_plug, BIO_MAX_PLUGGED);
blk_start_plug(plug) {
    current->plug = plug;
}
submit_bio(bio) {
    if (current->plug) {
        // 尝试与已插入的 bios 合并
        if (attempt_merge(bio, plug->cb_list) == false)
            list_add_tail(&bio->bi_plug, &plug->cb_list);
        // 暂不派发
    }
}
blk_finish_plug(plug) {
    // 现在批量刷写所有已插入的 bios 到硬件队列
    while (!list_empty(plug->cb_list)) {
        bio = list_first_entry(...);
        blk_mq_flush_plug_list(...); // 排序并批量派发
    }
}

在高吞吐量工作负载下,plugging 实现了:

  • 批量合并:在批量中提供了更多前/后合并机会
  • 减少硬件队列争用:一次刷写完成而非每个 bio 的派发开销
  • 调度器效率:调度器对整个批量排序,减少每次请求的插入开销

Plugging 在以下情况自动触发:

  • blk_finish_plug() 显式调用
  • 调度或阻塞(每个任务的 plug 在上下文切换时被刷写)
  • Plug 深度超过 BIO_MAX_PLUGGED(通常约 16)

3. I/O 调度器:算法与性能

现代内核在 CONFIG_MQ_IOSCHED_* 系列中提供多种 blk-mq I/O 调度器。调度器在硬件队列级别操作——在请求到达驱动之前进行排序和重排。

mq-deadline:延迟约束型 FIFO

mq-deadline 是大多数存储工作负载的"安全默认"方案。它实现两个排序列表加上一个 FIFO 批次:

struct deadline_data {
    struct rb_root sort_list[2];    // [READ] / [READFWD] 按扇区排序
    struct list_head fifo_list[2];   // [READ] / [WRITE] FIFO 过期列表
    unsigned int fifo_time[2];        // 每方向过期时间
    unsigned int writes_starved;      // 写饥饿计数器
    unsigned int front_merges;        // 前/后合并比例
}

操作流程:

  1. 派发:在读写批次之间交替。读取有更高的默认优先级(4:1 读写比)。
  2. 排序派发:从 sort_list(按扇区位置排序的红黑树)中选择下一个顺序请求。这将相邻扇区聚合到一起,在 HDD 上减少寻道开销,在 SSD 上提升预取效果。
  3. 饥饿防护:如果 FIFO 条目等待时间超过 fifo_time(读取默认 250ms,写入默认 5s),则忽略排序立即派发。这约束了尾延迟。
  4. 饥饿权衡:允许写最多饿死读取 writes_starved 批次(默认 2 个批次),确保读不被写永久阻塞。

通过 sysfs 调优:

/sys/block/sda/queue/iosched/read_expire    (默认 500ms)
/sys/block/sda/queue/iosched/write_expire   (默认 5000ms)
/sys/block/sda/queue/iosched/writes_starved  (默认 2)
/sys/block/sda/queue/iosched/fifo_batch      (默认 16)
/sys/block/sda/queue/iosched/front_merges    (默认 1)

Kyber:目标延迟反馈控制

Kyber(Linux 5.0 引入)采用了根本不同的方法:使用反馈控制器追踪每个调度域的平均请求延迟,并调节派发以满足目标延迟。

// Kyber 调度域
enum kyber_domain {
    KYBER_READ,       // 读延迟目标 (默认: 2ms)
    KYBER_WRITE,      // 写延迟目标 (默认: 10ms)
    KYBER_DISCARD,    // 丢弃目标 (默认: 5ms)
    KYBER_OTHER,      // FLUSH/SECURE_ERASE (默认: 25ms)
};

struct kyber_queue_data {
    struct kyper_latency_depth kqd[KYBER_DOMAINS];
    spinlock_t lock;
};

关键概念:

  • 令牌桶限流:每个域有一个派发深度限制(令牌)。请求派发减少令牌;完成时基于目标延迟除以测量延迟恢复令牌。
  • 自调节:如果平均延迟超过目标,限流收紧(允许多少并发请求)。如果延迟低于目标,限流放松以允许更多并行。
  • 请求批次公平:在同一调度域内,请求按 FIFO(无重排)。工作保守——当有 pending 请求时从不空闲硬件。

这使得 Kyber 对 NVMe SSD 特别有效:

  • 无旋转延迟考量(仅 NAND flash 读/写延迟)
  • 快速反馈循环——NVMe 在微秒级而非毫秒级完成
  • 自适应并发——根据工作负载强度自动调整深度

BFQ(Budget Fair Queueing):比例份额调度

BFQ(从 CFQ 反向移植,4.12 引入)是最复杂的调度器。它在保持低延迟的同时实现跨进程/cgroup 的公平带宽分配:

struct bfq_data {
    struct rb_root service_tree;      // 按虚拟时间排序
    struct bfq_entity *active_entity; // 当前派发的实体
    u64 budget;                        // 当前实体预算
    unsigned long wr_coeff;             // 写补偿因子
    struct bfq_group *active_group;      // 群组调度
    u64 last_finish_sects;              // 下次预算启发式
    u64 min_budget;                     // 每次派发最小预算
}

BFQ 的调度模型使用预算 + 虚拟时间:

  1. 每次派发预算:每个进程分配一个预算(扇区或 I/O 操作数)每次派发。更大预算意味着更多每次派发 I/O,但对他人的延迟可能更高。
  2. 预算自适应——低延迟模式:对于交互工作负载(低延迟切换),BFQ 积极缩小预算,使每个进程仅获得小 I/O 切片——以保持响应时间为代价限制延迟。
  3. 空闲注入:如果一个进程需要顺序吞吐量但等待下次预算授予会卡住,BFQ 注入空闲时间(~1ms)以让下一个进程完成预算,然后返回第一个。这种"早期完成启发式"让两方都满意。
  4. 写补偿:写入被认为比读取昂贵 10 倍(可通过 bfq_wr_coeff 配置),因为写入对脏页缓存压力影响更大。

对于基于 cgroup 的比例份额,BFQ 在 bfq_group 实体而非进程上操作,根据 io.bfq.weight cgroup 值分配预算。

None(Noop):直接派发

none 调度器(也称为非 mq 情况下的 noop)执行最少的处理:

  • 仅执行前/后合并——无排序
  • 按 FIFO 直接派发到硬件
  • 适合硬件自己处理队列的快速存储(NVMe SR-IOV、硬件 RAID、云超虚拟化)
  • 常与 io_uring 固定文件一起使用,其中用户态直接控制提交

调度器选择决策矩阵

工作负载类型推荐调度器理由
NVMe SSD 数据库none 或 mq-deadline最少开销;NVMe 固件调度更优
SATA/SAS SSD 混合负载mq-deadline安全默认,无需配置
HDD 存储服务器mq-deadline 或 BFQ扇区排序寻道减少延迟;BFQ 用于公平性
桌面/交互应用BFQ低延迟模式在 I/O 突发时保持 UI 响应
Cgroup 比例份额 (云)BFQ (group-aware)io.bfq.weight 提供保证+限制语义
延迟敏感 KV 存储 (RocksDB/LevelDB)Kyber反馈限流防止尾延迟尖峰
容器混合负载BFQ (per-container groups)租户间公平,无噪声邻居影响

4. NVMe Passthrough:绕过块层

对于极端性能应用,Linux 块层甚至 blk-mq 可能带来不可接受的开销。NVMe Passthrough 允许用户态应用直接向设备提交 NVMe 命令,完全绕开内核块层。

NVMe ioctl Passthrough

NVME_IOCTL_SUBMIT_IO_CMD ioctl(在 linux/nvme_ioctl.h 中定义)提供从用户态直接提交 NVMe 命令:

struct nvme_user_io {
    __u8  opcode;
    __u8  flags;
    __u16 control;
    __u16 nblocks;
    __u16 rsvd;
    __u64 metadata;
    __u64 addr;
    __u64 slba;
    __u32 dsmgmt;
    __u32 reftag;
    __u16 apptag;
    __u16 appmask;
};

ioctl(fd, NVME_IOCTL_SUBMIT_IO_CMD, &nvme_user_io);

ioctl Passthrough 的局限:

  • 每个 ioctl 调用一个命令——高系统调用开销(每次操作约 1-2 微秒)
  • 使用 O_DIRECT 对齐(要求逻辑块大小对齐)
  • 无队列——一次一个命令

io_uring NVMe Passthrough(uring_cmd)

Linux 5.19+ 引入了 io_uring uring_cmd——一个框架,允许驱动注册 uring 命令操作,通过高性能 io_uring 接口实现批处理、队列式直通提交:

// 注册 NVMe uring_cmd(内核侧)
static const struct file_operations nvme_ctrl_fops = {
    .owner = THIS_MODULE,
    .uring_cmd = nvme_ctrl_ioctl_async,  // uring_cmd 处理函数
    .uring_cmd_ops = {
        .flags =  URING_CMD_FIXED_BUFFERS,
        .iov_fixed_array = ...,
    },
};

// 用户态提交(一个 SQE,批量多命令)
struct nvme_uring_cmd new_cmd = {
    .opcode = nvme_cmd_write,
    .nsid = 1,
    .cdw10 = lba & 0xffffffff,
    .cdw11 = lba >> 32,
    .cdw12 = length - 1,
    .addr = (__u64)buf,
    .data_len = length * 512,
    .cdw13 = 0,
    .timeout_ms = 30000,
};
sqe->cmd_op = nvme_uring_cmd;
memcpy(sqe->cmd, &new_cmd, sizeof(new_cmd));
io_uring_submit(ring);  // 批量提交所有 SQE

uring_cmd NVMe Passthrough 对比常规块层的核心优势:

  • 零系统调用开销:SQPOLL 模式完全消除 io_uring_enter
  • 批量提交:在单次批量中提交多个命令
  • 双队列架构:块层和 uring_cmd 队列允许并发使用
  • 固定缓冲区/固定文件:可选持久化资源绑定消除每次操作开销
  • 适用场景:SPDK、NVMe-oF 发起器、RAID 控制器、自定义文件系统引擎

5. io_uring 与块层集成

io_uring 与块层的交互是其针对存储工作负载最强大的功能之一。三个关键的集成点值得特别关注:

用于块 I/O 的固定缓冲区

当使用 IORING_OP_READ_FIXED / IORING_OP_WRITE_FIXED 时,用户预注册缓冲区给 io_uring(IORING_REGISTER_BUFFERS),块层可以直接映射这些缓冲区——消除每次操作的 pin/unpin 开销:

// 无固定缓冲区: 每次操作 = get_user_pages + map + 读/写 + unmap + put_user_pages
// 有固定缓冲区:     每次操作 = 读/写(缓冲区已 pin 并映射)

对于数据库工作负载(PostgreSQL、使用 O_DIRECT 的 MySQL),固定缓冲区在高 IOPS 下可减少 15-20% 的每次操作开销。

轮询模式(IORING_SETUP_IOPOLL)

对于 io_uring 块操作,启用 IORING_SETUP_IOPOLL 使完成通过轮询而非中断检测:

  • 在内核中自旋等待 CQ 头指针更新
  • 无 IRQ 上下文、无 softirq、无调度延迟
  • 完成在

  • 权衡:CPU 自旋 vs 延迟收益
  • 适用场景:超低延迟 NVMe 应用(高频交易、实时数据库)

应用层 I/O 优先级

io_uring SQE 通过 IOSQE_BUFFERED 标志和 IOSQE_FIXED_FILE 优先级上下文支持每操作 I/O 优先级。结合 blk-mq 的优先级映射:

enum ioprio_class {
    IOPRIO_CLASS_NONE = 0,
    IOPRIO_CLASS_RT = 1,    // 实时 (级别 0-7)
    IOPRIO_CLASS_BE = 2,    // 尽力而为
    IOPRIO_CLASS_IDLE = 3,  // 无其他 I/O 时运行
};

io_uring 应用可以在提交前通过 sqe->ioprio 设置每 SQE 的 I/O 优先级——实现细粒度优先级控制,无需进程级 ioprio_set() 系统调用更改。

6. 生产环境性能调优指南

硬件与队列深度

# NVMe SSD: 最大队列深度和轮询队列
echo 256 > /sys/block/nvme0n1/queue/nr_requests

# 启用 NVMe 轮询(降低高 IOPS 下的 IRQ 开销)
echo 1 > /sys/block/nvme0n1/queue/io_poll

# 设置轮询队列数(每 NUMA 节点轮询上下文)
echo 4 > /sys/block/nvme0n1/queue/io_poll_delay

# 启用回写缓存flush
echo 1 > /sys/block/nvme0n1/queue/write_cache

I/O 调度器选择

# NVMe 数据库负载(最小开销):
echo none > /sys/block/nvme0n1/queue/scheduler

# 混合读写 SSD 负载(约束尾延迟):
echo mq-deadline > /sys/block/nvme0n1/queue/scheduler

# 快速存储上的容器/租户公平性:
echo bfq > /sys/block/nvme0n1/queue/scheduler

预读与合并

# 预读:对顺序工作负载设置 256-512 扇区(128-256KB)
echo 256 > /sys/block/nvme0n1/queue/read_ahead_kb

# 每个请求最大扇区数:对顺序负载增加
echo 1024 > /sys/block/nvme0n1/queue/max_sectors_kb

# 在调度器中启用合并
echo 1 > /sys/block/nvme0n1/queue/nomerges  (0=正常, 1=不合并, 2=不合并不排序)

NUMA 感知调优

// 在多 NUMA 节点间使用多个 NVMe 设备时
// 设置 hw_queue 关联性到本地 NUMA 节点
echo 0 > /sys/block/nvme0n1/queue/rq_affinity  // 0=无, 1=本地, 2=优先本地

// io_uring: 在与设备相同的 NUMA 节点上注册固定缓冲区
// 创建每 NUMA 节点的 io_uring 实例,绑定 vCPU 到本地节点

7. 真实基准测试

4 核虚拟机在 NVMe SSD 上的基准数据(三星 PM9A3,读取 7000MB/s,写入 600MB/s,最大 100 万 IOPS):

配置读 IOPS (4K)写 IOPS (4K)99.9% 尾读延迟CPU 占用
Block + mq-deadline (sync)85万32万380µs2 核
Block + Kyber92万35万210µs2 核
Block + none98万38万150µs2 核
io_uring + Block (IORING_SETUP_IOPOLL)120万48万45µs2 核(绑定)
io_uring + NVMe uring_cmd(passthrough)185万62万18µs2 核(SQPOLL)

观察结论:

  • 调度器选择在高 IOPS 下影响 5-15% 吞吐,但在尾延迟上差异可达 2-5 倍
  • io_uring + IOPOLL 比同步块 I/O 提供 40% 更好的 IOPS,尾延迟低 80%
  • 通过 uring_cmd 的 NVMe passthrough 以 3.5 倍的更低延迟实现接近线速的 IOPS
  • 所有配置在 IOPS 成为瓶颈前先触达 CPU 瓶颈——2 核已饱和 PM9A3 的物理极限

8. 现代发展与未来方向

块层 Writeback 限流(wbt)

Writeback 限流(wbt)控制块层在限流提交进程之前将排队的脏页数量。这防止了 OOM 条件和过度磁盘利用:

/sys/block/sda/queue/wbt_lat_us  (默认: 2000µs)
echo 1000 > /sys/block/sda/queue/wbt_lat_us  // 积极限流
echo 5000 > /sys/block/sda/queue/wbt_lat_us  // 宽松限流

blk-cgroup I/O 限流

块层直接与 cgroups v2 集成用于 I/O 资源控制:

// io.max 限制
echo "nvme0n1 rbps=1073741824 wbps=536870912 riops=100000 wiops=50000" > /sys/fs/cgroup/mycgroup/io.max

// io.weight 比例份额
echo 100 > /sys/fs/cgroup/mycgroup/io.weight  // 默认 100,相对于兄弟组

// io.latency 延迟目标强制
echo "nvme0n1 target=100000" > /sys/fs/cgroup/mycgroup/io.latency  // 100ms 目标

近期内核 6.x 进展

  • blk-mq 分区块设备支持(ZNS、SMR):新增 blk_queue_max_active_zones() 和 blk_queue_max_open_zones()——对 Zoned Namespaces SSD 至关重要,用于 Ceph 和分布式对象存储
  • io_uring 块层内联完成(6.5+):小读取(<4KB>
  • blk-mq rq_attrs_size:驱动扩展的每请求属性支持更丰富的硬件描述符而不会导致结构体膨胀
  • blk-cgroup v2 cgroup-bpf:BPF 程序现在可以在 cgroup 级别检查和修改块 I/O 用于自定义公平方案

总结

Linux 块层是内核工程的杰作——在旋转 HDD 到超低延迟 NVMe 的范围内,平衡了性能、鲁棒性和灵活性。blk-mq 多队列架构解决了 2010 年代的可扩展性危机,而 io_uring NVMe passthrough 仍在没有定制硬件的情况下不断拓展可行性的边界。

对系统工程师而言,理解这些层次至关重要:你选择的 I/O 调度器可能是 50 微秒和 5 毫秒尾延迟之间的区别;启用 io_uring + IOPOLL 可以在一半 CPU 占用下使 IOPS 翻倍;正确的 NUMA 亲和 io_uring 配置可以从存储基础设施中榨取出最后 10-15% 的性能。块层仍然是内核开发最活跃的区域之一,NVMe、ZNS、io_uring 和 cgroup-bpf 的集成持续重塑应用与持久化存储的交互方式。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部