Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3.13 块层、I/O 调度与持久化

本章回答

一次 write() 的数据,怎么从 page cache 变成设备上的一个命令?bio 与 request 为什么要分两级?blk-mq 的「多队列」多在哪,SSD 上为什么还要 I/O 调度器?fsync 的 flush 命令在块层长什么样,O_DIRECT 的对齐要求从哪来?iostat 的 %util 到 100% 就一定是磁盘瓶颈吗?

读完你会

画出「文件 → page cache → bio → request → 设备」的完整链路;按设备类型选 I/O 调度器并解释理由;说清 REQ_PREFLUSH/REQ_FUA 的持久化语义;用 iostat -x、blktrace、fio、bpftrace 各取一条证据;写出对齐正确的 O_DIRECT 读写。

3.12 停在 page cache:write() 把数据标脏,fsync() 把脏页写回。但「写回」到底怎么写?数据怎么变成设备能执行的命令?这就是块层(block layer)的职责。

块层是内核里「承上启下」的一层:向上给文件系统提供「读写一段逻辑块」的接口(submit_bio),向下把请求排队、合并、排序,交给驱动(nvme、virtio_blk、scsi)。它要同时处理三件互相冲突的事:吞吐(合并相邻 I/O)、延迟(别让读请求排在写后面太久)、持久化(flush 与 FUA 的顺序不能乱)。

本章路线:bio/request 两级结构 → blk-mq 多队列 → I/O 调度器 → 合并与 plug → 持久化语义 → io_uring → 观测与实验。母本:OSTEP 第 36–38、44 章(I/O 设备、磁盘、RAID、数据完整性),奔跑吧第 5 章(块层与中断)。

3.13.1 两级结构:bio 与 request

  用户 write() → page cache(folio 标脏)
        │  writeback(wb_workfn / kworker)
        ▼
  ┌──────────────────────────────────────────────┐
  │ struct bio:描述「一次 I/O 要读写哪些页」      │
  │  bi_bdev / bi_iter(扇区、偏移、长度)         │
  │  bi_io_vec[](每个向量:页 + 页内偏移 + 长度) │
  │  bi_opf(READ/WRITE/FLUSH/DISCARD + 标志)     │
  │  bi_end_io(完成回调)                         │
  └───────────────────┬──────────────────────────┘
                      │ submit_bio()
                      ▼
  ┌──────────────────────────────────────────────┐
  │ struct request:描述「设备要执行的一条命令」    │
  │  q / mq_hctx / mq_ctx(属于哪个队列)          │
  │  __sector / nr_phys_segments                   │
  │  bio 链表(合并后的多个 bio)                   │
  │  cmd_flags / rq_flags / tag(blk-mq 标签)     │
  └───────────────────┬──────────────────────────┘
                      │ blk_mq_dispatch_rq_list()
                      ▼
            驱动(nvme_queue_rq / virtblk_queue_rq)
                      │
                      ▼
                设备(NVMe / virtio-blk / SCSI)
                      │ 完成中断
                      ▼
             bio_endio() → 唤醒等待者 / 标脏页干净
定义 3.13.1 · bio 与 request

bio 是「文件系统视角的 I/O」:一组页(bio_vec)+ 起始扇区 + 操作类型 + 完成回调。它不关心设备怎么排队,可以跨不连续的物理页。request 是「设备视角的 I/O」:一条设备命令,包含一个或多个合并后的 bio,带标签(tag)、硬件队列(hctx)、超时与优先级。一个 bio 可能被合并进已有 request,也可能单独成为一个 request。

bio 的关键字段:

/* include/linux/blk_types.h —— 6.8 节选,已简化 */
struct bio {
	struct bio		*bi_next;
	struct block_device	*bi_bdev;
	blk_opf_t		bi_opf;		/* REQ_OP_* + 标志 */
	unsigned short		bi_flags;
	unsigned short		bi_ioprio;
	blk_status_t		bi_status;
	struct bvec_iter	bi_iter;	/* 当前扇区、剩余长度 */
	unsigned short		bi_vcnt;	/* bio_vec 个数 */
	struct bio_vec		*bi_io_vec;	/* 页数组 */
	bio_end_io_t		*bi_end_io;
	void			*bi_private;
	...
};

submit_bio() 之后,块层先尝试合并:如果新 bio 与某个已排队的 request 在扇区上相邻(前合并/后合并),就挂进去;否则新建 request。合并能显著减少设备命令数,是块层吞吐的主要来源。

3.13.2 blk-mq:每个 CPU 一个软件队列

传统块层只有一个 request_queue 和一把锁,核多了争用严重。blk-mq(4.0+,6.x 唯一实现)把队列分层:

   CPU0   CPU1   CPU2   CPU3
     │      │      │      │
     ▼      ▼      ▼      ▼
  ┌──────────────────────────┐
  │ software queues(per-CPU)│  ← 提交 bio 时无锁
  └───────────┬──────────────┘
              │ 按 mapping 分发
     ┌────────┴────────┐
     ▼                 ▼
  hctx 0            hctx 1        ← hardware queues(对应设备队列)
  tag 0..N          tag 0..N
     │                 │
     ▼                 ▼
  NVMe SQ 0         NVMe SQ 1
结构作用
struct blk_mq_tag_set标签集合,决定 queue_depth 与 nr_hw_queues
struct blk_mq_hw_ctx一个硬件队列的上下文(dispatch 链表、tag map、驱动队列)
struct blk_mq_ctxper-CPU 的软件队列(合并、统计)
struct request一条命令,带 tag(在 tag_set 里唯一)

blk_mq_submit_bio() 的流程:

  1. 用 blk_mq_get_new_requests() 从当前 CPU 的软件队列拿一个 tag(可能复用 current->plug 里的 request);
  2. 尝试 blk_mq_attempt_bio_merge() 合并;
  3. 拿不到 tag 就 blk_mq_get_tag() 睡眠等待(或返回 BLK_STS_AGAIN 给 NOWAIT 调用者);
  4. blk_mq_sched_insert_request() 交给 I/O 调度器或直接 blk_mq_run_hw_queue() 派发。

queue_depth 是设备能同时处理的命令数(NVMe 通常 1024,virtio 128–256)。iodepth 超过 queue_depth 不会更快,只会在块层排队。

