各位同学,大家好!欢迎来到今天的“C++ 高性能网络编程大师课”。我是你们的主讲人。
今天我们要聊的东西,有点“重口味”,有点“黑科技”,有点……让人肾上腺素飙升。我们要聊的是 RDMA(Remote Direct Memory Access,远程直接内存访问)。
在座的各位,谁没被 TCP/IP 协议栈那繁琐的内核拷贝折磨过?谁没在处理百万级并发请求时,看着 CPU 占用率飙红,然后怀疑人生?今天,我们要打破常规,我们要让网络不再是网络,我们要让网络变成一块直接插在你 CPU 里的巨大内存条。
主题是:C++ 与远程内存直接访问(RDMA):在 C++ 中通过单边操作(One-sided)实现跨节点内存池的零拷贝读写。
准备好了吗?系好安全带,我们要起飞了。
第一章:告别“蜗牛信使”——为什么我们需要 RDMA?
想象一下,你是一个忙碌的餐厅大厨(服务器),而你的顾客(客户端)坐在隔壁桌点菜。
传统的 TCP/IP 方式(双边操作):
顾客说:“老板,来份红烧肉!”
大厨听到后,写好菜谱(数据包),大喊一声:“服务员!”
服务员跑过来,接过菜谱,跑到后厨。
后厨把菜做好,服务员又跑回顾客桌前:“给,红烧肉!”
顾客吃。
在这个过程中,大厨、服务员、顾客都在跑来跑去,不仅累,还容易洒汤(丢包、延迟)。更重要的是,每一次搬运,数据都要在内核空间和用户空间之间来回折腾。CPU 大部分时间都在忙着“搬运”,而不是“做饭”。
RDMA 的方式(单边操作):
顾客直接把一张写着“红烧肉”的便条,用强力胶水直接贴到了大厨的脑门上(或者直接贴在大厨的案板上)。
大厨看到便条,直接做菜,不需要服务员传话。
这就是 RDMA。它绕过了操作系统内核,绕过了协议栈,甚至绕过了 CPU。数据在网络适配器(NIC)之间直接传输,就像数据长了腿,直接从网卡 A 跑到了网卡 B 的内存里。
单边操作(One-sided),就是这种“直接贴脑门”的高级玩法。它允许发起方直接读写目标方的内存,而目标方甚至不需要参与这个“读写”过程,只需要在那儿等着数据落袋即可。
第二章:RDMA 的 C++ 基础设施——构建“魔法实验室”
要玩 RDMA,光靠 socket 是不行的。你需要 rdma-core(Linux 下),以及一堆 ibv_* 开头的函数。这些函数底层都是通过 C 语言实现的,但我们在 C++ 里要优雅地使用它们。
RDMA 的核心概念就像是一个瑞士军刀,我们要掌握这几个核心对象:
- Context(上下文): 你和网卡(HCA)建立的连接通道。
- PD(Protection Domain,保护域): 一个逻辑上的内存管理单元,相当于你的“领地”。
- MR(Memory Region,内存区域): 你要共享的那块内存,必须经过“注册”才能被 RDMA 访问。
- QP(Queue Pair,队列对): 通信的通道,单向还是双向。
- CQ(Completion Queue,完成队列): 告诉你“任务做完没”的收据堆。
2.1 注册内存:告诉硬件“这块地盘归我了”
在单边操作中,我们不仅要注册本地内存(为了读写),还要注册远程内存(为了被别人读写)。这涉及到两个标志位:
IBV_ACCESS_LOCAL_WRITE:允许本地 CPU 写这块内存(废话,不然怎么用)。IBV_ACCESS_REMOTE_WRITE:允许远程的 RDMA 机器写这块内存(关键!这就是单边写入的前提)。
2.2 单边操作的关键参数
当你发起单边写入时,你不需要发送 send 命令,你需要的是 ibv_rdma_write。这个函数有两个灵魂参数:
- Remote Key (rkey): 远程内存的“钥匙”。就像你家门锁的密码,别人拿着这个密码才能撬开你的门。
- Remote Address: 远程内存的“地址”。别人知道你家门牌号。
第三章:构建跨节点内存池——共享书桌
我们的目标是实现一个跨节点的内存池。想象一下,节点 A 和节点 B 是两个学生,他们共用一张大桌子。节点 A 想写作业,直接在桌子上写就行;节点 B 想看,直接看就行。中间没有中间商赚差价。
在 C++ 中,我们要做的就是:
- 服务器端: 分配一块巨大的内存,注册它(设置
IBV_ACCESS_REMOTE_WRITE),然后把这个内存的地址和 rkey 发给客户端。 - 客户端: 收到地址和 rkey,把这个远程内存映射到自己的虚拟地址空间(或者直接用指针操作)。
第四章:实战代码——从零搭建 RDMA 单边通信
废话少说,上代码。我们将分两部分:服务器端和客户端。
4.1 服务器端代码
服务器是“地主”,它拥有内存,并且允许别人进来写。
#include <infiniband/verbs.h>
#include <cstring>
#include <iostream>
#include <unistd.h>
// 一个简单的状态码枚举,方便调试
enum Status {
SUCCESS = 0,
FAILURE = 1
};
class RDMA_Server {
private:
struct ibv_context *context;
struct ibv_pd *pd;
struct ibv_cq *cq;
struct ibv_qp *qp;
struct ibv_mr *mr; // 本地内存注册
struct ibv_qp_init_attr qp_init_attr;
struct ibv_qp_attr qp_attr;
// 共享的内存池
char *shared_memory;
size_t memory_size = 4096; // 分配 4K 内存
public:
RDMA_Server() : context(nullptr), pd(nullptr), cq(nullptr), qp(nullptr), mr(nullptr), shared_memory(nullptr) {}
~RDMA_Server() {
// 资源释放:经典的 RAII 思想,虽然这里手写析构
if (qp) ibv_destroy_qp(qp);
if (cq) ibv_destroy_cq(cq);
if (pd) ibv_dealloc_pd(pd);
if (context) ibv_close_device(context);
if (shared_memory) free(shared_memory);
}
bool init() {
// 1. 获取设备列表(只拿第一个,实际生产要遍历)
struct ibv_device **dev_list = ibv_get_device_list(nullptr);
if (!dev_list) {
std::cerr << "Failed to get device list" << std::endl;
return false;
}
context = ibv_open_device(dev_list[0]);
ibv_free_device_list(dev_list);
if (!context) return false;
// 2. 创建保护域 (PD)
pd = ibv_alloc_pd(context);
if (!pd) return false;
// 3. 创建完成队列 (CQ)
cq = ibv_create_cq(context, 10, nullptr, nullptr, 0);
if (!cq) return false;
// 4. 分配内存池
shared_memory = (char *)malloc(memory_size);
if (!shared_memory) return false;
// 5. 注册内存
// 关键点:设置 IBV_ACCESS_REMOTE_WRITE,允许别人写!
mr = ibv_reg_mr(pd, shared_memory, memory_size,
IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE);
if (!mr) return false;
// 6. 创建队列对 (QP)
memset(&qp_init_attr, 0, sizeof(qp_init_attr));
qp_init_attr.qp_type = IBV_QPT_RC; // 面向连接的 Reliable Connected
qp_init_attr.send_cq = cq;
qp_init_attr.recv_cq = cq;
qp_init_attr.cap.max_send_wr = 10;
qp_init_attr.cap.max_recv_wr = 10;
qp_init_attr.cap.max_send_sge = 1;
qp_init_attr.cap.max_recv_sge = 1;
qp = ibv_create_qp(pd, &qp_init_attr);
if (!qp) return false;
// 7. 修改 QP 状态为 INIT -> READY_TO_SEND
modify_qp_to_init();
std::cout << "[Server] Ready. Shared Memory: " << mr->addr
<< ", Remote Key: " << mr->rkey << std::endl;
return true;
}
void modify_qp_to_init() {
memset(&qp_attr, 0, sizeof(qp_attr));
qp_attr.qp_state = IBV_QPS_INIT;
qp_attr.pkey_index = 0;
qp_attr.port_num = 1; // 默认端口
qp_attr.init_qp_attr = qp_init_attr;
if (ibv_modify_qp(qp, &qp_attr, IBV_QP_STATE | IBV_QP_PKEY_INDEX | IBV_QP_PORT | IBV_QP_ACCESS_FLAGS)) {
std::cerr << "Failed to modify QP to INIT" << std::endl;
}
}
void modify_qp_to_rts() {
memset(&qp_attr, 0, sizeof(qp_attr));
qp_attr.qp_state = IBV_QPS_RTS;
qp_attr.sq_psn = 0;
qp_attr.rq_psn = 0;
qp_attr.retry_cnt = 7;
qp_attr.rnr_retry_cnt = 7;
if (ibv_modify_qp(qp, &qp_attr, IBV_QP_STATE | IBV_QP_SQ_PSN | IBV_QP_RQ_PSN | IBV_QP_ACCESS_FLAGS)) {
std::cerr << "Failed to modify QP to RTS" << std::endl;
}
}
void listen() {
// 这里简化了连接管理,实际需要使用 CM (Connection Manager)
// 假设我们有一个客户端连上来了,我们修改状态为 RTS
modify_qp_to_rts();
std::cout << "[Server] QP is now READY_TO_SEND." << std::endl;
// 服务器在此等待客户端写入
// 我们可以轮询 CQ 或者使用 eventfd
struct ibv_wc wc;
while (true) {
int ne = ibv_poll_cq(cq, 1, &wc);
if (ne > 0) {
if (wc.status == IBV_WC_SUCCESS) {
std::cout << "[Server] Got a write completion! Bytes written: " << wc.byte_len << std::endl;
std::cout << "[Server] Shared memory content: " << shared_memory << std::endl;
} else {
std::cerr << "[Server] Completion failed with status " << ibv_wc_status_str(wc.status) << std::endl;
}
}
}
}
};
int main() {
RDMA_Server server;
if (!server.init()) {
return -1;
}
server.listen();
return 0;
}
4.2 客户端代码
客户端是“黑客”,它不需要内存,它只需要拿到服务器的钥匙(rkey)和门牌号(地址),然后直接把数据扔进去。
#include <infiniband/verbs.h>
#include <cstring>
#include <iostream>
#include <unistd.h>
class RDMA_Client {
private:
struct ibv_context *context;
struct ibv_pd *pd;
struct ibv_cq *cq;
struct ibv_qp *qp;
struct ibv_mr *mr; // 客户端通常不需要注册大内存,除非也要写回去
// 从服务器获取的信息
uint64_t remote_addr;
uint32_t remote_key;
struct ibv_qp_init_attr qp_init_attr;
struct ibv_qp_attr qp_attr;
char *local_buffer;
size_t buffer_size = 256;
public:
RDMA_Client() : context(nullptr), pd(nullptr), cq(nullptr), qp(nullptr), mr(nullptr), remote_addr(0), remote_key(0) {}
~RDMA_Client() {
if (qp) ibv_destroy_qp(qp);
if (cq) ibv_destroy_cq(cq);
if (pd) ibv_dealloc_pd(pd);
if (context) ibv_close_device(context);
if (local_buffer) free(local_buffer);
}
bool connect_to_server(const char *ip) {
// 1. 获取设备
struct ibv_device **dev_list = ibv_get_device_list(nullptr);
if (!dev_list) return false;
context = ibv_open_device(dev_list[0]);
ibv_free_device_list(dev_list);
if (!context) return false;
// 2. 创建 PD, CQ
pd = ibv_alloc_pd(context);
cq = ibv_create_cq(context, 10, nullptr, nullptr, 0);
// 3. 创建 QP
memset(&qp_init_attr, 0, sizeof(qp_init_attr));
qp_init_attr.qp_type = IBV_QPT_RC;
qp_init_attr.send_cq = cq;
qp_init_attr.recv_cq = cq;
qp_init_attr.cap.max_send_wr = 10;
qp_init_attr.cap.max_recv_wr = 10;
qp_init_attr.cap.max_send_sge = 1;
qp_init_attr.cap.max_recv_sge = 1;
qp = ibv_create_qp(pd, &qp_init_attr);
// 4. 修改状态为 INIT
memset(&qp_attr, 0, sizeof(qp_attr));
qp_attr.qp_state = IBV_QPS_INIT;
qp_attr.pkey_index = 0;
qp_attr.port_num = 1;
qp_attr.init_qp_attr = qp_init_attr;
if (ibv_modify_qp(qp, &qp_attr, IBV_QP_STATE | IBV_QP_PKEY_INDEX | IBV_QP_PORT | IBV_QP_ACCESS_FLAGS)) {
return false;
}
// 5. 建立连接 (简化版,实际需要 resolve address 和 route)
// 这里我们假设通过某种方式(如 CM)拿到了服务器的地址和 rkey
// 在真实场景中,你需要使用 rdma_resolve_addr, rdma_resolve_route 等
// 这里为了演示单边操作,我们模拟“黑客”拿到了钥匙
// remote_addr = server_mr->addr;
// remote_key = server_mr->rkey;
// 为了代码能跑通,我们这里硬编码一下(假设服务器在 192.168.1.100)
// 实际上,你需要通过 rdma_cm 解析出 server_addr
// 这里仅作演示逻辑:
std::cout << "Enter Server Remote Address (Hex): ";
std::cin >> remote_addr;
std::cout << "Enter Server Remote Key: ";
std::cin >> remote_key;
// 6. 修改状态为 RTS
memset(&qp_attr, 0, sizeof(qp_attr));
qp_attr.qp_state = IBV_QPS_RTS;
qp_attr.sq_psn = 0;
qp_attr.rq_psn = 0;
qp_attr.retry_cnt = 7;
qp_attr.rnr_retry_cnt = 7;
if (ibv_modify_qp(qp, &qp_attr, IBV_QP_STATE | IBV_QP_SQ_PSN | IBV_QP_RQ_PSN | IBV_QP_ACCESS_FLAGS)) {
return false;
}
std::cout << "[Client] Connected to Server!" << std::endl;
return true;
}
void perform_rdma_write() {
// 1. 准备数据
local_buffer = (char *)malloc(buffer_size);
strcpy(local_buffer, "Hello RDMA! This is a Zero-Copy Write!");
// 2. 注册内存 (虽然这里是写远程,但如果是读远程,也需要注册本地接收缓冲区)
// 这里我们不需要写本地,所以可以不注册,或者注册个 dummy
// 实际上,为了 poll cq,我们需要注册一个接收 buffer,或者直接 poll
// 在单边操作中,不需要 recv buffer,因为目标端自己处理了。
// 但是,为了处理 Completion Event,我们需要一个接收 buffer。
// 简化起见,我们这里假设不需要接收 buffer,或者由 CQ 自动处理。
// 3. 发起单边写入 (核心!)
struct ibv_send_wr wr;
struct ibv_send_wr *bad_wr;
struct ibv_sge sge;
memset(&wr, 0, sizeof(wr));
memset(&sge, 0, sizeof(sge));
// 设置 SGE (Source Geometry)
sge.addr = (uint64_t)local_buffer;
sge.length = buffer_size;
sge.lkey = 0; // 如果本地没注册,设为 0 (Zero-based),或者注册个 dummy
// 设置 WR (Work Request)
wr.wr_id = 1;
wr.sg_list = &sge;
wr.num_sge = 1;
// 关键函数:RDMA Write
wr.opcode = IBV_WR_RDMA_WRITE; // 单边写
wr.wr.rdma.remote_addr = remote_addr; // 目标地址
wr.wr.rdma.rkey = remote_key; // 目标钥匙
// 4. 提交给硬件
int ret = ibv_post_send(qp, &wr, &bad_wr);
if (ret) {
std::cerr << "Failed to post send" << std::endl;
return;
}
std::cout << "[Client] Posted RDMA Write request. Waiting for completion..." << std::endl;
// 5. 轮询 CQ 等待结果
struct ibv_wc wc;
int ne = ibv_poll_cq(cq, 1, &wc);
if (ne > 0) {
if (wc.status == IBV_WC_SUCCESS) {
std::cout << "[Client] RDMA Write Successful!" << std::endl;
std::cout << "Data received by server: " << wc.byte_len << " bytes" << std::endl;
} else {
std::cerr << "[Client] Failed: " << ibv_wc_status_str(wc.status) << std::endl;
}
} else {
std::cerr << "[Client] No completion event" << std::endl;
}
}
};
int main() {
RDMA_Client client;
if (!client.connect_to_server("192.168.1.100")) {
return -1;
}
client.perform_rdma_write();
return 0;
}
第五章:深入解析——单边操作的魅力与坑
上面的代码展示了最基础的流程。现在,让我们像外科医生一样,切开来看看里面的构造。
5.1 为什么叫“单边”?
在传统的 ibv_post_send 中,发送方和接收方都有角色。而在 ibv_wr_rdma_write 中,接收方完全被动。
这就好比你在网上买书。传统方式是你下单 -> 商家发货 -> 物流送到你家 -> 你签收。商家和快递员都在忙。
单边方式是你直接把书扔到商家仓库里,商家自己拿。你不需要等快递员,商家也不需要打电话通知你。
5.2 内存屏障与一致性
这是 C++ 和 RDMA 交互中最微妙的地方。
当你在客户端调用 ibv_post_send 发起 RDMA 写入后,CPU 可能会认为数据还在缓冲区里,或者还没发送出去,于是 CPU 接着往下执行代码。但是,网络传输是异步的!数据可能还在网卡里排队。
如果你紧接着去读取本地内存来验证数据,或者去调用下一次发送,可能会出问题。
解决方案:
在 ibv_post_send 之后,你需要使用 ibv_flush_cache 或者依赖硬件的隐式屏障。更重要的是,一定要轮询 CQ(Completion Queue)。
ibv_poll_cq 是一个阻塞函数(或者至少它会等待)。它保证了在返回之前,硬件已经完成了写入操作,并且内存中的数据已经更新完毕。这是保证数据一致性的最后一道防线。
5.3 错误处理:别让程序“沉默爆炸”
RDMA 的错误码非常丰富。IBV_WC_SUCCESS 只是天堂,地狱里有 IBV_WC_MW_BIND_ERR,IBV_WC_ACCESS_VIOLATION(权限不足),IBV_WC_LOC_QP_OP_ERR(本地队列错误)。
在单边操作中,最常见的错误就是 IBV_WC_ACCESS_VIOLATION。这通常意味着:
- 服务器的内存没有注册
IBV_ACCESS_REMOTE_WRITE。 - 客户端提供的
rkey是错误的。 - 服务器的 QP 状态不是
RTS(Ready to Send),还在INIT或者RTR状态。
第六章:进阶话题——内存池的构造与优化
我们刚才演示的是“点对点”的单边写入。但在实际的生产环境中,我们往往需要构建一个共享内存池。
6.1 共享内存池的架构
想象一个巨大的内存块被切成 N 份。服务器把这块内存注册,并把每一块的 rkey 和 offset 发给客户端。
客户端需要维护一个本地索引,对应远程的 rkey 和 offset。
// 客户端侧的内存池管理结构
struct RemoteBlock {
uint64_t remote_addr;
uint32_t rkey;
// 可以加一些元数据,比如当前块的大小,或者锁(如果需要互斥访问)
};
std::vector<RemoteBlock> pool_blocks;
void register_pool_block(uint64_t addr, uint32_t key) {
RemoteBlock block;
block.remote_addr = addr;
block.rkey = key;
pool_blocks.push_back(block);
}
// 高效写入
void write_to_pool_block(int index, const char *data) {
if (index >= pool_blocks.size()) return;
struct ibv_send_wr wr;
// ... 填充 wr ...
wr.wr.rdma.remote_addr = pool_blocks[index].remote_addr;
wr.wr.rdma.rkey = pool_blocks[index].rkey;
ibv_post_send(qp, &wr, &bad_wr);
}
6.2 多租户与安全性
如果多个客户端共享同一个内存池,怎么保证数据不冲突?这就需要应用层的锁。RDMA 硬件本身并不保证单边写入的原子性(除非使用特定的原子操作指令,如 IBV_WR_ATOMIC_CMP_AND_SWAP)。
所以,单边操作 + 内存池 = 高性能 + 需要程序员自己当“交警”。
第七章:C++ 现代特性与 RDMA 的结合
虽然 RDMA 库是 C 风格的,但我们可以用 C++ 来包装它,让它更安全、更易用。
7.1 RAII 封装
我们刚才写的析构函数是手动的。在 C++11 及以后,我们可以用智能指针来管理资源。
#include <memory>
class RDMA_Memory_Region {
private:
struct ibv_mr *mr;
char *addr;
size_t length;
struct ibv_pd *pd;
public:
// 构造函数:注册内存
RDMA_Memory_Region(struct ibv_pd *pd_ptr, void *local_addr, size_t len)
: pd(pd_ptr), length(len) {
// 必须设置正确的权限 flags
mr = ibv_reg_mr(pd_ptr, local_addr, len,
IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE | IBV_ACCESS_REMOTE_READ);
if (!mr) throw std::runtime_error("MR Registration Failed");
addr = (char*)local_addr;
}
// 获取远程键
uint32_t get_rkey() const { return mr->rkey; }
// 获取地址偏移(方便计算)
uint64_t get_addr() const { return (uint64_t)addr; }
// 析构函数:自动注销
~RDMA_Memory_Region() {
if (mr) ibv_dereg_mr(mr);
}
// 禁止拷贝
RDMA_Memory_Region(const RDMA_Memory_Region&) = delete;
RDMA_Memory_Region& operator=(const RDMA_Memory_Region&) = delete;
};
这样,当 RDMA_Memory_Region 对象离开作用域时,内存会自动注销,防止内存泄漏。
7.2 异步模型
上面的代码用了同步的 poll_cq。在 C++ 中,我们可以结合 std::future 或 std::promise 来实现异步。
std::promise<void> write_promise;
std::future<void> write_future = write_promise.get_future();
// 在 CQ poll 回调中 (或者主循环中)
if (wc.status == IBV_WC_SUCCESS) {
write_promise.set_value();
}
这样,主线程就可以在发送请求后去做其他事情,等待数据写入完成。
第八章:性能调优与陷阱——老司机的经验之谈
理论讲完了,现在讲讲实战中的“坑”。
8.1 SGE (Scatter-Gather Element) 的数量
ibv_post_send 中的 num_sge 决定了一次发送可以包含多少个内存段。如果你一次要发送 1MB 的数据,但 max_send_sge 只能支持 1,你就得把 1MB 拆成 1024 个 1KB 的包发过去。这会极大地增加延迟!
优化: 在创建 QP 时,调大 cap.max_send_sge 和 cap.max_send_wr。
8.2 Receive Queue 的必要性
很多人以为单边操作不需要 Receive Queue。错!
Receive Queue 用于处理 Completion Events。当你调用 ibv_poll_cq 时,你是在从 Receive Queue 里拿事件。即使你不用 recv 命令,你也必须有一个 Receive Queue 来接收“任务完成”的通知。否则,CQ 是空的,程序就卡死了。
8.3 MTU (Maximum Transmission Unit)
如果你的数据包比 MTU 大,硬件会自动分片。在 RDMA 中,分片是大忌!它会增加延迟,破坏流水线。
建议: 将 MTU 设置为 4096 字节(Jumbo Frames),确保你的业务数据包都在这个范围内。
8.4 错误恢复
RDMA 是无连接的(虽然 RC 模式有连接)。如果网络抖动,QP 可能会断开。你需要实现重连逻辑。一旦检测到 IBV_WC_RETRY_EXC_ERR,就尝试 ibv_modify_qp 回 INIT 状态,重新握手。
第九章:总结——拥抱 RDMA 的未来
好了,同学们,今天的讲座接近尾声。
我们回顾了:
- 为什么传统网络慢: CPU 忙于拷贝和上下文切换。
- RDMA 是什么: 硬件加速的直连内存访问。
- 单边操作的魅力: 客户端直接写入服务器内存,无需等待接收。
- C++ 实践: 如何注册内存,如何获取 rkey,如何调用
ibv_rdma_write。 - 工程细节: CQ 轮询,SGE 设置,错误处理。
最后一点忠告:
RDMA 是一把双刃剑。它极快,但极难用。它要求你对网络协议栈、硬件内存管理有深刻的理解。如果你只是写个简单的聊天室,用 TCP 就好了。但如果你在构建一个分布式数据库、高性能计算集群,或者一个每秒需要处理百万次请求的 API 网关,RDMA 就是你的救命稻草。
当你看到 CPU 占用率维持在 5% 以下,而网络吞吐量却直冲云霄时,那种感觉,就像是你给赛车装上了火箭推进器。这就是 RDMA 带来的快感。
代码已经写在上面了,剩下的路,就靠你们自己去踩了。记得,注册内存的时候,别忘了给远程写权限!别到时候被硬件狠狠地“拒绝访问”!
谢谢大家,下课!