上一篇给出了 I/O 路径与线程消息两条坐标系。本篇只钉一条轴:谁在跑事件循环、跨核如何协作、poller 与 event 各自干什么——以及在 reactor 上阻塞意味着什么。
常见误区有两个:把 SPDK
想成「像内核一样靠中断唤醒业务线程」;或把
spdk_app_start() 想成「随便
sleep/mutex
也没关系,框架会调度别人」。两者都会把排障方向带偏——前者去找
IRQ 亲和却忘了热路径默认在轮询;后者在 reactor
回调里堵系统调用,同核所有 I/O 一起饿。
本文说明:reactor / event / poller 的官方定义;message
passing 相对锁的谱系;spdk_thread 与 event
framework 的层次;以及阻塞的软警告。
本文是「SPDK / 用户态存储栈」系列第 2 篇(共 16 篇)。→ 系列目录
篇目 核心内容 第 1 篇 · 用户态存储全景 生态位与五条坐标系 第 2 篇 · Reactor event / poller / 消息 / 阻塞警告 第 3 篇 · 环境与绑盘 hugepage、VFIO、独占 第 4 篇 · NVMe 驱动 qpair 与完成轮询
版本锚定:SPDK v26.01 Event Framework(spdk.io/doc/event.html)与 Message Passing and Concurrency;公共接口在
event.h/ thread 抽象;源码树 tag v26.01。不粘贴伪造的spdk_top/ 线程 dump。
一、官方怎么钉死三件套
Event Framework
开篇把目标写成:为异步、轮询模式、shared-nothing
服务端应用提供框架。框架是可选的——多数组件可不依赖
event 库单独集成;但示例应用与 spdk_tgt
一类程序默认走这条路。
三个概念:
| 概念 | 官方含义 | 排障后果 |
|---|---|---|
| Reactor | 每 CPU 核一条事件循环线程;从无锁队列取 event 并按 FIFO 执行 | 某核延迟尖刺看该 reactor,不要先平均所有核 |
| Event | 打包好的函数指针 +
参数,发往指定核;spdk_event_allocate()
创建,spdk_event_call() 投递 |
跨核协作的一等公民;不是「随便开线程」 |
| Poller | 在注册所在线程上反复执行的函数;spdk_poller_register();可每圈跑,也可按定时器降频 |
用来取代「靠中断推业务」的硬件轮询;堵在 poller 里 = 同线程全停 |
框架再包一层
app:spdk_app_start()
阻塞当前线程直到 spdk_app_stop()
或启动失败;可选 spdk_app_opts.shutdown_cb
在框架关停前跑自定义清理,且应在返回前调用
spdk_app_stop() 以继续关停流程。
flowchart LR
subgraph core0 ["Reactor core 0"]
EQ0["Event queue"]
P0["Pollers"]
L0["Event loop"]
end
subgraph core1 ["Reactor core 1"]
EQ1["Event queue"]
P1["Pollers"]
L1["Event loop"]
end
MSG["spdk_event_call"] --> EQ0
MSG --> EQ1
L0 --> EQ0
L0 --> P0
L1 --> EQ1
L1 --> P1
二、为何是 message passing,而不是热路径加锁
Message Passing and Concurrency 把动机写得很直白:希望 IOPS、算力、网卡吞吐随硬件近似线性扩展;实践上要尽量避免软件锁甚至原子操作争用。
传统模型:共享堆数据 + 锁 + 多线程抢。核数上来后,时间越来越多花在抢锁上。SPDK 的默认叙事是:
- 把可变状态指派给单个线程拥有。
- 其他线程要碰这份状态时,发一条消息,让拥有者代为执行。
- 消息 = 函数指针 + 上下文;经无锁环传递。
- 极端热路径可读数据可每线程一份副本,突变时广播「请更新你的本地副本」——用内存换掉频繁跨核读。
文档明确指出这不是新发明:Erlang、Go 把消息并发做成语言一等机制;SPDK 在 C 里用手写回调与状态机承担同样角色,并承认 C 缺少 future/promise 时异步难写——建议简单回调链自下而上排列,复杂逻辑显式状态机。
与 HAProxy 线程模型 / Envoy Main/Worker 同属「数据面少共享」家族,但 SPDK 更激进地把块 I/O 完成也绑在轮询循环上,而不是 epoll 等到可读再读。
谱系收束句:锁模型优化的是「少抢一会儿」;消息模型优化的是「根本不要在热路径上共享可变状态」。 前提是消息延迟可接受,且拥有者线程不被堵死。
三、spdk_thread
与 event framework 的层次
并发文档区分了两层,写作与排障时不要混为一谈:
3.1
底层:spdk_thread(libspdk_thread)
spdk_thread是轻量、无独立栈的执行抽象;下层框架对它调用spdk_thread_poll()跑一个时间片。- 下层可以把某个
spdk_thread在系统线程间迁移,但同一时刻只能有一个系统线程在 poll 它。 - 其上有
spdk_poller、spdk_thread_send_msg(),以及反复出现的模式:spdk_io_device(全局对象)+spdk_io_channel(每线程上下文)——名字源自块设备多队列,后来泛化到「任意按地址区分的对象 + 每线程私有上下文」。 - thread 抽象自己不拉系统线程;它依赖更下层的事件循环宿主。
3.2 上层:event
framework(lib/event)
- 为示例与多数 SPDK 应用提供:按核拉 reactor、调度轻量线程、信号处理与关停、基础命令行解析。
- 成熟应用可以只链底层库、自带 Seastar/libuv 一类循环;官方写明不想为所有示例绑死某一异步框架,于是自带 event 作为默认宿主。
对读者的实用含义:读 spdk_tgt 源码时看到的
reactor,是 event 框架对 thread
抽象的一种部署;读纯 lib/nvme
示例时,完成轮询可能直接写在你自己的循环里——库仍然
passive。
四、Event 与 Poller:何时用哪一个
| Event | Poller | |
|---|---|---|
| 触发 | 被投递一次,到目标核执行 | 在注册线程上反复执行直到注销 |
| 典型用途 | 跨核请求「请你改你的状态 / 跑这段逻辑」 | 轮询硬件 CQ、网卡、定时轻量维护 |
| API(文档级) | spdk_event_allocate /
spdk_event_call |
spdk_poller_register(及注销对) |
| 约束 | 回调不得阻塞,且应尽快返回 | 同上;低延迟不敏感时可挂定时器降频 |
官方对 event 函数的硬要求:never block,且最好非常短——因为它们从目标核的事件循环里直接调用。Poller 夹在 event 处理之间跑;默认每圈都跑,也可以按定时器调度。
把「偶发管理操作」做成 event、把「必须持续看硬件」做成 poller,是分工而不是口味。把阻塞 DNS、同步磁盘日志、长时间持锁塞进任一者,语义上都破坏框架前提。
五、在 reactor 上阻塞:软警告为何够硬
事件驱动系统的共同软警告(与 HAProxy poller、libevent 回调同构):
在 reactor / poller 回调里阻塞 = 该核上所有 event 与 poller 停止推进。
后果在存储栈上特别具体:
- CQ 不再被轮询 → 已提交 I/O 的完成回调推迟 → 应用层超时像「盘慢」,实则是 CPU 核被堵。
- 跨核消息堆积 → 别的核在等拥有者处理消息,尾延迟扩散。
- 看监测易误判 → 该核
%user可能仍高(忙等)或骤降(堵在 syscall);需要结合 poller 是否仍在推进,而不是只看平均负载。
官方提供 spdk_top(Getting
Started)观察线程、poller
与绑核统计——本环境若无实测,不粘贴伪造输出;语义上它回答的是「谁在哪核上空转/忙碌」,不是
magically 修复阻塞回调。
工程间隙:论文式 lock-free / message-passing 叙述常省略「日志、RPC 线程、glibc 解析器」对尾延迟的贡献。生产上要把同步重活挪到非 reactor线程,再用消息把结果送回拥有者。
SPDK 仍保留 spdk_spinlock
一类锁,但文档强调应优先消息;POSIX mutex/spinlock
与轻量线程模型不完全同构,持锁规则更严——细节以
spdk_spinlock 文档为准,本篇不展开 API 表。
六、与内核完成模型的对照句
| 内核 NVMe / io_uring | SPDK reactor 默认路径 | |
|---|---|---|
| 完成如何到达 | 中断 / softirq / 完成队列通知,再回到等待的提交方 | 用户态主动 process_completions / poller
轮询 |
| CPU 空闲时 | 可睡眠 | 专用核常持续轮询(CPU 税) |
| 扩展单位 | 常与 blk-mq 队列、io_uring 实例相关 | 核 + 每核私有队列/channel |
| 阻塞伤害面 | 阻塞的是该内核线程/任务 | 阻塞的是整核 reactor 上所有租户 |
这不是「谁更快」的排名——无本机对照实验就不写数字——而是归因语言:在 SPDK 里先问「哪颗核的循环停了」;在 io_uring 里先问「提交/完成环与内核驱动哪一段」。
开放问题(本系列后文回收):
- 低负载时能否用 interrupt mode / 降频 poller 压 CPU 税,而不把尾延迟打回内核路径量级?(传输侧见第 9 篇。)
- 与 io_uring 同机共置时,CPU 隔离与 IRQ 亲和如何划分,才不会两败俱伤?(第 15–16 篇。)
- 轻量线程可迁移系统线程的自由度,与「绑核 + 本地缓存」优化之间如何取舍?
七、把「拥有者线程」落到存储对象上
并发文档里的 spdk_io_device /
spdk_io_channel 不只是命名游戏。块设备、NVMe
controller 封装、后续 bdev 模块,反复出现同一模式:
- 全局对象保存慢变状态(能力、命名空间表、模块注册信息)。
- 每线程 channel 保存快路径需要的队列、序列号、本地统计。
- 快路径只碰 channel;要改全局状态就发消息给拥有者(或遍历「对该 device 已有 channel 的线程」逐个通知)。
这解释了为何「在错误线程上直接改全局配置」比「多加一把锁」更危险:锁至少还能让数据竞争变成可复现的卡顿;消息模型下越界写是静默破坏不变量。集成自定义模块时,第一设计题不是「函数怎么写」,而是状态的线程所有权图。
与 C 语言限制叠加:异步回调把控制流切碎,官方建议复杂逻辑写成显式状态机,并保持相关回调函数在源文件中可读地聚在一起。存储路径上的「提交 → 设备完成 → bdev 回调 → 可能再发跨核消息」天然是状态机;试图在一个反应器回调里用同步风格写完,往往下一步就是阻塞或超长临界。
工程间隙再钉一句:message passing 的微基准常很好看,是因为消息小、命中本地缓存;一旦消息携带大缓冲指针且跨 NUMA,或拥有者线程被偶发日志堵死,理论优势会蒸发——这不是否定模型,而是要求观测「拥有者是否跟得上」。
八、参考资料
规范 / 官方文档(A)
- SPDK, Event Framework @
v26.01:reactor / event / poller /
spdk_app_start/ shutdown_cb。 - SPDK, Message Passing and Concurrency @
v26.01:
spdk_thread、spdk_io_channel、消息动机与 C 异步建议。 - SPDK, Getting Started @
v26.01:
spdk_top定位。
源码(A)
spdk/spdktag v26.01:lib/event/、include/spdk/event.h、lib/thread/(目录级入口)。
站内对照
实验台账
- 本篇无命令输出、无自测延迟。
九、小结
- Reactor 是每核事件循环;event 做跨核投递;poller 做同线程反复轮询。
- Message passing 是热路径扩展叙事的核心,不是口号;状态所有权先于锁。
spdk_thread可脱离 event 框架嵌入其他循环;spdk_app_start()只是常见宿主。- 在 reactor 上阻塞会让同核完成轮询与消息处理一起停——排障时这是第一软警告。
- 与 io_uring 的差别首先是完成模型与 CPU 税,而不是「谁支持 NVMe」。
io_device/io_channel把「全局慢状态 + 每线程快路径」变成可复用模式;越界写比多一把锁更难查。
→ 上一篇:用户态存储全景 · 系列目录 · 下一篇:环境与绑盘
读完这篇,下一步读什么
优先读同系列或同问题的下一篇,把单篇消费变成主题集群。
【SPDK 用户态存储】用户态存储全景:从 Reactor 到 NVMe-oF 的坐标系
定位 SPDK 相对内核块层、O_DIRECT+io_uring 与 NVMe 协议百科的生态位;钉住 I/O 路径、线程模型、设备独占、块抽象、远端传输五条坐标系,并给出 16 篇阅读路线。
【SPDK 用户态存储】排障坐标系:绑盘、空转、队列满、传输超时与多路径
把 SPDK 常见症状映射到绑盘/环境、reactor CPU、bdev 队列、传输超时、多路径五轴;给出可核对清单与观测入口,不虚构延迟数字与 RPC dump。
【SPDK 用户态存储】环境与绑盘:Hugepage、CPU 亲和与 VFIO/UIO
说明 SPDK 运行前 env:大页分配、CPU 绑核语义、setup.sh 将 NVMe 从内核驱动解绑到 VFIO/UIO;钉住设备独占代价与 reset 回退路径。
【SPDK 用户态存储】用户态 NVMe 驱动:Controller、Qpair 与 Poll Group
拆解 SPDK 被动式 NVMe 库:probe/attach、admin 与 I/O qpair、完成轮询与 poll group;说明单线程独占 qpair、零拷贝提交路径及相对内核驱动的边界。