tag 的分配与回收是 blk-mq 的核心开销之一。tag_set 里每个硬件队列维护一个 sbitmap,blk_mq_get_tag() 用位图找空位,blk_mq_put_tag() 归还。请求超时(默认 30 秒,/sys/block/*/device/timeout)时,blk_mq_timeout_work() 会遍历在途 tag,调用驱动的 ->timeout();若驱动也救不回来,就 abort 并让上层看到 -EIO。这就是「磁盘卡死 30 秒后应用报 I/O error」的来源。

blk-mq 也负责 CPU 热插拔时的队列重映射(blk_mq_update_nr_hw_queues)与中断亲和性(每个 hctx 一个中断向量,绑到对应 CPU)。热插拔时 blk_mq_map_queues() 重新计算「CPU → hctx」的映射,在途请求不受影响,但新请求会走新映射——这是容器/虚拟机动态调整 vCPU 时 I/O 性能可能抖一下的原因。

3.13.3 I/O 调度器:机械盘与 SSD 的不同答案

调度器策略适合代价
none不排序,直接派发NVMe、低延迟 SSD依赖设备自身排队
mq-deadline读优先 + 过期时间,防止写饿死读SATA SSD、HDD、通用排序开销小
kyber读/写/丢弃三组令牌桶,按延迟目标限流快速 SSD调参依赖目标延迟
bfq按 cgroup/进程权重分配带宽,低延迟模式桌面、HDD、多租户CPU 开销大

查看与切换:

cat /sys/block/vda/queue/scheduler
# [mq-deadline] kyber bfq none
echo none > /sys/block/vda/queue/scheduler

mq-deadline 的核心是两条按扇区排序的红黑树(读、写)加一个 FIFO 过期队列:

  • 读请求默认过期时间短(百毫秒级),写请求长(秒级),保证读延迟;
  • writes_starved 控制「连续服务多少个读批次后必须服务一个写」,防止写饿死;
  • 相邻扇区的请求会被合并,减少寻道(对 HDD 有效)。

bfq 是唯一支持「按 cgroup 权重分配 I/O 带宽」的调度器,bfq.weight(1–1000,默认 100)与 cgroup v2 的 io.weight 对应。它在桌面场景能显著降低「后台拷贝大文件时前台卡顿」,代价是 CPU 占用。

I/O 调度器的五个误解
  1. 「SSD 不需要调度器」:none 适合 NVMe(设备自己有深队列),但 SATA SSD 队列浅,mq-deadline 仍能改善延迟。
  2. 「%util 100% 就是磁盘瓶颈」:%util 是「设备有请求在处理的采样时间占比」,对可并行的 SSD 会虚高;要看 aqu-sz(平均队列长度)与 await。
  3. 「bfq 一定比 mq-deadline 慢」:单流顺序 I/O 上略慢,但多流/多租户下公平性带来的总吞吐可能更高。
  4. 「改调度器立刻生效」:对已有队列生效,但在途请求按旧调度器走;切换后建议观察 iostat 几个采样周期。
  5. 「nr_requests 越大越好」:它只是块层排队上限,超过设备 queue_depth 的部分只会增加延迟,不会提高吞吐。

3.13.4 合并、plug 与 REQ 标志

plug(插头) 是「攒一批再发」的机制:进程提交 I/O 时,块层先把 request 挂在 current->plug 链表上,等到 blk_finish_plug()(通常是 io_schedule() 或 syscall 返回前)再一次性派发。这样相邻的多个 bio 有机会合并,减少设备命令数。

plug 的效果可以用一次顺序写估算:写 1 MiB(256 个 4 KiB 页)时,若每个 bio 单独下发,就是 256 条设备命令;plug 攒住后,块层把扇区相邻的 bio 合并成少数几个大 request(受 max_sectors_kb 限制),设备命令数可能降到个位数。代价是「攒」的这段时间 I/O 没有真正下发,所以对延迟敏感的同步 I/O,内核会在 io_schedule() 前主动 blk_flush_plug()。

REQ_* 标志决定请求的语义:

标志含义
REQ_OP_READ/REQ_OP_WRITE读写
REQ_OP_FLUSH刷设备写缓存(无数据)
REQ_OP_DISCARD通知 SSD「这些块不用了」(TRIM)
REQ_OP_WRITE_ZEROES写零(比写数据快,SSD 常用)
REQ_SYNC同步请求(影响调度优先级)
REQ_META元数据(文件系统内部)
REQ_PREFLUSH写之前先 flush
REQ_FUA写完成后保证落到介质(Force Unit Access)
REQ_NOWAIT不睡眠,拿不到资源立即返回
REQ_POLLED轮询完成(io_uring IOPOLL)

REQ_PREFLUSH 与 REQ_FUA 是持久化的关键,下一节展开。

3.13.5 持久化:flush、FUA 与「fsync 到底保证什么」

设备(尤其是 SSD 与虚拟磁盘)有易失写缓存:数据写进缓存就返回「完成」,掉电就丢。fsync 要保证的,是数据真正落到介质。

块层的两个原语:

  • flush(REQ_OP_FLUSH):命令设备「把缓存里的数据全写到介质」,无数据负载;
  • FUA(REQ_FUA):单条写命令带「写穿到介质」语义,不必额外 flush。

fsync 的路径(接 3.12):

fsync(fd)
  → ext4_sync_file()
      → filemap_write_and_wait_range()     数据页回写(普通写)
      → jbd2_complete_transaction()        等日志提交
      → blkdev_issue_flush(bdev)           REQ_OP_FLUSH 下发
          → blk_insert_flush()
              → 若有 REQ_PREFLUSH:先发 flush_rq,再发数据
              → 数据 request 完成后,若有 REQ_FUA:等设备确认

blk_insert_flush() 是块层处理 flush 的核心:

/* block/blk-flush.c: blk_insert_flush() —— 6.8 节选,已简化 */
void blk_insert_flush(struct request *rq)
{
	struct request_queue *q = rq->q;
	unsigned long fflags = q->queue_flags;
	unsigned int policy = blk_flush_policy(fflags, rq);
	...
	if (!policy) {
		/* 设备没有写缓存,或请求不需要 flush:直接派发 */
		blk_mq_run_hw_queue(rq->mq_hctx, false);
		return;
	}
	if (policy & REQ_FUA)
		rq->rq_flags |= RQF_FUA;
	if (policy & REQ_PREFLUSH)
		rq->rq_flags |= RQF_FLUSH_SEQ;
	...
}

设备是否有写缓存,由驱动在初始化时用 blk_queue_write_cache(q, wc, fua) 声明:

cat /sys/block/vda/queue/write_cache
# write back   ← 有易失缓存,fsync 必须发 flush
# write through ← 无缓存,fsync 可以省掉 flush

两件事必须分清:write_cache 是内核的「认知」,它来自设备/驱动上报;设备实际有没有缓存是硬件事实。如果认知是 write through 而设备其实有缓存(或虚拟化层隐藏了),内核就不会发 flush,fsync 的保证就落空。验证手段:

# SATA/SAS:看设备的 WCE 位
hdparm -W /dev/sda        # 输出 "write-caching = 1 (on)"
sdparm --get=WCE /dev/sda

# NVMe:看 controller 的 VWC 位
nvme id-ctrl /dev/nvme0 | grep vwc

# 虚拟磁盘:看 QEMU 的 cache 模式
# cache=writeback  → 有缓存,需 flush(默认)
# cache=none       → 绕过宿主 page cache,直通设备
# cache=unsafe     → 忽略 flush,性能最好、最不安全

O_DIRECT 并不等于「绕过写缓存」:它绕过的是内核 page cache,设备缓存仍在,O_DIRECT 写之后不 fsync 照样可能丢。

定义 3.13.2 · 持久化语义的层次
层次 保证 掉电后
write() 返回 数据在 page cache 可能丢(未回写)
fdatasync() 返回 数据 + 必要元数据在介质 不丢(设备守规矩时)
fsync() 返回 数据 + 全部元数据在介质 不丢
O_SYNC 写 每次 write 等价 fsync 不丢,但极慢
sync() 返回 所有文件系统已刷 不丢,但可能耗时几分钟

「设备守规矩」是前提:虚拟化层吞掉 flush、磁盘固件撒谎、RAID 卡无电池缓存却开 write-back,都会让 fsync 的保证失效。生产环境要用带掉电保护的设备,或至少验证 write_cache 设置。

3.13.6 io_uring:批量提交与轮询完成

io_uring(5.1+)把「提交」与「完成」做成两个共享内存环形队列(SQ/CQ),一次系统调用可以提交成百上千个 I/O,避免「每个 I/O 一次 syscall」的开销:

用户态                                 内核态
┌──────────────┐                     ┌──────────────────┐
│ SQ ring      │ io_uring_enter()    │ io_uring 提交队列  │
│  sqe[0..N]   ├────────────────────►│  → blk_mq_submit  │
└──────────────┘                     └────────┬─────────┘
┌──────────────┐   完成中断/轮询               │
│ CQ ring      │◄─────────────────────────────┘
│  cqe[0..N]   │   (IORING_SETUP_IOPOLL 时轮询设备)
└──────────────┘

关键特性:

特性作用
IORING_SETUP_SQPOLL内核线程轮询 SQ,用户态完全不进内核(省 syscall)
IORING_SETUP_IOPOLL轮询设备完成,省中断(低延迟 NVMe)
IORING_OP_READV/WRITEV向量读写
IORING_OP_FSYNC异步 fsync
IOSQE_IO_LINK串行链(前一个完成才发下一个)
RWF_DSYNC/RWF_SYNC单次写带持久化语义

io_uring 并不绕过块层(除非用 IORING_SETUP_IOPOLL 直接轮询设备),它的收益在「减少系统调用与上下文切换」,而不是「跳过调度」。

一个用 liburing 的读程序(需要 liburing-dev):

/* uring_read.c — 用 io_uring 读文件
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -O2 -o uring_read uring_read.c -luring
 */
#define _GNU_SOURCE
#include <fcntl.h>
#include <liburing.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define QD 8
#define BS 4096

int main(int argc, char *argv[])
{
	const char *path = argc > 1 ? argv[1] : "/etc/hosts";
	struct io_uring ring;
	struct io_uring_sqe *sqe;
	struct io_uring_cqe *cqe;
	char *buf;
	int fd, ret;

	if (posix_memalign((void **)&buf, BS, BS) != 0) return 1;
	fd = open(path, O_RDONLY | O_DIRECT);
	if (fd == -1) { perror("open"); return 1; }

	ret = io_uring_queue_init(QD, &ring, 0);
	if (ret < 0) { fprintf(stderr, "queue_init: %s\n", strerror(-ret)); return 1; }

	sqe = io_uring_get_sqe(&ring);
	io_uring_prep_read(sqe, fd, buf, BS, 0);
	io_uring_submit(&ring);

	ret = io_uring_wait_cqe(&ring, &cqe);
	if (ret < 0) { fprintf(stderr, "wait_cqe: %s\n", strerror(-ret)); return 1; }
	printf("read %d bytes, first=%02x\n", cqe->res, (unsigned char)buf[0]);
	io_uring_cqe_seen(&ring, cqe);
	io_uring_queue_exit(&ring);
	close(fd);
	free(buf);
	return 0;
}

注意 O_DIRECT 要求缓冲区按逻辑块大小对齐(这里 4096),否则 open 或 read 返回 EINVAL。

3.13.7 观测:iostat、blktrace、fio、bpftrace

iostat -x 1 的字段:

字段含义怎么读
r/s / w/s每秒读写次数IOPS
rkB/s / wkB/s每秒读写字节吞吐
rrqm/s / wrqm/s每秒合并的请求数合并率高说明顺序性好
r_await / w_await平均等待时间(ms,含排队)延迟
aqu-sz平均队列长度> 1 说明有排队
rareq-sz / wareq-sz平均请求大小(KB)判断顺序/随机
%util设备忙碌时间占比对 SSD 不可靠

fio 是压测的标准工具:

# 4K 随机读,iodepth 32,直接 I/O,跑 30 秒
fio --name=randread --ioengine=libaio --iodepth=32 --rw=randread \
    --bs=4k --direct=1 --size=1G --runtime=30 --time_based \
    --filename=/tmp/fio.dat --output-format=json

# 顺序写,测吞吐
fio --name=seqwrite --ioengine=libaio --iodepth=8 --rw=write \
    --bs=1M --direct=1 --size=2G --runtime=30 --time_based --filename=/tmp/fio.dat

blktrace 抓块层事件:

blktrace -d /dev/vda -o /tmp/trace &
# ... 跑负载 ...
kill %1
blkparse -i /tmp/trace | head -30

blkparse 的输出里 A(合并)、Q(排队)、G(获取 request)、I(插入调度器)、D(派发)、C(完成)标记了一个请求的完整生命周期,能精确算出「排队时间」与「设备时间」。

bpftrace 统计块层延迟:

# 按读写统计完成延迟(微秒)
bpftrace -e '
tracepoint:block:block_rq_issue { @start[args->dev, args->sector] = nsecs; }
tracepoint:block:block_rq_complete
/@start[args->dev, args->sector]/
{
	@us[args->rwbs] = hist((nsecs - @start[args->dev, args->sector]) / 1000);
	delete(@start[args->dev, args->sector]);
}'
I/O 性能排查顺序
  1. 看应用:是不是同步 I/O、小请求、没合并?strace -c -e trace=read,write,fsync。
  2. 看 page cache:命中率高就不是磁盘问题;free -h 的 buff/cache、/proc/vmstat 的 pgmajfault。
  3. 看块层:iostat -x 的 await、aqu-sz、%util;blktrace 看排队/设备时间占比。
  4. 看调度器:/sys/block/*/queue/scheduler 是否合适;NVMe 用 none,SATA 用 mq-deadline。
  5. 看文件系统:filefrag 是否碎片化;mount 选项是否有 noatime、discard。
  6. 最后才调参数:nr_requests、read_ahead_kb、vm.dirty_*。

3.13.8 实验:队列参数与 O_DIRECT 对齐

模块读取一个块设备的队列参数,验证「逻辑块/物理块/队列深度」的存在:

// queue_demo.c — 打印块设备的队列参数(Linux 6.8)
#include <linux/init.h>
#include <linux/module.h>
#include <linux/blkdev.h>

static char *devpath = "/dev/vda";
module_param(devpath, charp, 0444);
MODULE_PARM_DESC(devpath, "block device path");

static int __init queue_demo_init(void)
{
	struct bdev_handle *handle;
	struct block_device *bdev;
	struct request_queue *q;

	handle = blkdev_get_by_path(devpath, BLK_OPEN_READ, THIS_MODULE, NULL);
	if (IS_ERR(handle)) {
		pr_err("queue_demo: cannot open %s: %ld\n", devpath, PTR_ERR(handle));
		return 0;
	}
	bdev = handle->bdev;
	q = bdev_get_queue(bdev);
	pr_info("queue_demo: %s sectors=%llu logical=%u physical=%u\n",
		devpath, (unsigned long long)bdev_nr_sectors(bdev),
		queue_logical_block_size(q), queue_physical_block_size(q));
	pr_info("queue_demo: nr_requests=%lu scheduler=%s\n",
		q->nr_requests, q->elevator ? q->elevator->type->elevator_name : "none");
	bdev_release(handle);
	return 0;
}

static void __exit queue_demo_exit(void) { }

module_init(queue_demo_init);
module_exit(queue_demo_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("dump block queue limits");

预期输出类似 logical=512 physical=512(virtio-blk 默认)或 logical=4096 physical=4096(NVMe 4Kn)。O_DIRECT 的对齐要求就是 logical_block_size。

用户态验证对齐:

/* direct_align.c — 验证 O_DIRECT 的对齐要求
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -O2 -o direct_align direct_align.c
 * 运行: ./direct_align /tmp/fio.dat
 */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
	const char *path = argc > 1 ? argv[1] : "/tmp/fio.dat";
	char *buf;
	int fd;
	ssize_t n;

	if (posix_memalign((void **)&buf, 4096, 4096) != 0) return 1;
	memset(buf, 0x41, 4096);

	fd = open(path, O_RDWR | O_DIRECT);
	if (fd == -1) { perror("open O_DIRECT"); return 1; }

	/* 对齐的读:偏移 0、长度 4096、缓冲区 4096 对齐 → 成功 */
	n = pread(fd, buf, 4096, 0);
	printf("aligned   pread -> %zd (%s)\n", n, n < 0 ? strerror(errno) : "ok");

	/* 不对齐的读:偏移 1 → EINVAL */
	n = pread(fd, buf, 4096, 1);
	printf("unaligned pread -> %zd (%s)\n", n, n < 0 ? strerror(errno) : "ok");

	close(fd);
	free(buf);
	return 0;
}

