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() 注册一块内存时,实际上做了三件事:
- 告诉网卡:「这块内存的物理地址在这里,你可以直接访问」
- 给网卡一把「钥匙」—— 一个叫做 lkey/rkey 的整数
- 指定访问权限:本地读、本地写、远程读、远程写、原子操作
代码示例很简单,但背后的原理值得细品:
#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_memalign 或 mmap 来分配,再也没出过问题。
另外,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 那点事就简单多了。