3、RDMA编程模型:Verbs API概述、libibverbs库安装、ibv_open_device、ibv_alloc_pd、ibv_create_cq、ibv_create_qp
好,咱们正式开始动手写代码了。这一章我会带你过一遍RDMA编程最核心的Verbs API,以及那几个你绕不开的基础对象:设备、保护域、完成队列和队列对。说白了,这就是RDMA程序的“地基”。
我个人习惯把Verbs API理解成“网卡的驱动程序接口”。你想想看,你要让网卡帮你做内存搬运,总得有个方式告诉它“搬哪块内存”、“搬多少”、“搬完怎么通知我”。Verbs就是干这个的。
3.1 Verbs API 概述
Verbs API,全称是“RDMA Verbs”。它定义了一套标准接口,让用户态程序能直接跟RDMA网卡(比如Mellanox的ConnectX系列)打交道。这套接口在Linux上由libibverbs库提供。
我刚开始接触RDMA时,最困惑的就是:为什么叫“Verbs”?后来才明白,它把网卡操作抽象成了“动作”——比如“发送”、“接收”、“读取”、“写入”。每个动作就是一个Verb。嗯,这么一想就通了。
Verbs API主要分两类:
- 管理类Verbs:负责设备打开、资源分配、连接建立。比如咱们这章要讲的ibv_open_device、ibv_alloc_pd、ibv_create_cq、ibv_create_qp。
- 数据类Verbs:负责实际的数据传输。比如ibv_post_send(发起发送)、ibv_post_recv(发起接收)、ibv_poll_cq(轮询完成事件)。
这里有个关键点:管理类Verbs通常只在初始化阶段调用一次,而数据类Verbs是热点路径,性能敏感。我在项目中遇到过有人把ibv_alloc_pd放在循环里调用,结果性能惨不忍睹——这玩意儿创建一次就够了,别反复搞。
3.2 libibverbs库安装
在开始写代码之前,得先把libibverbs装上。不同发行版安装方式略有不同,但核心步骤差不多。
Ubuntu/Debian系:
sudo apt-get update
sudo apt-get install libibverbs-dev ibverbs-utils rdma-core
CentOS/RHEL系:
sudo yum install libibverbs-devel ibverbs-utils rdma-core
装完之后,可以用下面这条命令验证一下:
ibv_devinfo
如果能看到你的RDMA网卡信息(比如端口状态、GID、MTU等),那就说明装好了。我曾经在一台新机器上跑ibv_devinfo,结果啥也没输出——后来发现是内核模块没加载。执行 modprobe mlx5_core 和 modprobe mlx5_ib 就好了。你如果也遇到类似情况,先检查一下内核模块。
3.3 ibv_open_device:打开RDMA设备
好,库装好了,咱们开始写代码。第一步就是打开RDMA设备。
ibv_open_device 的作用是获取一个RDMA设备的句柄。这个句柄后续所有操作的基础。它的函数签名长这样:
struct ibv_context *ibv_open_device(struct ibv_device *device);
参数是一个 ibv_device 指针,返回值是一个 ibv_context 指针。怎么拿到 ibv_device 呢?通常用 ibv_get_device_list:
struct ibv_device **dev_list;
struct ibv_context *ctx;
int num_devices;
dev_list = ibv_get_device_list(&num_devices);
if (!dev_list || num_devices == 0) {
fprintf(stderr, "没有找到RDMA设备\n");
return -1;
}
// 通常打开第一个设备
ctx = ibv_open_device(dev_list[0]);
if (!ctx) {
fprintf(stderr, "打开设备失败\n");
ibv_free_device_list(dev_list);
return -1;
}
printf("成功打开设备: %s\n", ibv_get_device_name(dev_list[0]));
// 用完记得释放
ibv_free_device_list(dev_list);
这里有个坑:ibv_get_device_list 返回的列表是动态分配的,用完必须调用 ibv_free_device_list 释放。我见过有人忘了这步,导致内存泄漏——虽然泄漏不大,但长期运行的程序还是要注意。
ibv_get_device_name 打印所有设备名,然后根据名称选择目标设备。比如用 strcmp 匹配 "mlx5_0" 或 "mlx5_1"。
3.4 ibv_alloc_pd:分配保护域
设备打开了,接下来要分配保护域(Protection Domain,简称PD)。PD是RDMA中一个非常重要的概念——它把内存注册、队列对、完成队列等资源“圈”在一起,形成一个隔离域。说白了,同一个PD里的资源才能互相访问。
函数签名很简单:
struct ibv_pd *ibv_alloc_pd(struct ibv_context *context);
参数是之前打开的 ibv_context,返回值是一个 ibv_pd 指针。
struct ibv_pd *pd = ibv_alloc_pd(ctx);
if (!pd) {
fprintf(stderr, "分配保护域失败\n");
// 别忘了关闭设备
ibv_close_device(ctx);
return -1;
}
PD分配成功后,后续创建QP、CQ、MR(内存区域)时都要传入这个PD。我习惯把PD看作“资源容器”——所有需要互相通信的资源都放在同一个PD里。如果你有多个独立的通信通道,可以创建多个PD,互不干扰。
我曾经在一个项目中,为了图省事,所有QP都共用一个PD。结果后来要加一个安全隔离的需求,不得不重构代码,把PD拆开。嗯,提前规划好PD的粒度,能省不少事。
3.5 ibv_create_cq:创建完成队列
完成队列(Completion Queue,简称CQ)是RDMA的“通知机制”。当你发起一个数据传输操作(比如发送或接收),网卡完成之后,会把完成事件放到CQ里。你的程序通过轮询CQ来知道操作是否完成。
函数签名:
struct ibv_cq *ibv_create_cq(struct ibv_context *context, int cqe,
void *cq_context,
struct ibv_comp_channel *channel,
int comp_vector);
参数说明:
context:设备上下文cqe:CQ的容量,即最多能存放多少个完成事件cq_context:用户自定义上下文,通常传NULLchannel:完成事件通道,用于异步通知。如果不需要,传NULLcomp_vector:中断向量号,用于多队列优化。通常传0
一个典型的创建示例:
struct ibv_cq *cq = ibv_create_cq(ctx, 1024, NULL, NULL, 0);
if (!cq) {
fprintf(stderr, "创建完成队列失败\n");
ibv_dealloc_pd(pd);
ibv_close_device(ctx);
return -1;
}
这里 cqe = 1024 表示CQ最多能容纳1024个完成事件。这个值要根据你的实际并发量来设。设太小了,网卡完成事件放不下,会丢事件;设太大了,浪费内存。我一般按“最大未处理WR数量 * 2”来估算,留点余量。
3.6 ibv_create_qp:创建队列对
队列对(Queue Pair,简称QP)是RDMA通信的“核心引擎”。一个QP包含两个队列:发送队列(SQ)和接收队列(RQ)。你往SQ里放发送请求,往RQ里放接收请求,网卡就会自动处理。
创建QP比前面几个步骤稍微复杂一点,因为它需要先填充一个属性结构体:
struct ibv_qp_init_attr qp_attr;
memset(&qp_attr, 0, sizeof(qp_attr));
qp_attr.send_cq = cq; // 发送完成队列
qp_attr.recv_cq = cq; // 接收完成队列(可以和发送共用同一个CQ)
qp_attr.qp_type = IBV_QPT_RC; // 可靠连接(RC)模式
qp_attr.cap.max_send_wr = 128; // 发送队列深度
qp_attr.cap.max_recv_wr = 128; // 接收队列深度
qp_attr.cap.max_send_sge = 1; // 每个发送请求最多关联的SGE数
qp_attr.cap.max_recv_sge = 1; // 每个接收请求最多关联的SGE数
struct ibv_qp *qp = ibv_create_qp(pd, &qp_attr);
if (!qp) {
fprintf(stderr, "创建队列对失败\n");
ibv_destroy_cq(cq);
ibv_dealloc_pd(pd);
ibv_close_device(ctx);
return -1;
}
这里有几个关键点:
- QP类型:
IBV_QPT_RC是可靠连接,最常用。还有UC(不可靠连接)、UD(不可靠数据报)等,但RC是主流。 - 队列深度:
max_send_wr和max_recv_wr决定了QP能同时“在途”多少个请求。深度越大,流水线越深,但内存开销也越大。我一般从128开始调,压测时再根据实际吞吐量调整。 - SGE数量:SGE(Scatter/Gather Element)描述了一块内存缓冲区。大多数场景下,一个请求对应一个SGE就够了。如果你要做“零拷贝”聚合发送,才需要多个SGE。
我记得第一次调QP深度时,设了个4096,结果程序跑起来内存暴涨,还频繁报错。后来才发现,队列深度不是越大越好——它受网卡硬件资源限制。查一下网卡规格,再设一个合理的值。
ibv_modify_qp 把它切换到“就绪”状态(RTR和RTS)。这部分内容我会在后续章节详细讲。
3.7 资源释放的顺序
最后,提一下资源释放的顺序。很多新手会搞反,导致程序崩溃或资源泄漏。正确的顺序是:
- 先销毁QP:
ibv_destroy_qp(qp) - 再销毁CQ:
ibv_destroy_cq(cq) - 再释放PD:
ibv_dealloc_pd(pd) - 最后关闭设备:
ibv_close_device(ctx)
为什么是这个顺序?因为QP依赖于CQ和PD,CQ依赖于PD,PD依赖于设备。你如果先关了设备,再销毁QP,那QP销毁时访问设备上下文就会出问题。嗯,这个顺序我踩过坑,后来就记住了。
好了,这一章的内容就到这里。你掌握了Verbs API的基本概念,学会了安装libibverbs,也亲手创建了设备、PD、CQ和QP。下一章,我会带你做内存注册,然后真正开始收发数据。