在 ext4 上,未对齐的 O_DIRECT 读返回 EINVAL;在 xfs 上某些情况会自动回退到 buffered(不报错),这是文件系统实现差异,不是块层的。

例 3.13.1 · 一次「iostat 100% 但磁盘没满」的误判

某服务 iostat -x 显示 %util 长期 100%,运维判断「磁盘到极限」,申请换 SSD。换完发现 %util 还是 100%,但 QPS 提升了 3 倍。

原因:%util 是「设备有请求在处理的采样占比」,对支持并行的 SSD,只要一直有请求在飞,它就到 100%,与「是否饱和」无关。真正的饱和指标是 aqu-sz(平均队列长度)与 await(延迟):如果 aqu-sz 很小、await 稳定,说明设备还有余力;如果 aqu-sz 持续增长、await 上升,才是真饱和。

这个例子说明:块层指标要组合看,单看 %util 会误导。

3.13.10 bio、request 与 queue:字段级的 I/O 描述

这一节回答:bi_opf 的位怎么分、一个 bio 如何描述不连续的页、request 里的 tag 与 hctx 从哪来、request_queue 的 limits 谁维护。

3.13.1 给了两级结构的轮廓。真正读块层代码或写一个「直接发 bio」的模块时,需要看清字段。

struct bio 与 bio_vec

字段类型(6.8)含义
bi_nextstruct bio *同一 request 里的下一个 bio
bi_bdevstruct block_device *目标块设备(分区或整盘)
bi_opfblk_opf_t操作码 + 标志(见下)
bi_flagsunsigned shortBIO_* 内部标志(BIO_CLONED、BIO_CHAIN 等)
bi_iopriounsigned shortI/O 优先级(ioprio_set)
bi_statusblk_status_t完成状态(BLK_STS_OK 或错误)
bi_iterstruct bvec_iter当前扇区、剩余字节、当前向量索引
bi_vcntunsigned shortbio_vec 个数
bi_io_vecstruct bio_vec *页向量数组
bi_end_iobio_end_io_t *完成回调
bi_privatevoid *回调私有数据
bi_cookieunsigned int轮询 cookie(IORING_SETUP_IOPOLL)

bio_vec 描述「一个页里的一段」:

/* include/linux/bvec.h —— 6.8 节选 */
struct bio_vec {
	struct page	*bv_page;
	unsigned int	bv_len;
	unsigned int	bv_offset;
};

struct bvec_iter {
	sector_t	bi_sector;	/* 当前扇区 */
	unsigned int	bi_size;	/* 剩余字节 */
	unsigned int	bi_idx;		/* 当前 bio_vec 下标 */
	unsigned int	bi_bvec_done;	/* 当前向量内已处理字节 */
};

