2、RDMA核心概念:内存区域(MR)、完成队列(CQ)、工作队列(WQ)、保护域(PD)

好,咱们正式开始接触 RDMA 编程的核心零件了。

说实话,我第一次接触 RDMA 时,最头疼的就是这几个概念。MR、CQ、WQ、PD,每个缩写背后都藏着一套设计哲学。你光看名字,根本猜不到它们是干嘛的。

但一旦你理解了它们之间的关系,整个 RDMA 的编程模型就清晰了。我习惯把这四个东西比作一个「快递系统」:

  • PD(保护域)——就是你的仓库大门,决定谁能进谁不能进
  • MR(内存区域)——仓库里的货架,告诉快递员哪些地方可以放货
  • WQ(工作队列)——快递员的待办清单,写着「去A取货,送到B」
  • CQ(完成队列)——已签收的回执单,告诉你哪些任务干完了

嗯,这个比喻虽然糙了点,但道理是通的。咱们一个一个拆开讲。

2.1 保护域(PD)—— 隔离与安全的第一道防线

PD 是 RDMA 世界里最容易被忽略的概念。我见过不少新手,一上来就申请 MR、创建 QP,PD 随便传个 NULL 或者共享一个全局 PD。结果呢?不同应用之间的内存互相踩踏,查 bug 查到崩溃。

PD 说白了就是一个资源隔离容器。它把 MR、QP、AH(地址句柄)这些资源绑定在一起。只有属于同一个 PD 的资源,才能互相操作。

核心规则:

  • 一个 MR 只能被同一个 PD 下的 QP 访问
  • 不同 PD 之间的资源完全隔离
  • PD 本身不消耗太多系统资源,但设计上建议每个应用或每个线程使用独立的 PD

我在项目中遇到过这样一个坑:两个不同的服务模块,共用了同一个 PD。其中一个模块注册了 MR 用于发送数据,另一个模块的 QP 居然也能访问这块内存。虽然功能上没出问题,但安全上完全不合格。后来我强制要求每个模块创建自己的 PD,从此再没出过内存越权的事。

我的建议: 别偷懒。每个逻辑单元(进程、线程、甚至连接池)都创建独立的 PD。代码多几行,安全多十分。

2.2 内存区域(MR)—— 让网卡直接读写你的内存

MR 是 RDMA 最核心的机制之一。传统网络通信中,数据要从用户态拷贝到内核态,再拷贝到网卡。RDMA 绕过了内核,网卡直接读写用户态内存。但网卡怎么知道哪块内存是安全的?哪块内存可以访问?

答案就是 MR。

你调用 ibv_reg_mr() 注册一块内存时,实际上做了三件事:

  1. 告诉网卡:「这块内存的物理地址在这里,你可以直接访问」
  2. 给网卡一把「钥匙」—— 一个叫做 lkey/rkey 的整数
  3. 指定访问权限:本地读、本地写、远程读、远程写、原子操作

代码示例很简单,但背后的原理值得细品:

#include <infiniband/verbs.h>

struct ibv_pd *pd;      // 假设已经创建
void *buf = malloc(4096);
struct ibv_mr *mr;

mr = ibv_reg_mr(pd, buf, 4096,
                IBV_ACCESS_LOCAL_WRITE |
                IBV_ACCESS_REMOTE_WRITE |
                IBV_ACCESS_REMOTE_READ);

if (!mr) {
    // 注册失败,常见原因:内存未对齐、权限不足
    perror("ibv_reg_mr");
    exit(1);
}

printf("lkey: 0x%x, rkey: 0x%x\n", mr->lkey, mr->rkey);

这里有个细节:lkey 用于本地操作(比如发送数据时,告诉网卡从哪读),rkey 用于远程操作(对方网卡要写你的内存时,需要出示这个 key)。

我曾经踩过的坑: 注册 MR 时,内存地址必须是页对齐的(通常是 4KB)。如果你用 malloc 分配的内存,地址可能不对齐。注册会失败,或者性能骤降。我后来改用 posix_memalignmmap 来分配,再也没出过问题。

另外,MR 的注册和注销是有开销的。每次注册都要做一次 DMA 映射,涉及页表锁定和硬件表项更新。所以,不要频繁注册/注销 MR。我一般会预先分配一个大内存池,一次性注册,然后自己管理内部的内存分配。

2.3 工作队列(WQ)与工作请求(WR)—— 给网卡下指令

WQ 是 QP(队列对)的一部分。每个 QP 包含两个 WQ:

  • 发送队列(SQ)—— 存放你要发送的数据描述
  • 接收队列(RQ)—— 存放你准备接收数据的缓冲区描述

你想想看,网卡怎么知道你要发什么数据?你要告诉它。这个「告诉」的过程,就是提交一个 工作请求(WR) 到 WQ 中。

提交 WR 的代码长这样:

