2、NVMe多队列基础:队列对(QP)概念、提交队列(SQ)和完成队列(CQ)

好,咱们正式进入多队列的核心区。NVMe 能甩开 AHCI 一个时代,说白了就靠两样东西:队列对(QP) 和 中断聚合。这一章先把 QP 的概念吃透,SQ(提交队列)和 CQ(完成队列)是它的左膀右臂,搞不清这两个,后面写驱动全是坑。

2.1 队列对(QP)——NVMe 的最小执行单元

我刚开始接触 NVMe 时,第一反应是:这不就是个环形缓冲区吗?后来踩了坑才明白——QP 是“命令通道 + 结果通道”的绑定体。一个 QP 由一对队列组成:一个 SQ 往里塞命令,一个 CQ 往外吐完成状态。你想想看,如果只有一个共享的 CQ 收所有 SQ 的结果,那竞争锁得打到天上去。

🎯 核心要点:每个 QP 拥有独立的 SQ 和 CQ,互不干扰。这是 NVMe 实现无锁并发的基石。

我记得第一次给一个 8 核 ARM 平台做存储优化时,驱动里只开了 2 个 QP,结果 CPU 都在抢那两条队列的锁。后来改成每核一个 QP,吞吐直接翻倍。嗯,多队列的价值就在这里。

2.2 提交队列(SQ)——命令的下水道

SQ 就干一件事:把命令按顺序丢进去,让控制器自己提走。每个 SQ 条目(Submission Queue Entry)固定 64 字节,结构是这样的:

struct nvme_sq_entry {
    uint8_t  opcode;       // 操作码,读/写/刷新等
    uint8_t  flags;        // 比如 FUSE 标志
    uint16_t command_id;   // 驱动自己分配,用来匹配完成
    uint32_t nsid;         // 命名空间 ID
    uint64_t metadata;     // 元数据指针或 prp1
    uint64_t prp1;         // 物理区域页 1
    uint64_t prp2;         // 物理区域页 2(或 SGL)
    // ... 还有 7 个 reserved 字,共 64 字节
};
  • 生产者是谁? 驱动(Host)写 SQ Tail Doorbell,通知控制器有新命令。
  • 消费者是谁? 控制器从 SQ Head 开始取,处理完后更新 Head 指针。
  • 典型坑:我曾经因为忘记在命令提交后写 Doorbell 寄存器,控制器一直以为队列是空的,性能直接为零。调试了一整天才发现。

避坑指南:SQ 的深度建议设置为 2 的幂次,比如 16/32/64。NVMe 规范要求队列深度至少为 2,但我个人习惯起步 64,不然高端 SSD 的延迟优势根本发挥不出来。

2.3 完成队列(CQ)——命令的回执窗口

CQ 负责接收控制器的完成通知。每条完成条目(Completion Queue Entry)也是固定 16 字节,非常轻量:

struct nvme_cq_entry {
    uint32_t command_specific; // 命令专用,比如读返回的数据长度
    uint32_t reserved;
    uint16_t sq_head_ptr;     // 提交队列的 Head 指针(控制器更新)
    uint16_t sq_id;           // 这条完成属于哪个 SQ
    uint16_t command_id;      // 匹配提交时的 command_id
    uint16_t status_field;    // 状态码 + 相位标记(P)
};
  • 消费者是谁? 驱动轮流检查 CQ Head 处的相位标记(Phase Tag),判断是否为新完成。
  • 生产者是谁? 控制器写入 CQ,然后触发 MSI-X 中断(或中断聚合)。
  • 关键性能点:中断的代价很高,所以 NVMe 设计了 中断聚合——控制器攒一批完成再打断 CPU。我曾经在数据库场景下调整聚合超时,从 10μs 调到 100μs,CPU 利用率降了 30%,延迟只涨了 5μs。

⚠️ 相位标记(Phase Tag):这是 NVMe 避免锁竞争的精妙设计。驱动每次取完一批 CQ 条目后,翻转本地保存的相位预期值。控制器每写完一轮 CQ 缓冲区,也翻转相位位。这样驱动零锁就能判断完成是否有效。别问我怎么知道的——我在实现时忘了翻转相位,结果收到一堆“过期”完成,数据全乱了。

2.4 SQ 与 CQ 的配对关系

一个 QP 可以理解成“1 个 SQ + 1 个 CQ”,但这里有个容易混淆的点:NVMe 允许 多个 SQ 共享同一个 CQ。比如你开了 4 个 SQ,但只配 2 个 CQ,每个 CQ 收 2 个 SQ 的完成。这样做的好处是减少中断数量——但不是所有 SSD 都推荐这么搞。

配对模式 SQ 数量 CQ 数量 典型场景
1:1 每核 1 个 每 SQ 配 1 个 高并发、低延迟(数据库)
N:1 多个 1 个 减少中断、CPU 核数有限
N:M 按任务分组 按优先级分组 混合工作负载(建议谨慎使用)

我个人更倾向 1:1 的配对,尤其是做高性能存储时。原因很简单——没有锁,没有竞争,没有中断风暴。除非你 CPU 只有 2 核,那才考虑 N:1。

2.5 多队列工作机制全景图

下面这张图把 SQ、CQ、QP 的关系捋一遍。你盯着看 30 秒,后面代码就懂了。

NVMe 多队列:SQ、CQ 与 QP 关系 CPU Core 0 CPU Core 1 CPU Core 2 CPU Core 3 SQ 0 (提交队列) SQ 1 (提交队列) SQ 2 (提交队列) SQ 3 (提交队列) CQ 0 (完成队列) CQ 1 (完成队列) NVMe 控制器 (取命令→执行→写完成) 每个 CPU 核心拥有独立的 SQ,SQ 将命令提交给控制器,控制器处理完后将完成写入对应的 CQ,并通过中断通知 CPU QP = (SQ, CQ) 对;图中展示了 N:1 的配对(两个 SQ 共享一个 CQ)

从上图可以直观看到:每个 CPU 都有自己的 SQ,写命令时互不干扰;完成可以集中到少数 CQ 减少中断,也可以每个 SQ 配一个 CQ 降低延迟。这就是多队列灵活性的体现。

2.6 创建队列时的注意事项

最后分享几个实战中容易翻车的地方:

  1. 队列深度必须协商:创建 SQ 前先读 Identify Controller 数据结构的 SQES(最大队列深度上限)。不是所有 SSD 都支持 65536 深度。
  2. 物理内存对齐:SQ 和 CQ 的物理地址必须 4K 对齐(其实协议要求页大小对齐,但 NVMe 默认页 4K)。我见过有人传错地址低 12 位,结果控制器死活不认。
  3. 中断号绑定:每个 CQ 可以关联一个 MSI-X 向量。最好做 中断亲缘性绑定(IRQ affinity),把 CQ 的中断绑定到对应 CPU 核上,不然中断乱飞,缓存全失效。
  4. 队列状态检查:关闭队列前必须确保队列已空(通过 CSTS 寄存器轮询)。我曾经在队列还在处理命令时强行删除,控制器直接挂死,只能重启。

⚠️ 最大队列数量限制:NVMe 规范规定最多支持 65535 个队列对,但实际产品往往有更低的限制(比如 128 或 256)。创建队列前务必读取控制器返回的 NN(命名空间数量)和 MQS(最大队列数)。不要想当然地开满 64K。

好了,QP 的概念、SQ 和 CQ 的配对关系、创建时容易掉进去的坑,都过了一遍。搞懂这些,你再看多队列驱动代码就不会懵了。下一块内容我们直接上手——写出第一个多队列驱动的初始化流程。