所以一个 bio 可以描述「32 个不连续的 4 KiB 页」:bi_io_vec[0..31] 各指一页,bi_iter 在遍历时推进。bio_add_page() 只在「页内偏移 + 长度不超过页大小」且「总长度不超过 max_sectors」时才接受;否则返回 0,调用者要另起一个 bio。

bi_opf 的位布局

blk_opf_t 的低 8 位是操作码,高位是标志:

/* include/linux/blk_types.h —— 6.8 节选 */
#define REQ_OP_BITS	8
#define REQ_OP_MASK	((1 << REQ_OP_BITS) - 1)
#define REQ_FLAG_BITS	24

enum req_op {
	REQ_OP_READ		= 0,
	REQ_OP_WRITE		= 1,
	REQ_OP_FLUSH		= 2,
	REQ_OP_DISCARD		= 3,
	REQ_OP_SECURE_ERASE	= 5,
	REQ_OP_WRITE_ZEROES	= 9,
	REQ_OP_ZONE_OPEN	= 10,
	REQ_OP_ZONE_CLOSE	= 11,
	REQ_OP_ZONE_FINISH	= 12,
	REQ_OP_ZONE_APPEND	= 13,
	REQ_OP_ZONE_RESET	= 15,
	REQ_OP_DRV_IN		= 34,
	REQ_OP_DRV_OUT		= 35,
	REQ_OP_LAST,
};

bio_op(bio) 就是 bio->bi_opf & REQ_OP_MASK;标志位从 bit 8 开始,包括 REQ_SYNC、REQ_META、REQ_PREFLUSH、REQ_FUA、REQ_NOWAIT、REQ_POLLED 等。不要把 bi_opf 当操作码直接比较:bi_opf == REQ_OP_WRITE 在带 REQ_SYNC 时是假,必须用 bio_op()。

struct request

字段类型含义
qstruct request_queue *所属队列
mq_ctxstruct blk_mq_ctx *提交它的软件队列
mq_hctxstruct blk_mq_hw_ctx *派发到的硬件队列
cmd_flagsblk_opf_t操作码 + 标志(与 bio 合并后的结果)
rq_flagsunsigned int内部状态(RQF_ELV、RQF_FLUSH_SEQ、RQF_MQ_INFLIGHT)
tagunsigned int驱动 tag(BLK_MQ_NO_TAG 表示无)
internal_tagint调度器内部 tag
__sectorsector_t起始扇区
__data_lenunsigned int数据长度
bio / biotailstruct bio *合并后的 bio 链
end_iorq_end_io_fn *完成回调(块层/驱动)
start_time_nsu64进入队列的时间(延迟统计)
io_start_time_nsu64派发给设备的时间
nr_phys_segmentsunsigned short物理段数(DMA 映射用)

rq->bio 与 rq->biotail 构成一条单链表,rq_for_each_bio() 遍历。一个 request 的 __data_len 是链上所有 bio 的长度之和;合并时只允许「扇区连续、操作码相同、优先级兼容」的 bio。

request_queue 的 limits

queue_limits 是块层对「设备能接受什么」的声明,驱动在初始化时设置:

字段含义典型值
logical_block_size逻辑块大小512 或 4096
physical_block_size物理块大小4096
max_sectors单请求最大扇区数由 max_hw_sectors 限制
max_segments单请求最大段数128(virtio)
max_segment_size单段最大字节64 KiB
dma_alignmentDMA 缓冲区对齐3(8 字节)或 511(512 字节)
io_min / io_opt最优 I/O 大小SSD 的 indirection unit
chunk_sectorsRAID 条带大小0 表示无

这些值都能在 /sys/block/<dev>/queue/ 下看到:

for f in logical_block_size physical_block_size max_sectors_kb \
         max_segments max_segment_size dma_alignment io_min io_opt; do
	printf '%-24s %s\n' "$f" "$(cat /sys/block/vda/queue/$f 2>/dev/null)"
done

max_sectors_kb 是「块层允许的最大请求」,可以调小(牺牲合并换延迟),不能超过 max_hw_sectors_kb。dma_alignment 决定 O_DIRECT 的缓冲区对齐要求:x86 上通常是 511(512 字节对齐),不是 4096——4096 对齐只是「大多数文件系统 + 4K 逻辑块」的常见组合。

bio/request 的五个坑
  1. 「bi_opf 直接等于 REQ_OP_*」:带标志时不等,必须 bio_op()/req_op()。
  2. 「一个 bio 只能描述连续页」:bio_vec 数组可以指向任意页;连续性只影响合并与 DMA 段数。
  3. 「bio_add_page() 失败是错误」:返回 0 表示「这个 bio 装不下了」,应提交当前 bio 再新建;不是 I/O 错误。
  4. 「rq->tag 是数组下标」:它是 tag_set 的标签,具体含义由驱动的 tag map 决定;BLK_MQ_NO_TAG 表示未分配。
  5. 「max_sectors_kb 越大越好」:超过设备/驱动上限会被拒绝;过大还会增加单请求延迟、影响公平性。

3.13.11 blk-mq 的 tag 与派发:一条命令怎么落到硬件队列

这一节回答:tag 从哪来、软件队列怎么映射到硬件队列、派发为什么可能失败、完成为什么有时要 IPI。

三级对象

blk_mq_tag_set(整个设备一份)
  ├── nr_hw_queues、queue_depth、numa_node
  ├── tags[]:每个 hctx 一个 blk_mq_tags(sbitmap + rqs 数组)
  └── map[]:CPU → hctx 的映射

blk_mq_hw_ctx(每个硬件队列一个)
  ├── tags / sched_tags:驱动 tag / 调度器 tag
  ├── dispatch:待派发 request 链表
  ├── cpumask:允许提交的 CPU
  └── driver_data:驱动私有(NVMe 的 SQ、virtio 的 vq)

blk_mq_ctx(每个 CPU 一个软件队列)
  ├── rq_lists[]:按 hctx 类型分的待处理链表
  └── cpu、index_hctx

blk_mq_map_queue(q, op, ctx) 决定「这个请求去哪个 hctx」:

/* block/blk-mq.h —— 6.8 节选,已简化 */
static inline struct blk_mq_hw_ctx *blk_mq_map_queue(struct request_queue *q,
						     blk_opf_t opf,
						     struct blk_mq_ctx *ctx)
{
	enum hctx_type type = HCTX_TYPE_DEFAULT;

	if ((opf & REQ_POLLED) && q->tag_set->nr_maps > HCTX_TYPE_POLL)
		type = HCTX_TYPE_POLL;
	else if (op_is_sync(opf) && q->tag_set->nr_maps > HCTX_TYPE_READ)
		type = HCTX_TYPE_READ;
	...
	return ctx->hctxs[type];
}

三类 hctx(HCTX_TYPE_DEFAULT、READ、POLL)允许设备为读、写、轮询分别建队列。NVMe 常见「读队列 + 写队列 + 轮询队列」分离,减少读写互相干扰。

tag 分配:sbitmap

每个 blk_mq_tags 有两个 sbitmap:bitmap_tags(驱动 tag)与 breserved_tags(预留 tag,用于回收/刷新等不能失败的路径)。

blk_mq_get_tag()
  → __blk_mq_get_tag()
      → sbitmap_queue_get()          快速路径:找空位
      → 失败:io_schedule() 等 tag 释放(或 NOWAIT 返回 BLK_STS_AGAIN)
  → blk_mq_get_driver_tag()          把 tag 绑到 request

queue_depth 就是 sbitmap 的位数。nr_requests 是「块层最多排多少个 request」,通常等于 queue_depth * 2 或更大;它限制的是排队长度,不是并发命令数。

tag 耗尽是常见的「I/O 卡住」原因:

# 看每个 hctx 的 tag 使用
cat /sys/block/nvme0n1/mq/0/tags 2>/dev/null
ls /sys/block/nvme0n1/mq/
# 0 1 2 3 ... 每个目录对应一个 hctx

如果应用 iodepth 远大于 queue_depth,多出来的请求在 blk_mq_get_tag() 里睡眠,表现为「提交线程变慢」而不是设备更快。

派发:为什么 blk_mq_run_hw_queue 可能「没派出去」

blk_mq_dispatch_rq_list() 遍历 hctx->dispatch,对每个 request 调 q->mq_ops->queue_rq()。返回值决定下一步:

返回值含义块层动作
BLK_STS_OK已交给设备从 dispatch 链表摘除
BLK_STS_RESOURCE设备暂时没资源(队列满)停止派发,等完成后再跑
BLK_STS_DEV_RESOURCE设备资源但需重跑标记 BLK_MQ_S_SCHED_RESTART
BLK_STS_AGAIN需要重试(NOWAIT)放回链表
其他错误I/O 失败blk_mq_end_request() 结束