struct ibv_qp *qp;      // 假设已经创建
struct ibv_sge sg;      // 散列表项,描述数据在哪
struct ibv_send_wr wr;  // 发送工作请求
struct ibv_send_wr *bad_wr;

// 描述数据缓冲区
sg.addr   = (uint64_t)buf;   // 数据起始地址
sg.length = 1024;            // 数据长度
sg.lkey   = mr->lkey;        // 对应的 MR 的 lkey

// 填充工作请求
wr.wr_id      = 1;           // 用户自定义 ID,用于完成时识别
wr.sg_list    = &sg;
wr.num_sge    = 1;
wr.opcode     = IBV_WR_SEND; // 发送操作
wr.send_flags = IBV_SEND_SIGNALED; // 要求发送完成后通知我

// 提交到发送队列
if (ibv_post_send(qp, &wr, &bad_wr)) {
    fprintf(stderr, "ibv_post_send failed\n");
}

这里有个关键点:wr_id 是用户自定义的标识符。当操作完成后,CQ 会返回这个 ID,帮你识别是哪个请求完成了。我习惯用指针或者索引值作为 wr_id,这样在完成处理函数里可以直接定位到对应的数据结构。

我的习惯: 发送操作一般设置 IBV_SEND_SIGNALED 标志,这样每个发送都会产生一个完成事件。接收操作默认就是 signaled 的。但如果你批量发送大量小消息,可以只在最后一个请求上设置 signaled,减少 CQ 的负载。这叫「合并完成通知」,性能优化时很有用。

2.4 完成队列(CQ)—— 任务完成的回执单

你提交了 WR,网卡开始干活。但你怎么知道活干完了?

轮询 CQ。

CQ 是一个 FIFO 队列,网卡每完成一个操作(发送完成、接收完成、读写完成),就往 CQ 里塞一个 完成事件(WC)。你的程序需要主动去轮询 CQ,取出这些 WC。

轮询代码:

struct ibv_cq *cq;          // 假设已经创建
struct ibv_wc wc;           // 完成事件结构体

while (1) {
    int ne = ibv_poll_cq(cq, 1, &wc);
    if (ne == 0) {
        // 没有完成事件,可以忙等或让出 CPU
        continue;
    }
    if (ne < 0) {
        perror("ibv_poll_cq");
        break;
    }

    // 检查完成状态
    if (wc.status != IBV_WC_SUCCESS) {
        fprintf(stderr, "Work completion failed: %s\n",
                ibv_wc_status_str(wc.status));
        // 这里需要处理错误,比如重试或关闭连接
        break;
    }

    // 根据 wr_id 判断是哪个请求完成了
    switch (wc.opcode) {
        case IBV_WC_SEND:
            printf("Send completed, wr_id = %lu\n", wc.wr_id);
            break;
        case IBV_WC_RECV:
            printf("Receive completed, wr_id = %lu, len = %u\n",
                   wc.wr_id, wc.byte_len);
            break;
        // 其他 opcode...
    }
}

这里有个容易忽略的点:ibv_poll_cq非阻塞的。如果没有完成事件,它立刻返回 0。所以你需要自己决定轮询策略:

  • 忙等(spin):延迟最低,但占用 CPU
  • 阻塞等待:用 ibv_get_cq_event 配合事件通道,CPU 友好但延迟稍高
  • 混合模式:先忙等一小段时间,没有事件再阻塞等待

我在项目中做过测试:对于延迟敏感的应用(比如金融交易),忙等模式能降低 30% 的延迟。但对于吞吐型应用(比如分布式存储),阻塞等待更合适,CPU 利用率能降下来。

注意: CQ 是有深度的。创建 CQ 时指定的 cqe 参数就是队列容量。如果 CQ 满了,网卡无法再提交新的完成事件,会导致 QP 出错。我建议 CQ 深度至少是 QP 深度的 2 倍,防止突发流量导致溢出。

2.5 四个概念的关系总结

咱们用一张表来收尾:

概念 缩写 一句话描述 创建函数
保护域 PD 资源隔离容器,决定谁和谁是一伙的 ibv_alloc_pd()
内存区域 MR 注册一块内存,给网卡发「通行证」 ibv_reg_mr()
工作队列 WQ QP 内部的发送/接收队列,存放待办任务 创建 QP 时自动生成
完成队列 CQ 任务完成后的回执单,需要你主动去取 ibv_create_cq()

它们的依赖关系是这样的:

  • 创建 MR 需要 PD
  • 创建 QP 需要 PD 和 CQ
  • 提交 WR 需要 MR(通过 lkey 引用)
  • 完成事件从 CQ 中取出

说白了,PD 是根,MR 和 QP 是叶,CQ 是果实。你先把根扎稳了,后面的操作才安全可靠。

下一章,咱们会深入 QP 的创建和状态机。到时候你会发现,理解了今天这四个概念,QP 那点事就简单多了。