跳到主要内容

13.5 Shared Receive Queue (SRQ)

我们现在已经聊过了 QP、CQ,还有各种花里胡哨的域。你可能会觉得 RDMA 的对象模型有点像俄罗斯套娃,一层套一层。

但如果你的服务端需要面对成千上万个并发连接,你会发现刚才那套「每个 QP 都配一个独立接收队列」的规矩,开始变得有点不可理喻。

想象一下这个场景:你有 10,000 个客户端连着你。每个客户端都可能在某个时刻给你发消息,但绝大多数时候它们都是沉默的。按照旧规矩,你得为这 10,000 个 QP 每一个都准备好接收缓冲区——以防万一它突然发来数据。

这就像是你请了 10,000 个人吃饭,为了防止谁突然饿了,你在每张桌子上都摆满了满汉全席。结果呢?只有 100 个人在吃,剩下的 9,900 张桌子上的菜都在那里慢慢凉掉,甚至变质。

内存是一种稀缺资源,这种奢侈是服务器无法承受的。

这就引出了我们这一节的主角:Shared Receive Queue (SRQ,共享接收队列)


为什么我们需要 SRQ?

SRQ 的核心思想非常简单:把接收资源从「私有」变成「公有」

不再是每个 QP 自己守着一堆 Receive Request(接收请求),而是所有 QP 都连到同一个大池子里。谁收到了数据,谁就从池子里拿一个缓冲区去装。

如果你手头有 N 个 QP,每个 QP 最多可能会在某一时刻收到 M 个突发消息:

  • 不用 SRQ:你必须老老实实发布 N * M 个接收请求。哪怕 99% 的连接都是空闲的,这些内存也得占着。
  • 用 SRQ:你只需要发布 K * M 个(这里 K << N)。只要保证池子里总有活儿干,你就不用担心具体的某个 QP 会不会突然发洪水。

这听起来很完美,但这其实是工程界经典的「资源池化」trade-off:你用管理的复杂性换取了资源的利用率。

池化的代价:失控与水位线

SRQ 并不是没有代价的。

当你把接收队列共享之后,你失去了一个重要的控制权:你不再确切知道是哪个 QP 会拿走这个缓冲区。

在非共享模式下,你知道 QP A 专门处理小包,QP B 专门处理大包,所以你可以给 QP A 发小缓冲区,给 QP B 发大缓冲区。

但在 SRQ 模式下,你往池子里扔一个缓冲区,你根本知道是哪个 QP 会捡到它。可能是处理日志的小 QP,也可能是传输大文件的大 QP。

这就导致了一个硬性约束:所有投递到 SRQ 里的 Receive Request,其缓冲区大小必须能容纳所有关联 QP 中最大的那个消息。

如果你有两个 QP,一个传 64B 的心跳包,一个传 4MB 的数据块。很遗憾,为了照顾那个大家伙,你池子里所有的缓冲区都得是 4MB 级别的。这听起来很浪费?是的,这确实是 SRQ 的一个痛点。通常的解决办法是分级——创建两个 SRQ,一个专门挂小包 QP,一个专门挂大包 QP,以此减少内存浪费。

除了缓冲区大小失控的风险,还有一个更棘手的问题:池子空了怎么办?

在普通 QP 模式下,如果某个 QP 的接收队列空了,只会影响它自己。但在 SRQ 模式下,一旦池子枯竭,所有挂在上面的 QP 都会「饿死」,导致数据包被丢弃。

这就引入了 SRQ 的一个独门绝技:水位线

SRQ 允许你设置一个 srq_limit 阈值。当池子里的可用接收请求数量降到这个值以下时,硬件会触发一个异步事件 通知你:「嘿,快没水了,赶紧补水!」

这给了你一个喘息的机会。你可以在耗尽之前,通过 ib_post_srq_recv() 往里补充新的接收请求。


SRQ 的生命周期管理

让我们来看看在内核里怎么折腾这个东西。

创建 SRQ

和之前见过的 MR、QP 一样,SRQ 也不能悬浮在真空中,它必须属于一个 Protection Domain (PD)。这保证了只有同一个安全域里的 QP 才能共享这个队列。

调用的是 ib_create_srq()

struct ib_srq *ib_create_srq(struct ib_pd *pd,
struct ib_srq_init_attr *srq_init_attr);