BLK_STS_RESOURCE 是「派发失败但不是错误」:blk_mq_dispatch_rq_list() 返回 false,blk_mq_run_hw_queue() 不再重试,等某个 request 完成时通过 blk_mq_sched_restart() 重新触发。这种「完成驱动派发」的设计避免了忙等。

完成:IPI 与轮询

设备完成后,驱动调 blk_mq_complete_request(rq):

blk_mq_complete_request(rq)
  → 若当前 CPU 就是提交 CPU,且没有特殊要求:
      __blk_mq_complete_request()      直接在本 CPU 完成
  → 否则:
      blk_mq_complete_request_remote() 发 IPI 到提交 CPU
          → 在目标 CPU 上执行完成回调(保持 cache 局部性)

「在提交 CPU 上完成」是性能优化:bio 的完成回调通常要访问提交线程的栈/缓存,跨 CPU 完成会让 cache line 弹来弹去。/proc/interrupts 里 NVMe 的中断亲和性就是按这个原则配的。

IORING_SETUP_IOPOLL 走另一条路:blk_mq_poll() 主动查询设备完成队列(NVMe 的 CQ),不依赖中断。它适合「iodepth 高、延迟极低」的场景,但会占满一个 CPU 轮询。

CPU 热插拔与队列重映射

blk_mq_update_nr_hw_queues() 在设备重启或 CPU 数变化时重建映射:

blk_mq_update_nr_hw_queues(tag_set, nr_hw_queues)
  → blk_mq_realloc_hw_ctxs()
  → blk_mq_map_queues()
  → blk_mq_queue_reinit()

在途请求挂在旧 hctx 上,完成路径仍能工作;新请求走新映射。blk_mq_queue_reinit() 会冻结队列、迁移软件队列、恢复派发,这期间 I/O 会短暂停顿——这就是「热插拔 CPU 时数据库延迟抖动」的一个来源。

blk-mq 的五个坑
  1. 「queue_depth 是块层队列长度」:它是设备能同时处理的命令数(tag 数);块层排队上限是 nr_requests。
  2. 「BLK_STS_RESOURCE 是错误」:是「暂时没资源」,块层会重试;把它当 I/O 错误会误报。
  3. 「完成中断一定在提交 CPU」:默认尽量,但跨 NUMA/跨 CPU 时仍会 IPI;IOPOLL 则完全绕过中断。
  4. 「nr_hw_queues 等于 CPU 数」:可以不等;驱动可按 NUMA 节点或硬件队列数设置,少了会争用,多了浪费中断向量。
  5. 「blk_mq_run_hw_queue 返回就代表发出去了」:不一定;返回 false 表示没派完,调用者要等完成或重试。

3.13.12 合并、plug 与调度器内部:请求在队列里怎么排队

这一节回答:一个 bio 什么时候能合并进已有 request、plug 链表什么时候刷、mq-deadline 的两棵红黑树怎么排、bfq 怎么按权重分带宽。

合并的三个层次

层次函数合并对象条件
bio 合并blk_mq_attempt_bio_merge()新 bio → 已有 request扇区相邻、操作码相同、优先级兼容
plug 合并blk_attempt_plug_merge()新 bio → 当前进程 plug 链表同上,且 request 还在 plug 里
调度器合并elv_merge()新 request → 调度器队列ELEVATOR_BACK_MERGE/FRONT_MERGE

合并失败的原因要会读:

原因检查
扇区不相邻blktrace 的 A 事件 vs Q
操作码不同(读 vs 写)bio_op()
REQ_NOMERGE文件系统/驱动显式禁止
超过 max_sectorsmax_sectors_kb
物理段数超限max_segments
优先级/调度器不允许ioprio、cgroup

plug:攒批的边界

/* block/blk-core.c —— 6.8 节选,已简化 */
void blk_start_plug(struct blk_plug *plug)
{
	struct task_struct *tsk = current;

	if (tsk->plug)
		return;
	INIT_LIST_HEAD(&plug->mq_list);
	INIT_LIST_HEAD(&plug->cb_list);
	plug->cached_rq = NULL;
	plug->nr_ios = 0;
	plug->rq_count = 0;
	plug->multiple_queues = false;
	tsk->plug = plug;
}

void blk_finish_plug(struct blk_plug *plug)
{
	if (plug != current->plug)
		return;
	blk_flush_plug(plug, true);
	current->plug = NULL;
}

谁会 blk_start_plug()?

  • submit_bh()/mpage_writepages() 在读/写一批页时;
  • io_uring 的批量提交;
  • 文件系统的 writepages 循环;
  • blkdev_issue_flush() 之前的批量写。

blk_flush_plug() 在两种时机被调:显式 blk_finish_plug(),或 io_schedule() 前(避免「攒着 I/O 去睡觉」导致死锁)。plug->multiple_queues 为真时,刷出要按队列分组,避免跨队列乱序。

mq-deadline 的数据结构

/* block/mq-deadline.c —— 6.8 节选 */
struct deadline_data {
	/* 按扇区排序的红黑树:读/写各一棵 */
	struct rb_root sort_list[2];
	/* 按过期时间排序的 FIFO:读/写各一条 */
	struct list_head fifo_list[2];
	/* 下一次从哪棵树上取 */
	struct request *next_rq[2];
	unsigned int batching;
	unsigned int starved;
	int fifo_expire[2];
	int fifo_batch;
	int writes_starved;
	int front_merges;
	u32 async_depth;
	spinlock_t lock;
	spinlock_t zone_lock;
};

dd_dispatch_request() 的主循环:

  1. 若 batching 未用完,从 next_rq[READ]/next_rq[WRITE] 取一个,减少红黑树查找;
  2. 检查 fifo_list 里有没有过期请求,有就优先发(防止饿死);
  3. 读优先,但每服务 writes_starved 个读批次后强制服务一个写;
  4. 每次取请求后更新 batching/starved,并可能触发 blk_mq_run_hw_queue()。

