4、RDMA核心概念:内存注册、队列对(QP)、完成队列(CQ)、共享接收队列(SRQ)

好,咱们今天来啃 RDMA 里最核心的几个概念。说实话,很多新手一开始就被这些术语搞懵了。我当年也一样,对着文档看了三天,感觉每个字都认识,连起来就不知道在说什么。

其实没那么复杂。你想想看,RDMA 要解决什么问题?就是让网卡直接读写你的应用内存,绕过 CPU。那问题来了——网卡怎么知道哪块内存能碰、哪块不能碰?数据到了之后往哪儿放?谁通知你数据到了?

嗯,这几个问题,正好对应我们今天要讲的四个概念:内存注册队列对(QP)完成队列(CQ)共享接收队列(SRQ)

4.1 内存注册(Memory Registration)

先说内存注册。说白了,就是告诉网卡:「这块内存是我的,你可以直接读写它。」

为什么需要这个步骤?因为 RDMA 网卡要直接访问主机内存,但操作系统对内存是有保护的。你不能让网卡随便乱写,万一写到别的进程的内存里,那就出大事了。

所以,RDMA 要求你在使用某块内存之前,先把它「注册」到网卡上。注册的时候,网卡会记录这块内存的物理地址、长度、访问权限(读还是写)。同时,网卡和 CPU 会把这块内存的页表固定住,不让它被换出到磁盘——这就是所谓的内存钉扎(Memory Pinning)

关键点:注册后的内存,网卡可以直接通过 DMA 读写,不需要 CPU 参与。但代价是这部分内存不能被换页,会占用物理内存。

我在项目中遇到过一个问题:有个同事注册了 2GB 内存,结果系统开始频繁 OOM。为什么?因为注册的内存被钉住了,系统拿不回来。所以,内存注册不是越多越好,要按需分配。

注册完成后,你会得到一个 L_Key(本地键) 和一个 R_Key(远程键)。L_Key 用于本地操作,R_Key 用于远程操作。这两个 Key 就像一把锁的钥匙——网卡操作内存时,必须出示正确的 Key,否则操作会被拒绝。

小技巧:我个人习惯把 R_Key 和内存地址一起封装在应用层协议里发送给对端。这样对端拿到后,就可以直接发起远程读写。但注意,R_Key 不能泄露给不信任的节点,否则别人可以随意读写你的内存。

4.2 队列对(QP - Queue Pair)

接下来是队列对。这个名字很形象——它由两个队列组成:发送队列(SQ)接收队列(RQ)。一个用于发数据,一个用于收数据。

你可以把 QP 想象成一条「管道」。应用程序把要发送的数据描述成 工作请求(WR - Work Request),扔进发送队列。网卡从队列里取出 WR,执行实际的发送操作。接收端也一样,网卡收到数据后,从接收队列里取出一个 WR,把数据填进去。

QP 有三种传输类型,我简单列个表:

传输类型 特点 适用场景
可靠连接(RC) 一对一、可靠、保序 大多数存储场景,如 NVMe over Fabrics
不可靠连接(UC) 一对一、不可靠、不保序 对丢包不敏感的场景
不可靠数据报(UD) 多对多、不可靠、最大 MTU 限制 多播、控制面通信

我建议你在做存储网络时,优先选择 RC 模式。为什么?因为 NVMe over Fabrics 要求可靠传输,RC 正好满足。而且 RC 的保序特性,让你不用在应用层做额外的排序处理。

注意:每个 QP 都需要消耗一定的硬件资源。一台机器上能创建的 QP 数量是有限的。我曾经在一个项目中,因为创建了太多 QP 导致网卡资源耗尽,所有新连接都失败。后来改用 SRQ 才解决这个问题——嗯,这就是我们接下来要讲的内容。

4.3 完成队列(CQ - Completion Queue)

数据发出去之后,你怎么知道它发送成功了?接收端怎么知道数据已经收到了?这就是完成队列要做的事。

CQ 是一个存放 完成事件(CQE - Completion Queue Entry) 的队列。每当一个 WR 执行完毕(无论是成功还是失败),网卡就会往 CQ 里扔一个 CQE。应用程序通过轮询或事件通知的方式,从 CQ 里取出 CQE,就知道操作的结果了。

这里有个设计上的选择:一个 CQ 可以关联多个 QP。也就是说,你可以让所有 QP 的完成事件都放到同一个 CQ 里。这样做的好处是,你只需要一个线程去轮询 CQ,就能处理所有 QP 的完成事件。

我个人习惯的做法是:

  • 每个 CPU 核心创建一个 CQ
  • 把 QP 均匀分配到这些 CQ 上
  • 每个核心的线程只轮询自己对应的 CQ

这样做的好处是避免了锁竞争,性能会好很多。我在做 NVMe over Fabrics 的 target 端时,就是用这个方案,单机跑到了 100Gbps 的线速。

避坑指南:我曾经犯过一个错误——CQ 的深度设置得太小。结果在高负载下,CQ 满了,新的完成事件写不进去,导致网卡报错。后来我把 CQ 深度设置为 QP 数量的两倍,问题就解决了。

4.4 共享接收队列(SRQ - Shared Receive Queue)

最后说 SRQ。这个概念的引入,是为了解决一个实际问题:接收缓冲区管理

你想想看,如果你有 1000 个 QP,每个 QP 的接收队列里都要预先放好 WR(也就是接收缓冲区)。如果每个 QP 放 10 个,那就是 10000 个缓冲区。但问题是,并不是所有 QP 都在同时接收数据。大部分缓冲区其实是闲置的,白白浪费了内存。

SRQ 的做法是:把接收缓冲区集中管理。多个 QP 共享同一个接收队列。当数据到达时,网卡从 SRQ 里取一个 WR 来用。这样,你只需要准备少量的缓冲区,就能应对所有 QP 的接收需求。

我举个例子:

// 传统方式:每个 QP 独立接收队列
QP1: [WR][WR][WR]...  // 10个WR
QP2: [WR][WR][WR]...  // 10个WR
QP3: [WR][WR][WR]...  // 10个WR
// 总共 30 个 WR

// SRQ 方式:共享接收队列
SRQ: [WR][WR][WR]...  // 15个WR,所有QP共享
QP1 --> SRQ
QP2 --> SRQ
QP3 --> SRQ
// 总共 15 个 WR,节省了一半内存

我在做 NVMe over Fabrics 的 initiator 端时,用了 SRQ。因为 initiator 要同时连接多个 target,每个 target 对应一个 QP。如果用独立接收队列,内存开销会很大。用了 SRQ 之后,内存占用减少了 60% 以上。

注意:SRQ 虽然省内存,但也有代价。因为所有 QP 共享缓冲区,如果某个 QP 的接收速率特别快,可能会把 SRQ 里的 WR 全部消耗掉,导致其他 QP 没有缓冲区可用。所以,SRQ 的深度要根据最坏情况来设置。

小结

好了,这四个概念讲完了。我帮你捋一下它们之间的关系:

  • 内存注册:给网卡开「通行证」,告诉它哪块内存可以碰
  • QP:数据传输的「管道」,一个发一个收
  • CQ:操作结果的「信箱」,告诉你数据发没发成功
  • SRQ:接收缓冲区的「共享池」,省内存用的

这四个概念,是 RDMA 编程的基石。你理解了它们,后面再看 NVMe over Fabrics 的协议栈,就会觉得顺理成章了。

下一章,我们会把这些概念串起来,讲一讲 RDMA 的实际编程流程。到时候我会带你看一段真实的代码,看看这些概念是怎么落地的。