DMA 与 mempool 解决「缓冲能否被设备 DMA」之后,上层仍需要一套与具体后端解耦的块语义:读、写、unmap、flush,以及失败时的排队与 reset。SPDK Block Device User Guide 把这一层叫作 bdev:官方措辞是「等价于传统内核存储栈里紧贴驱动之上的操作系统块存储层」。本文只钉三件事:统一了什么、一次 I/O 怎么走完、相对 Linux block 层砍掉了什么。
本文是「SPDK / 用户态存储栈」系列第 6 篇(共 16 篇)。→ 系列目录
篇目 核心内容 第 5 篇 · DMA / mempool 可 DMA 内存与缓冲池 第 6 篇 · bdev 核心 统一接口、生命周期、排队完成 第 7 篇 · bdev 模块 nvme / aio / raid / lvol 边界 上一篇:DMA 与 mempool · 下一篇:bdev 模块边界
版本锚定:SPDK v26.01(LTS);Block Device User Guide;公开头
include/spdk/bdev.h、实现树lib/bdev/@ tag v26.01。本篇无压测数字、无伪造 RPC 输出。
本篇不展开:各模块 RPC 菜谱与叠层代价(第 7 篇)、把 bdev 挂到 NVMe-oF namespace(第 8 篇)、iostat / trace 读法(第 13 篇)。
一、问题:用户态里「块设备」到底是谁
内核路径上,文件系统或 O_DIRECT 应用面对的是
struct block_device / bio /
request_queue 这一整套调度与合并语义。SPDK
进程独占盘之后,应用(或
nvmf_tgt、vhost)不再经过内核块层,但仍需要:
- 同一套提交 API 覆盖本机 NVMe、远端 NVMe-oF、ramdisk、内核 AIO 文件等后端;
- 可叠加的虚拟块设备(logical volume、RAID、分区),而不把策略写进每个驱动;
- 多队列、尽量无锁的提交路径,与 reactor / poller 的线程模型对齐。
官方 Block Device User Guide 把 bdev 库的职责写成六条:可插拔模块 API;多种驱动模块;应用侧枚举 / 认领与读写 unmap;叠层设施(含 lvol、GPT);JSON-RPC 配置;以及 request queueing、timeout、reset,外加 multiple, lockless queues。后两条是排障时最容易被忽略的:队列不是无限的,超时与 reset 是显式状态机,不是「内核会帮你排队到天荒地老」。
flowchart TB
app["App / nvmf / vhost"] --> api["bdev application API"]
api --> q["per-channel lockless queues"]
q --> core["lib/bdev core"]
core --> mod["bdev / vbdev modules"]
mod --> nvme["nvme"]
mod --> aio["aio / uring"]
mod --> mem["malloc / null"]
mod --> virt["raid / lvol / gpt"]
直觉:模块负责「接到哪种介质」;bdev 核心负责「队列、完成回调、超时与 reset」。 上层只认 bdev 名与通道(channel),不认 PCIe BDF 或 RDMA 地址——那些落在模块 attach 阶段。
二、统一了什么:块语义与通道模型
2.1 应用 API 的边界
对应用而言,公开入口集中在
include/spdk/bdev.h(tag
v26.01):按名打开 bdev、取 I/O 通道、提交
read / write / unmap / flush / reset
等,并注册完成回调。枚举与认领(claim)保证同一物理后端不会被互斥消费者重复打开到冲突状态——具体冲突规则由模块实现,核心只提供认领框架。
与内核对照时,有用的对应关系是概念级的,不是 API 一一映射:
| 内核块层(概念) | SPDK bdev(概念) | 差异要点 |
|---|---|---|
gendisk / bdev 名 |
bdev 名 / UUID / alias | 进程内命名空间;热拔插靠 RPC 与模块回调 |
bio / request |
spdk_bdev_io |
无 elevator 合并策略;合并若存在由模块自行决定 |
blk-mq 硬件队列 |
bdev I/O channel + 多无锁队列 | 通道绑定线程;跨线程靠 message,不靠共享队列锁 |
request_fn /
mq_ops.queue_rq |
模块 fn_table 提交入口 |
轮询完成,而非默认中断软中断路径 |
blkdev_issue_flush 等 |
bdev flush / unmap 等 | 能力位由模块声明;不支持则提交失败或降级 |
统一的是:块大小、容量、读写 unmap flush、队列深度与错误码对上层可见的形状。 不统一的是:电梯算法、I/O 优先级类、cgroup blkio、内核 bio 合并与 plugging——这些在用户态轮询栈里要么不存在,要么被刻意砍掉以换取可预测延迟。
2.2 通道与无锁队列
Block Device User Guide 明确写了 multiple, lockless queues for sending I/O。实践含义与 第 2 篇、第 4 篇 一致:
- 每个需要发 I/O 的线程拿自己的 I/O channel;
- 同一 channel 上提交与完成处理单线程进行,避免在热路径加锁;
- 跨线程操作走 SPDK message / event,而不是在共享
request_queue上抢锁。
因此「队列满」首先是 该 channel 相对该 bdev 的队列资源耗尽,不是全局唯一大锁队列堵死。排障时要问:是哪个 reactor、哪个 channel、下层模块队列(例如 NVMe qpair)是否也满。
三、一次 I/O 的生命周期
下面按机制顺序描述,不绑定某一模块的私有状态机细节(模块差异见第 7 篇)。
3.1 提交
- 上层(应用、nvmf poll group、vhost)在持有 channel
的线程上调用
spdk_bdev_*提交接口,填入缓冲、LBA、长度、完成回调与cb_arg。 - bdev 核心分配 / 填充
spdk_bdev_io,做能力与对齐检查(块大小、是否支持 unmap 等)。 - 若当前可下发,调用模块
fn_table中的提交函数;若暂时不能(例如内部队列或下游背压),进入 queueing:请求挂在 bdev 侧队列,等待下游空间或超时逻辑。
官方把 Request queueing, timeout, and reset
handling
列为库能力,说明排队不是「无限缓冲」:超时路径与 reset
会清空或失败在途
I/O,语义由核心与模块协作完成。本篇不伪造某次
bdev_get_iostat 数字;观测字段见第 13 篇。
3.2 完成
- 下层模块在 poller 中发现硬件或内核 AIO 完成,调用 bdev 完成路径。
- 核心把结果写回
spdk_bdev_io,调用上层注册的完成回调。 - 回调必须遵守线程约束:通常在提交所用 channel 对应的线程上下文中执行;若上层需要跨核汇总,应自行 message,而不是假设回调可重入任意线程。
sequenceDiagram
participant App as Upper layer
participant Bdev as lib/bdev
participant Mod as bdev module
participant Dev as Device / backend
App->>Bdev: submit on channel
alt queue not full
Bdev->>Mod: fn_table submit
Mod->>Dev: doorbell / aio / memcpy
else queueing
Bdev-->>Bdev: enqueue pending
end
Dev-->>Mod: completion polled
Mod->>Bdev: complete bdev_io
Bdev->>App: callback
3.3 失败轴
至少分清三条轴,避免都叫「I/O 失败」:
| 轴 | 典型含义 | 上层该做什么 |
|---|---|---|
| 提交时返回错误 | 参数非法、无能力、资源立刻不可得 | 纠正参数或降并发;不要空转重试同一非法请求 |
| 排队后超时 / reset | 下游卡住或显式 reset | 查模块与设备健康;reset 期间新 I/O 可能失败或再排队 |
| 完成回调带错误状态 | 介质 / 传输 / 远端拒绝 | 按 NVMe / 模块错误语义重试或 failover(第 10、14 篇) |
与内核对照:Linux 可以把 bio 在 elevator / scheduler 里合并、延迟、再下发;bdev 默认假设 调用方已经按延迟敏感路径组织好并发,核心提供排队与 timeout/reset,而不是 CFS 式的公平调度。
3.4 Reset 与超时在契约里的位置
把 reset 理解成「再试一次 ioctl」会误事。在 bdev 契约里,reset 更接近:
- 上层或超时逻辑决定「这条后端不再可信地前进」;
- 核心与模块协作,使在途
spdk_bdev_io以错误完成或被清除,并尝试把模块/设备拉回可提交状态; - 在此窗口内,同一 channel 上的新提交可能失败、再入队,或看到短暂的能力抖动。
因此排障时应把「偶发完成错误」「提交即失败」「reset
风暴」分开计数。v26.01 changelog
还扩展了统计重置模式(例如只清错误计数的
SPDK_BDEV_RESET_STAT_ERROR)以及
bdev_get_iostat 一次取多 bdev 名——说明官方把
错误与流量观测
当成契约的一部分,而不是模块私货。具体字段读法留给第 13
篇;本篇只需记住:没有观测就谈不上调队列深度。
缓冲约束与第 5 篇衔接:提交时传入的 payload 必须满足模块声明的 DMA / 对齐要求。bdev 核心做能力与块对齐检查,但 不会 默默替你把任意用户缓冲拷成可 DMA 内存;该拷的时候通常发生在特定 vbdev(例如官方 crypto 写路径的 scratch),代价由那一层承担。
四、相对 Linux block 层:统一与砍掉
4.1 谱系
经典内核块层叙事(bio → request →
驱动)服务的是「多文件系统、多调度器、多中断源共享一台机器」。用户态轮询栈的分叉点,是把
设备独占 + 每核队列 + 完成轮询
当成一等公民:SPDK 设计陈述与 Block Device User
Guide 把 bdev
明确写成「内核块层在用户态的对应物」,但目标函数从「共享公平」换成「单租户数据面延迟可预测」。站内
storage/79
仍走内核提交;本系列走的是另一格。
4.2 争论:还要不要「块层」
- A 侧(保留统一块层):nvmf、vhost、raid、lvol、crypto 都需要同一套提交与叠层接口;没有 bdev,每个前端都要直连 NVMe 驱动,组合爆炸。官方文档把 stacking 与模块 API 写成一等功能,支持这一侧。
- B 侧(尽量薄):每多一层
vbdev,就多一次回调与可能的缓冲约束;极致延迟路径会主张「前端直接打
lib/nvme」。工程上,SPDK 仍默认 前端走 bdev,极致旁路是特例而非文档主路径。
本系列立场:把 bdev 当 契约层——统一语义与排队;叠层是否划算由第 7 篇按边界判断,而不是在本篇用未测数字宣称「一层固定加多少微秒」。
4.3 砍掉后的运维后果
砍掉内核调度与 cgroup 之后:
- CPU 公平与「同机其他进程的块 I/O」不再由 blkcg 协调——设备已被 VFIO 绑走(第 3 篇);
- 延迟异常要在 reactor 占用、channel
队列、模块后端 三维上看,而不是只看
iostat的 await; - 快照、精简、RAID 重建等「块服务」若需要,必须显式叠 vbdev 或外置控制面,内核 MD/DM 不会自动出现在路径上。
4.4 一张对照表:别再问「和内核哪个快」
| 问题 | 内核块层答案形态 | bdev 答案形态 |
|---|---|---|
| 谁决定合并 | elevator / 调度策略 | 通常不合并,或模块自定 |
| 谁保证多租公平 | cgroup / 权重 / 限速 | 进程独占与人工核隔离 |
| 完成如何送达 | 中断 → softirq → 完成 | poller 主动取完成 |
| 队列满了怎样 | 阻塞、拥堵控制、再排队 | 提交失败或 bdev 内 queueing + timeout |
| 热扩展块服务 | DM/MD/设备映射器生态 | vbdev 模块 + JSON-RPC |
「哪个快」只有在固定 workload、固定核与固定后端上才有意义;本系列禁止用未跑过的数字填表。机制上可以确定的是:两边优化的目标函数不同——一个优化共享机上的可管理性,一个优化专用数据面的可预测轮询路径。
若应用已经在内核用 io_uring 把提交开销压下去,却仍觉得「块层太重」,先区分瓶颈在 调度/合并 还是在 驱动/中断。前者未必需要 SPDK;后者才更接近本系列的迁移动机(第 15–16 篇收束)。
五、开放问题
- ZNS / KV 等命令集如何在通用 bdev 语义上暴露? 通用 read/write/unmap 无法表达 zone append 的全部约束;模块能力位与专用 API 的边界仍在演进。入口:NVMe ZNS 相关规范与 SPDK zoned 模块文档;站内协议背景见 storage/03。
- 排队深度与超时默认值如何随后端类型自适应? 本机 NVMe 与跨机 TCP NVMe-oF 的合理 timeout 不是同一数量级;目前更多依赖部署经验与 RPC 调参,而非单一自动策略。
- 与 io_uring 内核路径的「混合完成模型」:同机部分盘走 uring bdev、部分盘走用户态 NVMe 时,完成轮询与中断等待如何共置在同一 reactor 上仍缺统一最佳实践(第 15 篇对照)。
六、参考资料
规范 / 官方文档(A)
- SPDK v26.01,Block Device User Guide(bdev 职责、queueing / timeout / reset、lockless queues、模块与 stacking)。
- SPDK v26.01,Changelog(bdev
API:
spdk_bdev_write_uncorrectable_blocks、bdev_get_iostat的names字段等;本篇不依赖未测性能)。
源码(A)
spdk/spdktag v26.01:include/spdk/bdev.h、lib/bdev/;模块树module/bdev/。
站内
实验台账
- 本篇无命令输出、无 IOPS / 延迟数字。
七、小结
- bdev 是用户态块栈契约:统一提交 API、通道、排队 / 超时 / reset;模块负责后端差异。
- I/O 生命周期是「channel 上提交 →(可排队)→ 模块下发 → poll 完成 → 回调」;跨线程靠 message。
- 相对 Linux block 层:统一了块语义与多队列形状,砍掉了共享机公平调度与大量内核合并 / cgroup 语义。
- 选哪个模块、能否叠层——见 第 7 篇。
→ 系列目录 · 上一篇:DMA 与 mempool · 下一篇:bdev 模块边界
读完这篇,下一步读什么
优先读同系列或同问题的下一篇,把单篇消费变成主题集群。
【SPDK 用户态存储】用户态存储全景:从 Reactor 到 NVMe-oF 的坐标系
定位 SPDK 相对内核块层、O_DIRECT+io_uring 与 NVMe 协议百科的生态位;钉住 I/O 路径、线程模型、设备独占、块抽象、远端传输五条坐标系,并给出 16 篇阅读路线。
【SPDK 用户态存储】bdev 模块边界:nvme / aio / malloc 与何时不该叠层
按后端与虚拟层两类划清 SPDK bdev 模块边界:nvme、aio、malloc、null、raid、lvol 各自保证什么;说明叠层何时伤害延迟与排障,并拒绝做成模块百科。
【SPDK 用户态存储】NVMe-oF Target:subsystem、listener 与 namespace→bdev
拆解 SPDK NVMe-oF target:lib/nvmf 中 tgt/subsystem/listener/namespace 与 bdev 的映射;说明 poll group 如何驱动 I/O,以及 app/nvmf_tgt 相对库的角色。
【SPDK 用户态存储】vhost / virtio 边界:对 VMM 提供块后端的位置
把 SPDK vhost-user 钉成对 QEMU/VMM 的块后端位置:共享内存与跳过提交通知、vhost-scsi/blk 差异、cpumask 与 hot-attach 边界;不写完整 virtio 规范教程。