你需要传入 PD 以及一个初始化属性结构 srq_init_attr。在这个结构里,你会指定最大接收请求数量、最大 SGE 数量等。

修改 SRQ 属性

创建好了,不是就完了。正如刚才说的,水位线是动态调整的,或者某些硬件允许你动态调整 SRQ 的大小。

这时候就要用 ib_modify_srq()

这是一个实战场景:我想在 SRQ 的剩余请求数降到 5 个以下时收到警报

struct ib_srq_attr srq_attr;
int ret;

memset(&srq_attr, 0, sizeof(srq_attr));
srq_attr.srq_limit = 5; /* 设置水位线为 5 */

ret = ib_modify_srq(srq, &srq_attr, IB_SRQ_LIMIT);
if (ret) {
printk(KERN_ERR "Failed to set the SRQ's limit value\n");
return ret;
}

这里使用了 IB_SRQ_LIMIT 这个命令,告诉内核我们要修改的是水位线属性。一旦设置成功,当 SRQ 内部的计数器跌破这个数时,你注册的事件处理函数就会被唤醒。

⚠️ 踩坑预警:千万别等到 0 才设置水位线。如果在收到事件之前,新的一波突发流量把剩下的 5 个也吃光了,你还是得丢包。给自己留一点余量,比如 5% 或者 10%。

查询 SRQ

如果你忘了自己设了多少,或者想看看当前的状态,可以用 ib_query_srq()

int ib_query_srq(struct ib_srq *srq, struct ib_srq_attr *srq_attr);

这通常用于调试或者监控。如果你发现查询出来的 srq_limit 是 0,那就说明你之前没设置过水位线(或者把它关了)。

销毁 SRQ

当一切结束,不需要共享了,调用 ib_destroy_srq()。但在销毁之前,请确保所有关联它的 QP 都已经被销毁或者解绑,否则行为是未定义的。


真正的干活:投递接收请求

创建了 SRQ 只是搭了个台子,真正让数据流动起来的是往里面投递 Work Request (WR)

在普通 QP 里,我们用 ib_post_recv。在 SRQ 这里,API 变成了 ib_post_srq_recv()

逻辑是一样的:把一个 ib_recv_wr(接收请求)挂到链表里,扔给硬件。但这里要注意,因为 SRQ 是共享的,你的 wr_id 必须设计得足够聪明,以便在取回 WC 时,你能通过某种方式反向追踪到上下文。

下面是一个标准的 SRQ 接收投递流程。我们投递一个接收请求,告诉硬件:「如果来了数据,就放到这个 DMA 地址里」。

struct ib_recv_wr wr, *bad_wr;
struct ib_sge sg;
int ret;

/* 1. 准备 SGE (scatter/gather entry),指明数据放哪 */
memset(&sg, 0, sizeof(sg));
sg.addr = dma_addr; /* 物理地址 */
sg.length = len; /* 缓冲区长度 */
sg.lkey = mr->lkey; /* 本地密钥 */

/* 2. 准备 Work Request */
memset(&wr, 0, sizeof(wr));
wr.next = NULL; /* 单个请求,没有 next */
wr.wr_id = (uintptr_t)dma_addr; /* 把地址塞进 wr_id,方便回溯 */
wr.sg_list = &sg; /* 指向 SGE */
wr.num_sge = 1; /* 只有一个 SGE */

/* 3. 投递! */
ret = ib_post_srq_recv(srq, &wr, &bad_wr);
if (ret) {
printk(KERN_ERR "Failed to post Receive Request to an SRQ\n");
return ret;
}

这里有个细节值得玩味bad_wr 参数。

如果 ib_post_srq_recv 失败了,它会把第一个出错的 WR 指针放到 bad_wr 里。这在大批量投递(链表投递)时非常有用——它能告诉你「挂在这条链上的哪个环节断了」,而不仅仅是告诉你「失败了」。但在单次投递的例子里,它主要就是个错误检查的标志。


重新审视那张图

回到我们刚才那张图(Figure 13-4)。你现在看到的不再是三个孤立的 QP 孤零零地挂在那里,而是三个 QP 的「接收端」全部汇聚到了下方的 SRQ 上。

这就是 RDMA 解决「接收侧扩展性」问题的终极答案。

它并不完美(你需要忍受大缓冲区的浪费,你需要处理水位线逻辑),但在高并发的场景下,这是唯一能让你既保住内存,又保住延迟的手段。