ch13_4
13.4 Completion Queue (CQ) —— 任务完成的信箱
上一节我们搞定的是「货」(Memory Region)和「路」(Address Handle),但这还只是静态的准备。RDMA 的本质是动——数据必须真正飞出去,再飞回来。
这就涉及到一个核心问题:你怎么知道网卡干完活了?
你把一个 Work Request(WR)扔进队列,转头去干别的事了。网卡在后台默默地 DMA 读、组包、发出去。这一切都是异步的。你不能一直在那儿等,否则 CPU 核心就浪费了。
你需要一个地方,让网卡把「干完了」这件事告诉你。这个地方就是 Completion Queue (CQ)。
为什么我们需要 CQ?
想象一下,你往 QP 的发送队列(SQ)里扔了一个 Send 请求,指向了一块内存缓冲区。
这就有一个微妙的时刻:在网卡确认完成之前,这块内存处于「薛定谔」状态。
- 如果是发送操作:网卡可能正在读这块内存。如果你这时候把这块内存释放了,或者往里写了新数据,网卡就会发出错包。更糟糕的是,你根本不知道它发出去没。
- 如果是接收操作:网卡可能随时会把收到的数据写进来。如果你去读这块内存,读到的可能是垃圾数据,也可能是还没收全的数据。
规则很简单:只要 WR 没完成,它指向的内存就是碰不得的。
那么,什么叫「完成」?
这就涉及到 Work Completion (WC) 的概念。WC 是一张「收据」。
- 如果是可靠连接(RC):收到 WC 意味着远端已经确认收到了。这叫「落袋为安」。
- 如果是不可靠连接(UC/UD):收到 WC 意味着网卡已经尽力发出去了(发到网线上了)。至于对方收没收到,天知道。
CQ 就是专门存放这些「收据」的队列。
CQ 的工作机制:FIFO 与通知
你可以把 CQ 理解为一个信箱。网卡是邮递员,你是取信的人。
- FIFO 顺序:信箱里的信(WC)是按时间顺序塞进去的。你取信的时候,必须按顺序取,不能跳着取。这保证了因果关系:你先发出的请求,肯定先拿到结果。
- 容量上限:信箱大小是有限的。如果你不取信,信塞满了,邮递员就没法再塞新的了。这时候,RDMA 栈会直接报错,关联的所有 QP 都会进入错误状态(Error State)。这很严重,相当于整个通信链路断了。
- 两种取信方式:
- 轮询:你每隔一小会儿去信箱看一眼。「有信吗?没有?那待会儿再来。」这是高性能场景的首选,没有中断开销。
- 事件通知:你跟邮递员说,「有信的时候敲门叫我」。你可以配置「塞满 10 封信再叫」或者「每隔 100ms 叫一次」,这叫中断合并,能降低 CPU 被打断的频率。
实战层:创建与操作 CQ
我们来看内核里怎么玩转这个机制。
1. 创建 CQ (ib_create_cq)
首先,你得有个信箱。
struct ib_cq *ib_create_cq(struct ib_device *device,
ib_comp_handler comp_handler,
void (*event_handler)(struct ib_event *, void *),
void *context,
struct ib_cq_init_attr *cq_attr);
这里有几个关键参数要填:
device:指向你的 RDMA 设备(HCA)。comp_handler:这是你的「取信回调」。当你用通知模式时,WC 到了,内核就会调这个函数。如果你是纯轮询模式,这个可以填 NULL。cq_attr:这里定义信箱的大小和属性。
⚠️ 踩坑预警
cq_attr->cqe(CQ Entries)是你期望的最小容量。底层驱动可能会给你分配一个更大的值(为了性能对齐)。你千万别假设它就是你申请的那个数,必须用返回值里的实际容量。而且,这个容量必须 >= 你所有关联 QP 的 Send Queue 和 Receive Queue 容量之和。为什么?最坏情况下,所有请求同时完成,CQ 得装得下所有的 WC。装不下就会溢出,直接炸链。
2. 调整大小 (ib_resize_cq)
跑着跑着发现信箱太小了?或者申请大了浪费?可以改。
int ib_resize_cq(struct ib_cq *cq, int cqe);
但有一个硬性限制:新的容量不能小于当前 CQ 里已有的 WC 数量。 你不能在信箱还没掏空的时候把信箱变小,这会把信挤坏。
3. 修改行为 (ib_modify_cq)
这个函数用来微调通知策略。
int ib_modify_cq(struct ib_cq *cq, u16 cq_count, u16 cq_period);
这是为了做中断合并:
cq_count:积攒了多少个 WC 后再发中断。cq_period:最多等多久(微秒)必须发一次中断。
这能帮你平衡延迟和 CPU 占用。如果你不调,默认可能来一个 WC 就发一次中断,在高吞吐下 CPU 会疯掉。
4. 窥探 (ib_peek_cq)
不想取出来,只想看看现在有多少信?
int ib_peek_cq(struct ib_cq *cq, int count);
这相当于看一眼信箱的透明玻璃,数数里面有几封信。这不会把 WC 取走。
5. 请求通知 (ib_req_notify_cq)
如果你用的是「敲门模式」(事件通知),你需要告诉内核「我准备好听敲门声了」。
int ib_req_notify_cq(struct ib_cq *cq,
enum ib_cq_notify_flags flags);
flags 有两种讲究:
IB_CQ_SOLICITED:只有那些标记为「Solicited」(请求通知)的 WC 到了才敲门。IB_CQ_NEXT_COMP:下一个 WC 到了就敲门,不管有没有标记。
这有个微妙的坑:调用这个函数本身不会产生通知。它只是「预订」下一次。如果调用之前 CQ 里已经有 WC 了,这个函数不会触发通知,你得自己去把那些 WC poll 出来。
6. 轮询 (ib_poll_cq) —— 核心操作
这是最常用的动作。不管你用不用通知,最终都得靠这个函数把数据拿出来。
int ib_poll_cq(struct ib_cq *cq, int num_entries, struct ib_wc *wc);
num_entries:这次想取多少个。wc:用来装结果的数组。- 返回值:实际取到了多少个。
注意,这是个「非阻塞」函数。没信就返回 0,绝不睡觉。
真实的场景:清空 CQ 并检查错误
我们来写一段典型的「处理逻辑」。
这段代码通常发生在你的 comp_handler 回调里,或者你的主循环里。
struct ib_wc wc;
int num_comp = 0;
// 循环取信,直到信箱空了
while (ib_poll_cq(cq, 1, &wc) > 0) {
// 第一步:看状态码
if (wc.status != IB_WC_SUCCESS) {
// 💥 炸了
printk(KERN_ERR "The Work Completion[%d] has a bad status %d\n",
num_comp, wc.status);
// 这里通常要做很重的清理工作,比如重启 QP
return -EINVAL;
}
// 第二步:看是谁干的(操作码)
switch (wc.opcode) {
case IB_WC_SEND:
// 发送完成
printk("Send operation completed.\n");
break;
case IB_WC_RECV:
// 接收完成
// wc.byte_len 告诉你收了多长的数据
printk("Received %d bytes\n", wc.byte_len);
break;
case IB_WC_RDMA_WRITE:
// RDMA 写完成
break;
// ... 其他操作
}
num_comp++;
}
⚠️ 千万别忘的一件事 当你处理完
IB_WC_RECV之后,必须立刻往 RQ(接收队列)里补一个新的Post Receive。 为什么?因为 Receive Queue 是消耗品。你不补,网卡收包没地儿放,就会触发 RNR (Receiver Not Ready) 错误, flow 就断了。 很多新手第一次写 RDMA,发一个包能通,第二个包就死机了,就是因为忘了Post Receive。
特殊的存在:XRC Domain
在 CQ 的最后,原文提到了一个叫 XRC Domain 的东西。
这是一个比较高级的概念,用于一种叫 eXtended Reliable Connected (XRC) 的传输模式。
还记得我们之前说的 QP 是一对儿(SQ 和 RQ 绑死)吗?XRC 把它们解耦了,为了实现那种「一个超级服务器连接一万个客户端」的场景。
XRC Domain 就是一个隔离域。 它限定了「哪些 XRC SRQ 可以互相通信」。
- 如果两个 QP 在同一个 XRC Domain 里,它们可以共享 SRQ。
- 如果不在,虽然硬件连接着,但逻辑上老死不相往来。
这就像在一栋大楼里(RDMA Device),虽然大家都在一个物理网络里,但你要进「财务部」(XRC Domain)的门,得有专门的门禁卡。这主要是为了在大规模集群里做资源和安全隔离。
本章回响
到这里,RDMA 核心对象的拼图已经快拼齐了。 从最底层的设备,到指路的 AH,到装货的 MR,再到今天这个负责「收工」的 CQ。
你会发现 RDMA 的设计哲学其实非常一致:所有的「管理」都在内核里搞定(创建、修改、销毁),所有的「数据通路」都交给用户态直接下命令。
CQ 是这两个世界之间的桥梁。它是一个纯粹的状态队列,它不管数据长什么样,它只管「成功」还是「失败」。这种极简主义,正是它能跑得那么快的根本原因。
下一节,我们将把这些零件组装起来,看看那个最终的执行者:Queue Pair (QP)。那是所有指令真正起飞的地方。