13.7 RDMA 支持的操作
上一节我们花了很大力气去搞清楚 QP 这个「阿凡达」是怎么造出来的,以及它的生命周期有多脆弱。
现在,当 QP 终于走到了 RTS(Ready to Send)状态,意味着传输通道已经打通。是时候让它干点正事了。
在 RDMA 的世界里,并不是所有的通信方式都是平等的。你有一大把操作符可以用,从最简单的「发个消息」到魔术般的「远程内存读写」。这一节,我们要拆解这些操作背后的机制,看看怎么把数据真正地搬出去,以及当网络抽风时,硬件会怎么自救。
操作菜单:你能做什么?
InfiniBand 提供的操作类型比普通网络接口要丰富得多。这不是为了炫技,而是为了覆盖不同的性能语义。
我们可以把它们分为几类来看。
1. 消息传递
这是最像传统 Socket 的部分,但也仅仅是「像」而已。
- Send(发送): 把消息扔到线上。但这有一个前提:远端必须提前准备好接收请求。如果远端没准备好,这包数据会被丢弃或者触发错误流(取决于传输类型)。消息会被写入远端缓冲区。
- Send with Immediate(带立即数的发送): 发送消息的同时,附带一个 32 位的带外数据。 这个 32 位数据不会进入数据缓冲区,而是直接出现在接收方的 Work Completion(WC) 里。 这是个很巧妙的机制,你可以用它来发送简短的指令或元数据,而接收方不需要去解析庞大的数据包就能拿到。
2. RDMA 操作
这是 RDMA 的灵魂,也是它区别于普通网卡的地方。
- RDMA Write(远程写): 直接把数据写到远端的内存地址里。远端的 CPU 完全不需要参与——没有中断,没有内核上下文切换。只要远端给出了权限,数据就「嗖」地一下过去了。
- RDMA Write with Immediate(带立即数的远程写):
这是
RDMA Write和Send with Immediate的结合体。 数据被写入远端指定内存(像 RDMA Write),同时那个 32 位立即数会被塞进远端的 CQ(像 Send Immediate)。 注意:这个操作要求远端必须有一个 Receive Request 在排队。为什么?因为那个立即数需要找个地方落脚(WC),而 WC 只有在有对应的接收操作时才会生成。可以把它理解为「零字节的 Send + 正常的 RDMA Write」。 - RDMA Read(远程读): 主动发起方指定一个远端地址,把那里的数据拉回本地缓冲区。这是「拉」模式,主动权在读取方。
3. 原子操作
在分布式系统中,锁是性能杀手。RDMA 提供了硬件级别的原子操作,让你绕过锁直接操作远端内存。
- Compare and Swap (CAS):
比较远端地址的值与
valueX。如果相等,就替换成valueY。整个过程是原子的。操作完成后,远端的原始值会被发回并保存在本地。 - Fetch and Add: 原子地将远端地址的值增加一个数值。原始值同样会被发回本地。
- Masked Compare and Swap: 带掩码的 CAS。它只比较掩码指定的位,相等则只替换掩码对应的位。
- Masked Fetch and Add: 带掩码的加法,只改变掩码指定的位。
4. 内存管理扩展
- Bind Memory Window:把一个内存窗口绑定到特定的内存区域。
- Fast Registration:通过 WR 快速注册一个 FMR。
- Local Invalidate:通过 WR 使一个 FMR 失效。如果有人再用旧的 key,就会报错。这个操作可以和 Send/Read 组合,执行顺序是先读写,后失效。
接收请求:谁来买单?
对于所有「消费」Receive Request 的操作(比如 Send),远端必须提前把盘子摆好。
Receive Request 指定了数据落在哪里。你的 Scatter List 列出的缓冲区总大小,必须大于等于进来的消息大小。不然? Overflow 错误等着你。
这里有个专门针对 UD QP 的坑:
UD 是不可靠数据报,消息可能来自子网内,也可能来自子网外;可能是单播,也可能是组播。这意味着消息可能带有一个 40 字节的 GRH(Global Routing Header)。
如果你用的是 UD QP,必须在 Receive Request 的缓冲区里多预留 40 个字节。
- 如果收到的消息带 GRH,前 40 字节会被填满 GRH 内容(告诉你怎么回消息),真正的数据从第 40 字节开始。
- 如果没 GRH,这 40 字节就是未定义的(或者被硬件忽略),数据直接从开头放(具体情况看硬件实现,通常你还需要看 WC 里的标志位来判断)。
代码实战:提交接收请求
我们来看看怎么用内核 API 把一个 Receive Request 也就是 ib_recv_wr 塞进 RQ。
这里假设 qp 已经创建好了,dma_addr 是已经用 ib_dma_map_single 映射好的地址,mr 是注册好的内存区域。
struct ib_recv_wr wr, *bad_wr;
struct ib_sge sg;
int ret;
// 1. 填充 scatter-gather 元素(SGE)
// 这就是数据要落地的地址
memset(&sg, 0, sizeof(sg));
sg.addr = dma_addr; // 物理地址
sg.length = len; // 缓冲区长度(UD记得加40!)
sg.lkey = mr->lkey; // 本地密钥
// 2. 填充 Work Request
memset(&wr, 0, sizeof(wr));
wr.next = NULL; // 链表指针,单个请求就填 NULL
wr.wr_id = (uintptr_t)dma_addr; // 用于标识这个请求的 ID,轮询 CQ 时会原样返回
wr.sg_list = &sg; // 指向 SGE 数组
wr.num_sge = 1; // 有几个 SGE
// 3. 提交到内核
ret = ib_post_recv(qp, &wr, &bad_wr);
if (ret) {
printk(KERN_ERR "Failed to post Receive Request to a QP\n");
return ret;
}
⚠️ 千万别忘了轮询 CQ:你提交了 Request,只是把任务挂了出去。等数据到了,你需要在对应的 CQ 里去取 Work Completion,才知道接收成功没。
发送请求:把数据扔出去
发送端的逻辑跟接收端很像,只不过 Work Request 的结构更丰富,因为要指定 opcode(操作码)。
下面是一个标准的 Send 操作示例。
struct ib_sge sg;
struct ib_send_wr wr, *bad_wr;
int ret;
// 1. SGE 设置
memset(&sg, 0, sizeof(sg));
sg.addr = dma_addr;
sg.length = len;
sg.lkey = mr->lkey;
// 2. Send WR 设置
memset(&wr, 0, sizeof(wr));
wr.next = NULL;
wr.wr_id = (uintptr_t)dma_addr;
wr.sg_list = &sg;
wr.num_sge = 1;
// 关键部分:指定操作和行为
wr.opcode = IB_WR_SEND; // 这是个普通的 Send
wr.send_flags = IB_SEND_SIGNALED; // 请求产生一个 WC。如果不设这个,
// 只有在链表里的最后一个 WR 才会产生 WC,
// 这通常用于性能优化(Batching)。
// 3. 提交
ret = ib_post_send(qp, &wr, &bad_wr);
if (ret) {
printk(KERN_ERR "Failed to post Send Request to a QP\n");
return ret;
}
当网络出错时:重试流
理想情况下,WR 提交 -> 硬件发送 -> 硬件接收 -> 生成 WC。
但现实是残酷的。WC 可能会带着错误回来。一旦出错,内存缓冲区里的内容就是未定义的——脏了。
有些错误是致命的(比如权限越界),硬件不会重试,直接报错。但在 Reliable 传输类型(RC) 下,硬件有两个非常强大的自动重试机制。作为开发者,你通常感觉不到这些重试的发生,除了网络稍微卡顿一下。
1. 通用重试流
如果发送方发了包,却没在超时时间内收到 ACK 或 NACK,硬件会自动重发。
这通常是因为:
- 远端 QP 状态不对(没到 RTR,或者进 Error 态了)。
- 路由配置错了。
- 路上包丢了(CRC 错误)。
- 回来的 ACK 丢了。
只要最终 ACK 收到了,一切都好,上层应用无感知。如果重试次数耗尽还是没收到,发送方会收到一个 Retry Error 的 WC。
2. RNR (Receiver Not Ready) 流
这是一个专门针对「笨蛋接收方」的保护机制。
场景:发送方发了数据,接收方收到了,但发现 RQ 里没有空的 Receive Request(即没摆盘子)。
在传统网络里,这包数据直接丢了。在 RDMA RC 模式里,接收方会回一个 RNR NACK。
发送方收到 NACK 后,会暂停一小会儿(RNR NACK 里带的时间),然后重发。
如果接收方及时补上了 Receive Request,数据就能正常接收,发送方收到 ACK,皆大欢喜。
如果接收方一直不补,发送方重试次数耗尽,就会收到 RNR Retry Error 的 WC。
组播:一点对多点
组播允许一个 UD QP 向多个 UD QP 发送消息。
机制很简单:
- 想收消息的 UD QP 必须调用
ib_attach_mcast()把自己挂到某个组播组上。 - 网卡收到组播包后,会把它复制给所有挂在这个组上的 QP。
不想收了?调用 ib_detach_mcast() 即可。
用户态 vs 内核态 API
RDMA 的美妙之处在于,用户态和内核态用的 API 几乎是一样的。
- 前缀:内核用
ib_,用户态用ibv_。 - 控制路径:用户态调用控制函数时,会陷入内核,因为要操作特权资源(比如分配 QP 号)。
- 差异点:
- 有些 QP 类型只在内核可见(SMI, GSI)。
- 有些特权操作只能在内核做(物理内存注册、FMR)。
- 通知机制:内核 API 是异步的(回调函数);用户态 API 是同步的,你需要主动去 poll CQ 或 event。
本章小结
我们在这章里走了很远。从 InfiniBand 的零拷贝、内核旁路优势,到复杂的软硬件架构,再到 QP、MR、CQ 这些核心对象的创建与管理。
最后这一节,我们把这些对象串了起来,展示了如何通过 Send 和 RDMA 操作让数据流动起来。
RDMA 的学习曲线确实陡峭——你要理解地址转换、内存注册、状态机、重试流……但一旦你驯服了这套系统,你得到的将是一个能绕过操作系统内核、直接触碰网络脉搏的强大引擎。
下一章,我们将把目光投向更广阔的系统视角——网络命名空间和蓝牙子系统。