4、CQ与完成事件处理:轮询(Polling)与事件通知(Event Notification)的选择误区、CQ溢出与丢失完成事件

CQ(Completion Queue)是RDMA编程里最容易被忽视的环节。很多人把精力都花在QP配置和内存注册上,结果程序跑起来要么CPU飙到100%,要么丢事件丢到怀疑人生。我早期做存储项目时就吃过这个亏——CQ溢出导致数据静默损坏,排查了整整三天。

说白了,CQ就是硬件通知软件“活干完了”的通道。怎么从这条通道里取消息,直接决定了你的系统是高性能还是高延迟。咱们今天就把轮询和事件通知这两个模式掰开揉碎讲清楚。

4.1 轮询模式:简单粗暴但烧CPU

轮询,就是让CPU不停地去问CQ:“有完成事件没?有完成事件没?”

struct ibv_cq *cq;
struct ibv_wc wc;
int ret;

while (1) {
    ret = ibv_poll_cq(cq, 1, &wc);
    if (ret > 0) {
        // 处理完成事件
        if (wc.status != IBV_WC_SUCCESS) {
            fprintf(stderr, "Work completion failed: %s\n",
                    ibv_wc_status_str(wc.status));
        }
    }
}

这段代码看起来简单吧?但问题就出在这个“简单”上。轮询模式下,CPU会一直占用一个核心,哪怕没有事件也在空转。我见过有人把轮询线程绑在CPU0上,结果整个系统的中断都被拖慢了。

核心要点:轮询适合延迟敏感型场景,比如高频交易、实时计算。但代价是CPU占用率几乎100%。

什么时候该用轮询?我个人习惯看两个指标:

  • 消息间隔时间:如果两个完成事件之间的间隔小于1微秒,轮询比事件通知快
  • CPU核心富余度:如果你有16个核心,拿1个去轮询无所谓;但如果是4核的嵌入式设备,就得掂量掂量

4.2 事件通知模式:省CPU但可能丢事件

事件通知模式,就是让硬件在CQ里有完成事件时,通过中断或信号通知应用程序。代码大概是这样的:

struct ibv_comp_channel *channel;
struct ibv_cq *cq;
struct ibv_cq *ev_cq;
void *ev_ctx;

// 创建完成事件通道
channel = ibv_create_comp_channel(context);

// 创建CQ并关联到通道
cq = ibv_create_cq(context, cq_depth, NULL, channel, 0);

// 请求事件通知
ibv_req_notify_cq(cq, 0);

while (1) {
    // 等待事件通知
    ibv_get_cq_event(channel, &ev_cq, &ev_ctx);
    
    // 处理所有完成事件
    ibv_ack_cq_events(ev_cq, 1);
    
    // 重新请求通知
    ibv_req_notify_cq(cq, 0);
    
    // 轮询处理所有待处理事件
    while (ibv_poll_cq(cq, 1, &wc) > 0) {
        // 处理完成事件
    }
}

嗯,这里要注意一个关键点:事件通知和轮询是配合使用的。你不能只等事件通知而不去轮询。为什么?因为事件通知可能会合并——硬件可能只发一个中断告诉你“有事件了”,但CQ里可能积压了100个完成事件。

我曾经踩过的坑:只调用ibv_get_cq_event(),然后只处理一个wc就回去继续等事件。结果CQ里的完成事件越积越多,最后CQ溢出,硬件直接报错。那一次线上事故,丢了几十万条消息。

4.3 CQ溢出:无声的数据杀手

CQ溢出是最隐蔽的问题。硬件往CQ里写完成事件,软件从CQ里读。如果软件读得不够快,CQ满了,硬件就会丢弃新的事件。

CQ深度 推荐场景 注意事项
128-256 低延迟、小消息 适合轮询模式,CPU占用高
1024-4096 高吞吐、大消息 适合事件通知模式,注意处理速度
65536+ 批量处理场景 内存占用大,但能抗突发流量

怎么判断CQ是否溢出?看硬件计数器。Mellanox网卡可以通过perfquery工具查看:

# 查看CQ溢出计数
perfquery -x <lid> | grep CQOverflow

如果这个计数器在增长,说明你的CQ深度不够,或者处理速度跟不上。我建议把CQ深度设为QP深度的2-4倍。比如你的QP发送队列深度是256,CQ就设512或1024。

4.4 丢失完成事件的典型场景

丢失完成事件,说白了就是软件以为硬件没干完活,其实硬件早就干完了。最常见的几个原因:

  1. 事件通知未重新请求:每次收到事件通知后,必须重新调用ibv_req_notify_cq()。忘了这一步,后续的事件就不会通知你了。
  2. 多线程竞争:多个线程同时轮询同一个CQ,导致完成事件被“抢走”。一个线程处理了,另一个线程还在傻等。
  3. CQ与QP不匹配:发送和接收共用同一个CQ,但处理逻辑没区分wc的opcode。结果把发送完成事件当成接收来处理。

我的调试技巧:在开发阶段,每个完成事件都打印wc.wr_id和wc.status。wr_id是用户自定义的标识,可以用来追踪是哪个WR完成了。线上环境可以把这个日志关掉,但开发时一定要开着。

4.5 如何选择:轮询还是事件通知?

这个问题没有标准答案。我一般按这个思路来选:

  • 延迟优先:用轮询。把轮询线程绑在独立核心上,设置高优先级。
  • 吞吐优先:用事件通知。配合批量处理,一次事件通知处理一批完成事件。
  • CPU敏感:用事件通知。但要注意处理延迟,别让CQ溢出。
  • 混合模式:先轮询几次,如果没事件就切到事件通知等待。这样既降低了延迟,又节省了CPU。

你想想看,其实很多问题都是因为对CQ的行为理解不够深。硬件层面的完成事件处理,说白了就是生产者-消费者模型。硬件是生产者,软件是消费者。只要消费者比生产者快,系统就稳定。反过来,就等着出问题吧。

最后说一句:永远不要假设CQ不会溢出。在生产环境里,一定要监控CQ溢出计数器。我见过太多人因为忽略了这一点,在流量高峰时系统突然崩溃。嗯,血的教训啊。