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() → 唤醒等待者 / 标脏页干净
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_ctx | per-CPU 的软件队列(合并、统计) |
struct request | 一条命令,带 tag(在 tag_set 里唯一) |
blk_mq_submit_bio() 的流程:
- 用
blk_mq_get_new_requests()从当前 CPU 的软件队列拿一个 tag(可能复用current->plug里的 request); - 尝试
blk_mq_attempt_bio_merge()合并; - 拿不到 tag 就
blk_mq_get_tag()睡眠等待(或返回BLK_STS_AGAIN给NOWAIT调用者); 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 占用。
- 「SSD 不需要调度器」:
none适合 NVMe(设备自己有深队列),但 SATA SSD 队列浅,mq-deadline仍能改善延迟。 - 「
%util100% 就是磁盘瓶颈」:%util是「设备有请求在处理的采样时间占比」,对可并行的 SSD 会虚高;要看aqu-sz(平均队列长度)与await。 - 「
bfq一定比mq-deadline慢」:单流顺序 I/O 上略慢,但多流/多租户下公平性带来的总吞吐可能更高。 - 「改调度器立刻生效」:对已有队列生效,但在途请求按旧调度器走;切换后建议观察
iostat几个采样周期。 - 「
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 照样可能丢。
| 层次 | 保证 | 掉电后 |
|---|---|---|
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、小请求、没合并?
strace -c -e trace=read,write,fsync。 - 看 page cache:命中率高就不是磁盘问题;
free -h的buff/cache、/proc/vmstat的pgmajfault。 - 看块层:
iostat -x的await、aqu-sz、%util;blktrace看排队/设备时间占比。 - 看调度器:
/sys/block/*/queue/scheduler是否合适;NVMe 用none,SATA 用mq-deadline。 - 看文件系统:
filefrag是否碎片化;mount选项是否有noatime、discard。 - 最后才调参数:
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(不报错),这是文件系统实现差异,不是块层的。
某服务 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_next | struct bio * | 同一 request 里的下一个 bio |
bi_bdev | struct block_device * | 目标块设备(分区或整盘) |
bi_opf | blk_opf_t | 操作码 + 标志(见下) |
bi_flags | unsigned short | BIO_* 内部标志(BIO_CLONED、BIO_CHAIN 等) |
bi_ioprio | unsigned short | I/O 优先级(ioprio_set) |
bi_status | blk_status_t | 完成状态(BLK_STS_OK 或错误) |
bi_iter | struct bvec_iter | 当前扇区、剩余字节、当前向量索引 |
bi_vcnt | unsigned short | bio_vec 个数 |
bi_io_vec | struct bio_vec * | 页向量数组 |
bi_end_io | bio_end_io_t * | 完成回调 |
bi_private | void * | 回调私有数据 |
bi_cookie | unsigned 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
| 字段 | 类型 | 含义 |
|---|---|---|
q | struct request_queue * | 所属队列 |
mq_ctx | struct blk_mq_ctx * | 提交它的软件队列 |
mq_hctx | struct blk_mq_hw_ctx * | 派发到的硬件队列 |
cmd_flags | blk_opf_t | 操作码 + 标志(与 bio 合并后的结果) |
rq_flags | unsigned int | 内部状态(RQF_ELV、RQF_FLUSH_SEQ、RQF_MQ_INFLIGHT) |
tag | unsigned int | 驱动 tag(BLK_MQ_NO_TAG 表示无) |
internal_tag | int | 调度器内部 tag |
__sector | sector_t | 起始扇区 |
__data_len | unsigned int | 数据长度 |
bio / biotail | struct bio * | 合并后的 bio 链 |
end_io | rq_end_io_fn * | 完成回调(块层/驱动) |
start_time_ns | u64 | 进入队列的时间(延迟统计) |
io_start_time_ns | u64 | 派发给设备的时间 |
nr_phys_segments | unsigned 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_alignment | DMA 缓冲区对齐 | 3(8 字节)或 511(512 字节) |
io_min / io_opt | 最优 I/O 大小 | SSD 的 indirection unit |
chunk_sectors | RAID 条带大小 | 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 逻辑块」的常见组合。
- 「
bi_opf直接等于REQ_OP_*」:带标志时不等,必须bio_op()/req_op()。 - 「一个 bio 只能描述连续页」:
bio_vec数组可以指向任意页;连续性只影响合并与 DMA 段数。 - 「
bio_add_page()失败是错误」:返回 0 表示「这个 bio 装不下了」,应提交当前 bio 再新建;不是 I/O 错误。 - 「
rq->tag是数组下标」:它是tag_set的标签,具体含义由驱动的 tag map 决定;BLK_MQ_NO_TAG表示未分配。 - 「
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 时数据库延迟抖动」的一个来源。
- 「
queue_depth是块层队列长度」:它是设备能同时处理的命令数(tag 数);块层排队上限是nr_requests。 - 「
BLK_STS_RESOURCE是错误」:是「暂时没资源」,块层会重试;把它当 I/O 错误会误报。 - 「完成中断一定在提交 CPU」:默认尽量,但跨 NUMA/跨 CPU 时仍会 IPI;
IOPOLL则完全绕过中断。 - 「
nr_hw_queues等于 CPU 数」:可以不等;驱动可按 NUMA 节点或硬件队列数设置,少了会争用,多了浪费中断向量。 - 「
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_sectors | max_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() 的主循环:
- 若
batching未用完,从next_rq[READ]/next_rq[WRITE]取一个,减少红黑树查找; - 检查
fifo_list里有没有过期请求,有就优先发(防止饿死); - 读优先,但每服务
writes_starved个读批次后强制服务一个写; - 每次取请求后更新
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 把队列占满」的场景。目标延迟设得太小会导致令牌耗尽、吞吐下降;设得太大等于没有限流。
- 「合并越多越好」:合并增加单请求延迟,对交互式读不一定是好事;
mq-deadline的读优先就是为此。 - 「plug 会自动刷」:进程异常退出/长时间不调
blk_finish_plug时,I/O 可能一直攒着;io_schedule()前会刷,但纯 CPU 循环不会。 - 「
fifo_expire越小越好」:太小会让写请求频繁过期、打乱顺序,反而降低吞吐。 - 「
bfq的weight是百分比」:是相对权重,实际份额 = 权重 / 同层活跃队列权重之和。 - 「
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 |
- 「
EIO就是磁盘坏」:可能是线缆、HBA、多路径、文件系统元数据错误;先看dmesg的具体状态。 - 「
BLK_STS_RESOURCE会重试到成功」:块层会重跑派发,但如果设备一直没资源,请求最终会超时。 - 「分区是独立设备」:它共享整盘的
request_queue,/sys/block/vda/vda1/queue/是符号链接;调队列参数要调整盘。 - 「
fsync的EIO一定是这次写造成的」:errseq_t报告的是「自上次fsync以来」的任何回写错误,可能来自更早的写。 - 「超时时间越短越好」:太短会误杀慢设备(尤其虚拟化/网络存储),太长会让故障恢复变慢;按设备类型调。
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 数组登记到 ring | IOSQE_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 轮询 SQ | io_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一定绕过块层」:只有IOPOLL直接轮询设备;普通模式仍走blk_mq_submit_bio()。 - 「
cqe->res是字节数」:读/写是字节数,其他 opcode 是各自语义(如openat是 fd);负数才是错误。 - 「CQ 满了会阻塞」:不会,内核增加
cq.overflow并在下次enter时补;应用要检查overflow。 - 「registered buffer 可以提前 free」:内核 pin 住页,但应用仍拥有它;提前
free会让内核写已释放内存。 - 「
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_queue | dev、sector、nr_sector、rwbs、comm | bio 进入块层 |
block:block_bio_complete | dev、sector、nr_sector、error | bio 完成 |
block:block_rq_insert | dev、sector、nr_sector、rwbs、comm | request 插入调度器 |
block:block_rq_issue | 同上 + cmd | request 派发给驱动 |
block:block_rq_complete | dev、sector、nr_sector、error、cmd | 设备完成 |
block:block_rq_merge | dev、sector、nr_sector、rwbs | 合并 |
block:block_getrq / sleeprq | dev、sector、rwbs | 取 tag / 等 tag |
block:block_unplug | dev、nr_rq | plug 刷出 |
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 每行开头的字母表示请求生命周期的一个阶段:
| 码 | 含义 | 说明 |
|---|---|---|
Q | queued | bio 进入块层 |
G | get request | 分配到 request |
I | inserted | 插入调度器队列 |
M | merged | 合并(前/后) |
P | plugged | 进入 plug |
U | unplugged | plug 刷出 |
D | issued | 派发给驱动 |
C | completed | 设备完成 |
A | remap/abort? | 实际是 A = 合并(旧版),以 blkparse 文档为准 |
X | split | 请求拆分 |
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 加权。
blktrace+btt:拿到Q2G/G2I/I2D/D2C四段。iostat -x:aqu-sz判断排队,await判断总延迟,%util只作参考。bpftrace:按rwbs或进程分组的完成延迟直方图,找异常进程。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
- 「
fio的iodepth等于设备并发」:libaio的iodepth还受nr_requests与queue_depth限制;超过后提交线程会等 tag。 - 「
--direct=1一定绕过缓存」:文件系统仍可能有元数据缓存;测裸设备用/dev/vda,测文件用O_DIRECT且文件已fsync。 - 「
blktrace对 virtio-blk 一定可用」:需要CONFIG_BLK_DEV_IO_TRACE与 debugfs;容器里可能没权限。 - 「
btt的时间戳是墙钟」:它是 per-CPU 的 monotonic 时钟,跨 CPU 比较要先做时钟同步(btt会处理,但精度有限)。
3.13.17 陷阱清单:块层与持久化篇
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | %util 100% 但吞吐上不去 | 把「忙碌占比」当饱和 | 看 aqu-sz、await、fio 的 IOPS 曲线 |
| 2 | fsync 后掉电仍丢数据 | 设备/虚拟化层吞 flush | 查 write_cache、QEMU cache=、阵列电池 |
| 3 | O_DIRECT 返回 EINVAL | 缓冲区/偏移/长度未按逻辑块对齐 | posix_memalign + 对齐偏移 |
| 4 | iodepth 提高反而变慢 | 超过 queue_depth,tag 争用 | 压到 queue_depth 以内;看 nr_requests |
| 5 | 随机写延迟高 | 写缓存/调度器/文件系统碎片 | 换 mq-deadline/none;filefrag |
| 6 | 分区 I/O 错位 | 分区表/bd_start_sect 错 | lsblk -o NAME,START;重读分区表 |
| 7 | I/O 卡 30 秒后 EIO | 请求超时被 abort | 查 /sys/block/*/device/timeout 与 dmesg |
| 8 | io_uring 提交 -EAGAIN | ring 满/资源不足 | 增加 CQ 大小、处理 cq.overflow |
| 9 | linked chain 全部 -ECANCELED | 链中某个请求失败 | 逐段检查 cqe->res;拆链 |
| 10 | IOPOLL 返回 -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_cache | write back/through 的区别 | 3.13.5 |
读 iostat -x | 能指出 await 与 aqu-sz | 3.13.7 |
| 写出对齐的 O_DIRECT | 缓冲区/偏移/长度都对齐 | 3.13.8 |
| 说出 bio/request 关键字段 | bi_opf 位布局、tag | 3.13.10 |
解释 tag 分配与 BLK_STS_RESOURCE | sbitmap 与重试 | 3.13.11 |
| 画出合并的三个层次 | bio/plug/调度器 | 3.13.12 |
| 说清 I/O 错误上传链 | blk_status → wb_err → fsync | 3.13.13 |
| 说出 io_uring 的 SQ/CQ 语义 | head/tail 谁改 | 3.13.14 |
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–313 | 3.13.1、3.13.2、3.13.10 | 完整(设备、驱动、中断) |
| OSTEP | 第 37 章 磁盘驱动器 | p314–325 | 3.13.3、3.13.12 | 完整(调度器;CHS 几何在 1.13.5) |
| OSTEP | 第 38 章 RAID | p326–341 | 1.13.5、0.10.7、3.13.5 | 精选(RAID 选型在 1.13.5/0.10;3.13.5 只讲 flush 前提) |
| OSTEP | 第 44 章 数据完整性和保护 | p408–418 | 3.13.5、3.12.5.1 | 合并(flush/FUA;静默坏块/写错位置/scrub 在 3.12.5.1) |
| 奔跑吧 Linux 内核 | 第 5 章 5.1 中断管理机制 | p616–657 | 3.13.2、3.13.11 | 背景(完成中断、IPI 与队列) |
| 奔跑吧 Linux 内核 | 第 5 章 5.2 软中断与 tasklet | p658–670 | 3.13.11 | 背景(块完成的下半部) |
| 奔跑吧 Linux 内核 | 第 5 章 5.3 workqueue | p671–704 | 3.13.11 | 背景(io-wq 的对照) |
| 奔跑吧 Linux 内核 | 第 2 章 2.7 VMA 操作 | p141–153 | 3.13.1 | 背景(writeback 的来源) |
| Kerrisk | 第 13 章 文件 I/O 缓冲 | 章级 | 3.13.5 | 完整(O_SYNC/fsync 语义) |
下一站:3.14 设备驱动 从块层走到驱动:字符设备怎么注册、
file_operations怎么接到用户态、中断与 DMA 怎么配合、设备树/ACPI 怎么描述硬件。