土法炼钢 · 系统与基础设施

【SPDK 用户态存储】Reactor 与线程模型:Event、Poller 与 Message Passing

文章导航

分类入口
storagelinux
标签入口
#spdk#reactor#poller#event-framework#message-passing#thread#v26.01

目录

上一篇给出了 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 Frameworkspdk.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 里 = 同线程全停

框架再包一层 appspdk_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 的默认叙事是:

  1. 把可变状态指派给单个线程拥有。
  2. 其他线程要碰这份状态时,发一条消息,让拥有者代为执行。
  3. 消息 = 函数指针 + 上下文;经无锁环传递。
  4. 极端热路径可读数据可每线程一份副本,突变时广播「请更新你的本地副本」——用内存换掉频繁跨核读。

文档明确指出这不是新发明:Erlang、Go 把消息并发做成语言一等机制;SPDK 在 C 里用手写回调与状态机承担同样角色,并承认 C 缺少 future/promise 时异步难写——建议简单回调链自下而上排列,复杂逻辑显式状态机。

HAProxy 线程模型 / Envoy Main/Worker 同属「数据面少共享」家族,但 SPDK 更激进地把块 I/O 完成也绑在轮询循环上,而不是 epoll 等到可读再读。

谱系收束句:锁模型优化的是「少抢一会儿」;消息模型优化的是「根本不要在热路径上共享可变状态」。 前提是消息延迟可接受,且拥有者线程不被堵死。


三、spdk_thread 与 event framework 的层次

并发文档区分了两层,写作与排障时不要混为一谈:

3.1 底层:spdk_threadlibspdk_thread

3.2 上层:event framework(lib/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 停止推进。

后果在存储栈上特别具体:

  1. CQ 不再被轮询 → 已提交 I/O 的完成回调推迟 → 应用层超时像「盘慢」,实则是 CPU 核被堵。
  2. 跨核消息堆积 → 别的核在等拥有者处理消息,尾延迟扩散。
  3. 看监测易误判 → 该核 %user 可能仍高(忙等)或骤降(堵在 syscall);需要结合 poller 是否仍在推进,而不是只看平均负载。

官方提供 spdk_topGetting 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 里先问「提交/完成环与内核驱动哪一段」。

开放问题(本系列后文回收):

  1. 低负载时能否用 interrupt mode / 降频 poller 压 CPU 税,而不把尾延迟打回内核路径量级?(传输侧见第 9 篇。)
  2. 与 io_uring 同机共置时,CPU 隔离与 IRQ 亲和如何划分,才不会两败俱伤?(第 15–16 篇。)
  3. 轻量线程可迁移系统线程的自由度,与「绑核 + 本地缓存」优化之间如何取舍?

七、把「拥有者线程」落到存储对象上

并发文档里的 spdk_io_device / spdk_io_channel 不只是命名游戏。块设备、NVMe controller 封装、后续 bdev 模块,反复出现同一模式:

这解释了为何「在错误线程上直接改全局配置」比「多加一把锁」更危险:锁至少还能让数据竞争变成可复现的卡顿;消息模型下越界写是静默破坏不变量。集成自定义模块时,第一设计题不是「函数怎么写」,而是状态的线程所有权图

与 C 语言限制叠加:异步回调把控制流切碎,官方建议复杂逻辑写成显式状态机,并保持相关回调函数在源文件中可读地聚在一起。存储路径上的「提交 → 设备完成 → bdev 回调 → 可能再发跨核消息」天然是状态机;试图在一个反应器回调里用同步风格写完,往往下一步就是阻塞或超长临界。

工程间隙再钉一句:message passing 的微基准常很好看,是因为消息小、命中本地缓存;一旦消息携带大缓冲指针且跨 NUMA,或拥有者线程被偶发日志堵死,理论优势会蒸发——这不是否定模型,而是要求观测「拥有者是否跟得上」。


八、参考资料

规范 / 官方文档(A)

源码(A)

站内对照

实验台账


九、小结

  1. Reactor 是每核事件循环;event 做跨核投递;poller 做同线程反复轮询。
  2. Message passing 是热路径扩展叙事的核心,不是口号;状态所有权先于锁。
  3. spdk_thread 可脱离 event 框架嵌入其他循环;spdk_app_start() 只是常见宿主。
  4. 在 reactor 上阻塞会让同核完成轮询与消息处理一起停——排障时这是第一软警告。
  5. 与 io_uring 的差别首先是完成模型与 CPU 税,而不是「谁支持 NVMe」。
  6. io_device / io_channel 把「全局慢状态 + 每线程快路径」变成可复用模式;越界写比多一把锁更难查。

上一篇:用户态存储全景 · 系列目录 · 下一篇:环境与绑盘

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。


By .