fifo_expire[READ] 默认 100 ms、fifo_expire[WRITE] 默认 5 s(/sys/block/*/queue/iosched/ 可调)。读的过期时间短,是因为交互式读延迟比写吞吐更敏感。

bfq:按权重分带宽

bfq 为每个 cgroup/进程维护一个 bfq_queue,用「预算 + 虚拟时间」调度:

概念作用
bfq_weight队列权重(1–1000,默认 100)
bfq_budget一次调度给多少字节
bfq_vtime虚拟时间,按权重推进
low_latency低延迟模式:交互队列优先
bfq_bfqq每个队列的调度实体

cgroup v2 的 io.weight 映射到 bfq_weight;io.max 则是硬限速(blk-throttle,与调度器无关)。bfq 的代价是每次派发都要算虚拟时间,CPU 开销比 mq-deadline 高,高 IOPS 设备上不划算。

kyber:按延迟目标限流

kyber 把 I/O 分成读、写、discard 三类,每类一个令牌桶,按「目标延迟」动态调整令牌数:

/sys/block/*/queue/iosched/read_lat_nsec      读目标延迟
/sys/block/*/queue/iosched/write_lat_nsec     写目标延迟
/sys/block/*/queue/iosched/discard_lat_nsec   discard 目标延迟

它适合「设备很快、但不想让某类 I/O 把队列占满」的场景。目标延迟设得太小会导致令牌耗尽、吞吐下降;设得太大等于没有限流。

合并与调度的五个坑
  1. 「合并越多越好」:合并增加单请求延迟,对交互式读不一定是好事;mq-deadline 的读优先就是为此。
  2. 「plug 会自动刷」:进程异常退出/长时间不调 blk_finish_plug 时,I/O 可能一直攒着;io_schedule() 前会刷,但纯 CPU 循环不会。
  3. 「fifo_expire 越小越好」:太小会让写请求频繁过期、打乱顺序,反而降低吞吐。
  4. 「bfq 的 weight 是百分比」:是相对权重,实际份额 = 权重 / 同层活跃队列权重之和。
  5. 「kyber 的令牌数固定」:它按目标延迟动态调整,read_lat_nsec 改了要观察几个采样周期才稳定。

3.13.13 错误处理、分区与超时:I/O 失败怎么一路传上去

这一节回答:blk_status_t 有哪些值、分区偏移在哪一层加、I/O 超时后内核做什么、错误怎么变成 fsync 的 EIO。

错误码:blk_status_t

块层不用 errno,而是 blk_status_t(u8 枚举),避免在快速路径上处理负值:

状态含义典型来源
BLK_STS_OK成功—
BLK_STS_NOTSUPP操作不支持discard/写零不支持
BLK_STS_TIMEOUT超时驱动/传输层
BLK_STS_NOSPC空间不足thin provisioning、zone 满
BLK_STS_TRANSPORT传输错误链路/线缆
BLK_STS_TARGET目标错误SCSI target
BLK_STS_NEXUS预留冲突SCSI reservation
BLK_STS_MEDIUM介质错误坏道
BLK_STS_PROTECTION完整性校验失败T10-PI
BLK_STS_RESOURCE资源暂时不足队列满(可重试)
BLK_STS_IOERR通用 I/O 错误驱动兜底
BLK_STS_AGAIN需要重试NOWAIT 路径
BLK_STS_ZONE_*zone 相关zoned block device

blk_status_to_errno() 把状态转成 errno:NOTSUPP → -EOPNOTSUPP、NOSPC → -ENOSPC、TIMEOUT → -ETIMEDOUT、其余多数 → -EIO。用户态看到的 EIO 往往是「块层也不知道具体原因」,要看 dmesg 里驱动的详细报错。

提交前的检查

submit_bio_noacct() 在把 bio 交给队列前做几项检查:

submit_bio_noacct(bio)
  → submit_bio_checks(bio)
      → bio_check_ro()          写只读设备 → BLK_STS_IOERR
      → bio_check_eod()         超出设备末尾 → BLK_STS_IOERR
      → blk_partition_remap()   分区:扇区 += 分区起始
      → blk_throtl_bio()        cgroup I/O 限流
      → blk_crypto_bio_prep()   内联加密
  → blk_mq_submit_bio()

bio_check_eod() 是「越界写」的守门人:bi_iter.bi_sector + 长度 > bdev_nr_sectors() 就拒绝。用户态 pwrite 超出文件末尾会先被文件系统扩展文件大小,不会直接触发它;但 O_DIRECT 写裸设备、或文件系统元数据算错块号时会。

blk_partition_remap() 处理分区偏移:/dev/vda1 的 bd_start_sect 是 2048,bio 的扇区要加上它才落到整盘的正确位置。5.11 之前分区用 struct hd_struct,之后统一为 struct block_device(bd_partno != 0 表示分区),所以旧代码里的 hd_struct 在 6.8 已经不存在。

完成与部分完成

驱动完成中断
  → blk_mq_end_request(rq, status)
      → blk_update_request(rq, status, nr_bytes)
          → 遍历 rq->bio,按字节数更新 bi_iter
          → 全部完成:bio_endio(bio)
          → 部分完成:保留剩余,返回 false
      → 若还有剩余:重新派发或标记错误

blk_update_request() 支持「部分完成」:设备可能只写了请求的一部分(如 SCSI 的 residual),块层更新 bi_iter 后把剩余部分重新排队。文件系统看到的是「这个 bio 完成了多少字节」,而不是「全成/全败」。

bio_endio() 一路向上:

bio_endio(bio)
  → bio->bi_end_io(bio)           ← 文件系统的完成回调
      → ext4_end_bio() / iomap_finish_ioend()
      → folio_end_writeback()     清除 PG_writeback
      → mapping_set_error()       记录 wb_err(供 fsync 报告)
      → 唤醒等待者(wait_on_page_writeback)

mapping_set_error() 就是 3.12.13 里 errseq_t 的来源:错误被记在 address_space->wb_err,下一次 fsync 通过 errseq_check_and_advance() 看到它并返回 -EIO。

超时

每个 request 有 deadline(jiffies),blk_add_timer() 在入队时设置。blk_mq_timeout_work() 定期扫描:

blk_mq_timeout_work()
  → 遍历在途 tag
      → 超时:rq->q->mq_ops->timeout(rq)     ← 驱动决定
          → BLK_EH_DONE        驱动已处理
          → BLK_EH_RESET_TIMER 再给一次机会
          → BLK_EH_RESET_TIMER 多次后:abort
      → abort:blk_mq_end_request(rq, BLK_STS_TIMEOUT)

超时时间在 /sys/block/<dev>/device/timeout(SCSI 默认 30 秒,NVMe 默认由控制器 ctrl_loss_tmo 决定)。超时后请求被 abort,文件系统收到 EIO;如果设备随后又完成了这个请求,块层会丢弃重复完成(RQF_MQ_INFLIGHT 已清)。

现象可能原因排查
EIO + dmesg 有 blk_update_request: I/O error介质/链路错误smartctl -a、nvme error-log
30 秒卡顿后 EIO请求超时/sys/block/*/device/timeout、dmesg 的 abort 日志
ENOSPC 但 df 有空间thin pool/zone 满dmsetup status、blkzone report
分区读写错位分区表/bd_start_sect 错lsblk -o NAME,START,SECTORS
错误处理的五个坑
  1. 「EIO 就是磁盘坏」:可能是线缆、HBA、多路径、文件系统元数据错误;先看 dmesg 的具体状态。
  2. 「BLK_STS_RESOURCE 会重试到成功」:块层会重跑派发,但如果设备一直没资源,请求最终会超时。
  3. 「分区是独立设备」:它共享整盘的 request_queue,/sys/block/vda/vda1/queue/ 是符号链接;调队列参数要调整盘。
  4. 「fsync 的 EIO 一定是这次写造成的」:errseq_t 报告的是「自上次 fsync 以来」的任何回写错误,可能来自更早的写。
  5. 「超时时间越短越好」:太短会误杀慢设备(尤其虚拟化/网络存储),太长会让故障恢复变慢;按设备类型调。

3.13.14 io_uring 的内部结构:SQ/CQ、fixed file 与 linked chain

这一节回答:SQ/CQ 的 head/tail 谁改、io_uring_enter 一次能提交多少、IOSQE_IO_LINK 失败时为什么是 -ECANCELED、SQPOLL 省掉了什么。

两个环

SQ ring(用户写、内核读)
  sq.head(内核更新,表示已消费到哪)
  sq.tail(用户更新,表示提交到哪)
  sq.ring_mask、sq.ring_entries
  sq.flags(IORING_SQ_NEED_WAKEUP)
  sq.dropped(内核丢弃的 sqe 数)
  sq.array[](索引 → sqe 下标)

CQ ring(内核写、用户读)
  cq.head(用户更新,表示已处理到哪)
  cq.tail(内核更新,表示完成到哪)
  cq.ring_mask、cq.ring_entries
  cq.overflow(CQ 满时溢出计数)
  cqes[](16 字节的完成事件)

struct io_uring_sqe 是 64 字节定长:

/* include/uapi/linux/io_uring.h —— 6.8 节选 */
struct io_uring_sqe {
	__u8	opcode;
	__u8	flags;		/* IOSQE_* */
	__u16	ioprio;
	__s32	fd;
	union { __u64 off; __u64 addr2; ... };
	union { __u64 addr; __u64 splice_off_in; ... };
	__u32	len;
	union { __kernel_rwf_t rw_flags; ... };
	__u64	user_data;	/* 原样回到 cqe */
	union { __u16 buf_index; __u16 buf_group; };
	__u16	personality;
	union { __s32 splice_fd_in; __u32 file_index; ... };
	...
};

user_data 是给用户态用的「请求 ID」,内核不解释;cqe->user_data 原样返回,应用靠它匹配请求。cqe->res 是返回值(负数是 -errno),cqe->flags 是 IORING_CQE_F_BUFFER 等标志。

一次 io_uring_enter 做什么

io_uring_enter(fd, to_submit, min_complete, flags, sig, sz)
  → io_uring_enter2()
      → io_submit_sqes(ctx, to_submit)
          → 从 sq.tail 读 sqe,按 sq.array 取下标
          → io_init_req() 分配 io_kiocb,复制 sqe
          → io_issue_sqe() 直接执行(read/write/fsync)
              → 需要异步:io_queue_async() 交给 io-wq 工作线程
      → 若 min_complete > 0:io_cqring_wait() 等待完成

to_submit 可以一次提交几百个 sqe;min_complete 是「至少等到几个完成」。IORING_ENTER_GETEVENTS 表示「提交后等完成」,不设就只提交。

fixed file 与 registered buffer

每次 I/O 都 fdget()/fget() 有开销。io_uring_register() 可以预注册:

注册类型作用sqe 标志
IORING_REGISTER_FILES把 fd 数组登记到 ringIOSQE_FIXED_FILE,sqe->fd 变成索引
IORING_REGISTER_BUFFERS登记缓冲区(页被 pin 住)IORING_OP_READ_FIXED/WRITE_FIXED
IORING_REGISTER_FILES_UPDATE更新登记表—
IORING_REGISTER_EVENTFD完成事件写 eventfd—
IORING_REGISTER_PBUF_RING提供的缓冲区环(网络收包)IOSQE_BUFFER_SELECT

fixed file 省掉「fd → struct file」的查找与引用计数;registered buffer 省掉「用户地址 → 页」的 pin,但要求缓冲区生命周期由应用保证(提前释放会写坏内存)。

linked chain 与取消

IOSQE_IO_LINK 把多个 sqe 串成链:前一个成功才发下一个;前一个失败,后续全部以 -ECANCELED 完成。链的长度受 IO_URING_MAX_LINK_NR?实际上没有硬上限,但链太长会增加延迟。

IOSQE_IO_DRAIN 是另一种顺序保证:它等前面所有请求完成后才发自己,常用于「写数据 → fsync」的屏障。IOSQE_ASYNC 强制走 io-wq,避免在提交上下文里做可能阻塞的操作。

io_uring 的取消有三条路:

方式作用
IORING_OP_ASYNC_CANCEL按 user_data/fd 取消一个请求
io_uring_register(IORING_REGISTER_SYNC_CANCEL)同步取消
关闭 ring取消所有未完成请求

SQPOLL 与 IOPOLL

模式机制省掉代价
IORING_SETUP_SQPOLL内核线程 io_uring-sq 轮询 SQio_uring_enter 系统调用一个 CPU 常驻轮询
IORING_SETUP_IOPOLL提交后轮询设备 CQ完成中断CPU 轮询设备
两者同时完全无系统调用、无中断两者两个 CPU 可能被占满

SQPOLL 需要 CAP_SYS_ADMIN 或 IORING_SETUP_SQ_AFF 指定 CPU;空闲时会睡眠(IORING_SETUP_SQPOLL + sq_thread_idle),不是永远忙等。IOPOLL 只对支持轮询的块设备(NVMe)有效,普通 SATA 会返回 -EOPNOTSUPP。

6.8 的边界

  • io_uring 的 opcode 在 6.8 已覆盖 read/write/fsync/accept/connect/send/recv/splice/timeout/poll/openat/statx/mkdir/unlink/rename 等;
  • IORING_OP_FIXED_FD_INSTALL 在 6.8 引入(把 fixed file 安装成真实 fd);
  • 部分文件系统/网络协议仍会「punt 到 io-wq」,即不是真正的异步,只是换了个线程做;
  • io_uring 的安全策略受 kernel.io_uring_disabled 控制(6.6+):0 允许、1 禁止非特权、2 完全禁止。
io_uring 的五个坑
  1. 「io_uring 一定绕过块层」:只有 IOPOLL 直接轮询设备;普通模式仍走 blk_mq_submit_bio()。
  2. 「cqe->res 是字节数」:读/写是字节数,其他 opcode 是各自语义(如 openat 是 fd);负数才是错误。
  3. 「CQ 满了会阻塞」:不会,内核增加 cq.overflow 并在下次 enter 时补;应用要检查 overflow。
  4. 「registered buffer 可以提前 free」:内核 pin 住页,但应用仍拥有它;提前 free 会让内核写已释放内存。
  5. 「SQPOLL 不需要任何系统调用」:仍需 io_uring_enter 处理 IORING_SQ_NEED_WAKEUP 与 CQ 等待;只是提交路径省了。

3.13.15 观测:block tracepoint、blktrace 动作码与 btt

这一节回答:block_rq_* 的参数字段有哪些、blktrace 的 Q/G/I/D/C 各代表什么、btt 怎么把延迟拆成「排队/设备/完成」。

block tracepoint 的字段

事件关键参数含义
block:block_bio_queuedev、sector、nr_sector、rwbs、commbio 进入块层
block:block_bio_completedev、sector、nr_sector、errorbio 完成
block:block_rq_insertdev、sector、nr_sector、rwbs、commrequest 插入调度器
block:block_rq_issue同上 + cmdrequest 派发给驱动
block:block_rq_completedev、sector、nr_sector、error、cmd设备完成
block:block_rq_mergedev、sector、nr_sector、rwbs合并
block:block_getrq / sleeprqdev、sector、rwbs取 tag / 等 tag
block:block_unplugdev、nr_rqplug 刷出

dev 是 dev_t(major<<20 | minor),不是 major:minor 字符串;bpftrace 里要自己拆:

sudo bpftrace -e '
tracepoint:block:block_rq_issue {
	printf("%s %d:%d sector=%llu len=%u rwbs=%s\n", comm,
	       args->dev >> 20, args->dev & ((1<<20)-1),
	       args->sector, args->nr_sector, args->rwbs);
}'

blktrace 的动作码

blkparse 每行开头的字母表示请求生命周期的一个阶段:

码含义说明
Qqueuedbio 进入块层
Gget request分配到 request
Iinserted插入调度器队列
Mmerged合并(前/后)
Pplugged进入 plug
Uunpluggedplug 刷出
Dissued派发给驱动
Ccompleted设备完成
Aremap/abort?实际是 A = 合并(旧版),以 blkparse 文档为准
Xsplit请求拆分

blkparse 的 -f 可以自定义格式,btt 则把 trace 变成「每个 I/O 的时间分解」:

blktrace -d /dev/vda -o /tmp/trace -w 10
btt -i /tmp/trace -o /tmp/btt
cat /tmp/btt/btt.log | head -20

btt 输出里的 Q2G(排队等 request)、G2I(调度器)、I2D(等派发)、D2C(设备时间)四段相加就是总延迟。哪一段占比最大,优化方向就在哪:

  • Q2G/G2I 大:块层排队/调度器瓶颈;
  • I2D 大:设备队列满或驱动派发慢;
  • D2C 大:设备本身慢(或虚拟化层)。

fio 的延迟分布

fio --name=randread --ioengine=libaio --iodepth=32 --rw=randread \
    --bs=4k --direct=1 --size=1G --runtime=30 --time_based \
    --filename=/tmp/fio.dat --percentile_list=50:99:99.9:99.99

fio 的 clat(completion latency)不含排队,lat 含排队;比较两者能看出「设备慢」还是「队列长」。--lat_percentiles=1 让百分位按延迟统计而不是按 IOPS 加权。

块层延迟的四段拆解
  1. blktrace + btt:拿到 Q2G/G2I/I2D/D2C 四段。
  2. iostat -x:aqu-sz 判断排队,await 判断总延迟,%util 只作参考。
  3. bpftrace:按 rwbs 或进程分组的完成延迟直方图,找异常进程。
  4. fio:控制变量(iodepth、bs、direct、调度器)复现,确认瓶颈可复现。

3.13.16 实验:iodepth 矩阵与 btt 延迟拆解

这一节给两个实验:一个用 fio 做「iodepth × 调度器」矩阵,验证「超过 queue_depth 不会更快」;一个用 blktrace + btt 拆解一次随机读的延迟。均在 QEMU(Linux 6.8,virtio-blk)验证流程,未在真机实测。

实验一:iodepth 矩阵

# 准备 2 GiB 测试文件(在虚机内,避免宿主缓存干扰)
dd if=/dev/zero of=/tmp/fio.dat bs=1M count=2048 oflag=direct

# 看设备队列深度
cat /sys/block/vda/queue/nr_requests
cat /sys/block/vda/queue/scheduler
cat /sys/block/vda/queue/max_sectors_kb

# 4K 随机读,iodepth 从 1 到 256
for qd in 1 4 16 64 256; do
	fio --name=r$qd --ioengine=libaio --iodepth=$qd --rw=randread \
	    --bs=4k --direct=1 --size=2G --runtime=15 --time_based \
	    --filename=/tmp/fio.dat --output-format=json \
	    --percentile_list=99 2>/dev/null | \
	  jq -r --arg qd "$qd" '"iodepth=\($qd) iops=\(.jobs[0].read.iops|floor) p99=\(.jobs[0].read.clat_ns.percentile."99.000000"|tonumber/1000|floor)us"'
done

预期曲线:iodepth 1→16 时 IOPS 近似线性上升,16→64 增长放缓,64→256 基本持平(virtio-blk 的 queue_depth 通常 128)。如果 256 反而下降,说明 tag 争用/调度开销超过了并行收益。

换调度器再跑一遍:

for sched in none mq-deadline kyber bfq; do
	echo $sched > /sys/block/vda/queue/scheduler
	echo "== $sched =="
	# 取 iodepth=32 的 IOPS
	fio --name=s --ioengine=libaio --iodepth=32 --rw=randread \
	    --bs=4k --direct=1 --size=2G --runtime=10 --time_based \
	    --filename=/tmp/fio.dat --output-format=json 2>/dev/null | \
	  jq -r '.jobs[0].read.iops|floor'
done

预期:none 与 kyber 在 NVMe 上接近;mq-deadline 在 SATA/HDD 上更好;bfq 单流随机读最慢(它优先公平性)。

实验二:btt 拆解一次随机读

# 抓 10 秒块层事件
blktrace -d /dev/vda -o /tmp/trace -w 10 &
sleep 1
fio --name=probe --ioengine=libaio --iodepth=16 --rw=randread \
    --bs=4k --direct=1 --size=1G --runtime=8 --time_based \
    --filename=/tmp/fio.dat >/dev/null
wait

# 分析
btt -i /tmp/trace -o /tmp/btt
ls /tmp/btt
# btt.log 里有每个 I/O 的 Q/G/I/D/C 时间戳
head -30 /tmp/btt/btt.log

btt.log 的每一行形如:

  8,0  0  1  0.000000000  12345  Q  R 4096 + 8 [fio]
  8,0  0  2  0.000012345  12345  G  R 4096 + 8 [fio]
  8,0  0  3  0.000045678  12345  I  R 4096 + 8 [fio]
  8,0  0  4  0.000078901  12345  D  R 4096 + 8 [fio]
  8,0  0  5  0.000234567  12345  C  R 4096 + 8 [0]

把 D 到 C 的时间减去,就是设备服务时间;Q 到 D 是块层排队。若 Q→D 远大于 D→C,瓶颈在块层/调度器;反之在设备。

btt 还会生成 iostat 风格的汇总:

cat /tmp/btt/iostat.log 2>/dev/null | head
实验的四个坑
  1. 「fio 的 iodepth 等于设备并发」:libaio 的 iodepth 还受 nr_requests 与 queue_depth 限制;超过后提交线程会等 tag。
  2. 「--direct=1 一定绕过缓存」:文件系统仍可能有元数据缓存;测裸设备用 /dev/vda,测文件用 O_DIRECT 且文件已 fsync。
  3. 「blktrace 对 virtio-blk 一定可用」:需要 CONFIG_BLK_DEV_IO_TRACE 与 debugfs;容器里可能没权限。
  4. 「btt 的时间戳是墙钟」:它是 per-CPU 的 monotonic 时钟,跨 CPU 比较要先做时钟同步(btt 会处理,但精度有限)。

3.13.17 陷阱清单:块层与持久化篇

#现象原因修复
1%util 100% 但吞吐上不去把「忙碌占比」当饱和看 aqu-sz、await、fio 的 IOPS 曲线
2fsync 后掉电仍丢数据设备/虚拟化层吞 flush查 write_cache、QEMU cache=、阵列电池
3O_DIRECT 返回 EINVAL缓冲区/偏移/长度未按逻辑块对齐posix_memalign + 对齐偏移
4iodepth 提高反而变慢超过 queue_depth,tag 争用压到 queue_depth 以内;看 nr_requests
5随机写延迟高写缓存/调度器/文件系统碎片换 mq-deadline/none;filefrag
6分区 I/O 错位分区表/bd_start_sect 错lsblk -o NAME,START;重读分区表
7I/O 卡 30 秒后 EIO请求超时被 abort查 /sys/block/*/device/timeout 与 dmesg
8io_uring 提交 -EAGAINring 满/资源不足增加 CQ 大小、处理 cq.overflow
9linked chain 全部 -ECANCELED链中某个请求失败逐段检查 cqe->res;拆链
10IOPOLL 返回 -EOPNOTSUPP设备/文件系统不支持轮询换 NVMe;或去掉 IORING_SETUP_IOPOLL
11写吞吐远低于读写放大/垃圾回收/日志提交看 iostat 的 wkB/s、fio 的 clat
12容器里 blktrace 不可用缺 CAP_SYS_ADMIN/debugfs在宿主抓,或用 bpftrace tracepoint

3.13.18 本章出口检查

检查项达标线没达标去哪
画出 bio→request→驱动链能说出合并发生在哪3.13.1
解释 blk-mq 两级队列软件队列 vs 硬件队列3.13.2
按设备选调度器NVMe/SATA/HDD 各一例3.13.3
说清 PREFLUSH 与 FUA谁先谁后、各自保证什么3.13.5
解释 write_cachewrite back/through 的区别3.13.5
读 iostat -x能指出 await 与 aqu-sz3.13.7
写出对齐的 O_DIRECT缓冲区/偏移/长度都对齐3.13.8
说出 bio/request 关键字段bi_opf 位布局、tag3.13.10
解释 tag 分配与 BLK_STS_RESOURCEsbitmap 与重试3.13.11
画出合并的三个层次bio/plug/调度器3.13.12
说清 I/O 错误上传链blk_status → wb_err → fsync3.13.13
说出 io_uring 的 SQ/CQ 语义head/tail 谁改3.13.14
即时自测 · 3.13

Q1 一个 128 KiB 的写请求,在块层可能变成几个 bio、几个 request?什么情况下会合并?

Q2 blk-mq 的 queue_depth=1024,应用用 iodepth=64 压测,块层会排队吗?如果把 iodepth 提到 2048 呢?

Q3 fsync 返回 0 后立刻拔电,数据一定不丢吗?列出三个可能让它失效的因素。

Q4 REQ_PREFLUSH 与 REQ_FUA 的区别是什么?为什么有的文件系统只用 FUA 不用 PREFLUSH?

Q5 O_DIRECT 的读为什么可能返回 EINVAL?在 ext4 与 xfs 上行为一样吗?

答案

A1 取决于页的连续性:bio 按 bio_vec 描述页,128 KiB 若是 32 个连续页,可以是一个 bio(32 个向量);若页不连续,可能拆成多个 bio。request 由「扇区相邻的 bio」合并而成,顺序写通常合成 1 个 request,随机写可能每个 bio 一个 request。

A2 iodepth=64 小于 queue_depth=1024,块层一般不会因 tag 不足排队(但可能因调度器/合并排队)。提到 2048 超过 queue_depth,blk_mq_get_tag() 会让提交者睡眠等待 tag,应用层看到的是提交变慢,而不是设备更快。

A3 不一定。三个因素:① 设备/虚拟化层没有真正执行 flush(写缓存仍在);② 磁盘固件撒谎或 RAID 卡无电池却开 write-back;③ 文件系统/块层的 flush 顺序有 bug(少见但发生过)。fsync 的保证依赖「设备遵守 flush 语义」这个前提。

A4 REQ_PREFLUSH 要求「在写数据之前先 flush 一次」,用于保证「之前的写已经落盘」;REQ_FUA 要求「这条写完成后数据已在介质」,不需要额外 flush。只用 FUA 的文件系统(如某些配置的 ext4/xfs)假定设备支持 FUA,省掉一次 flush 往返,延迟更低;不支持 FUA 的设备上块层会退化成 PREFLUSH + 普通写。

A5 因为 O_DIRECT 要求缓冲区地址、文件偏移、读写长度都按 logical_block_size 对齐,任一不满足就 EINVAL。ext4 严格返回 EINVAL;xfs 在某些未对齐场景会回退到 buffered I/O(不报错但失去 direct 语义),所以「同样的代码在 xfs 上能跑」不代表对齐正确。

母本对应

母本章节/小节原书页(PDF)本书落点程度
OSTEP第 36 章 I/O 设备p303–3133.13.1、3.13.2、3.13.10完整(设备、驱动、中断)
OSTEP第 37 章 磁盘驱动器p314–3253.13.3、3.13.12完整(调度器;CHS 几何在 1.13.5)
OSTEP第 38 章 RAIDp326–3411.13.5、0.10.7、3.13.5精选(RAID 选型在 1.13.5/0.10;3.13.5 只讲 flush 前提)
OSTEP第 44 章 数据完整性和保护p408–4183.13.5、3.12.5.1合并(flush/FUA;静默坏块/写错位置/scrub 在 3.12.5.1)
奔跑吧 Linux 内核第 5 章 5.1 中断管理机制p616–6573.13.2、3.13.11背景(完成中断、IPI 与队列)
奔跑吧 Linux 内核第 5 章 5.2 软中断与 taskletp658–6703.13.11背景(块完成的下半部)
奔跑吧 Linux 内核第 5 章 5.3 workqueuep671–7043.13.11背景(io-wq 的对照)
奔跑吧 Linux 内核第 2 章 2.7 VMA 操作p141–1533.13.1背景(writeback 的来源)
Kerrisk第 13 章 文件 I/O 缓冲章级3.13.5完整(O_SYNC/fsync 语义)

下一站:3.14 设备驱动 从块层走到驱动:字符设备怎么注册、file_operations 怎么接到用户态、中断与 DMA 怎么配合、设备树/ACPI 怎么描述硬件。