DatenLord | Rust实现RDMA

作者|王璞

后期编辑|张汉东

转自《RustMagazine中文精选》


RDMA是常⽤于⾼性能计算(HPC)领域的⾼速⽹络,在存储⽹络等专⽤场景也有⼴泛的⽤途。RDMA最⼤的特点是通过软硬件配合,在⽹络传输数据的时候,完全不需要CPU/内核参与,从⽽实现⾼性能的传输⽹络。最早RDMA要求使⽤InfiniBand (IB)⽹络,采⽤专⻔的IB⽹卡和IB交换机。现在RDMA也可以采⽤以太⽹交换机,但是还需要专⽤的IB⽹卡。虽然也有基于以太⽹卡⽤软件实现RDMA的⽅案,但是这种⽅案没有性能优势。

RDMA在实际使⽤的时候,需要采⽤特定的接⼝来编程,⽽且由于RDMA在传输数据的过程中,CPU/内核不参与,因此很多底层的⼯作需要在RDMA编程的时候⾃⾏实现。⽐如RDMA传输时涉及的各种内存管理⼯作,都要开发者调⽤RDMA的接⼝来完成,甚⾄⾃⾏实现,⽽不像在socket编程的时候,有内核帮忙做各种缓存等等。也正是由于RDMA编程的复杂度很⾼,再加上先前RDMA硬件价格⾼昂,使得RDMA不像TCP/IP得到⼴泛使⽤。

本⽂主要介绍我们⽤Rust对RDMA的C接⼝封装时碰到的各种问题,并探讨下如何⽤Rust对RDMA实现safe封装。下⾯⾸先简单介绍RDMA的基本编程⽅式,然后介绍下采⽤Rust对RDMA的C接⼝封装时碰到的各种技术问题,最后介绍下后续⼯作。我们⽤Rust实现的RDMA封装已经开源,包括rdma-sysasync-rdma,前者是对RDMA接⼝的unsafe封装,后者是safe封装(尚未完成)。

目录


RDMA编程理念 


先⾸先简要介绍下RDMA编程,因为本⽂重点不是如何⽤RDMA编程,所以主要介绍下RDMA的编程理念。RDMA的全称是Remote Direct Memory Access,从字⾯意思可以看出,RDMA要实现直接访问远程内存,RDMA的很多操作就是关于如何在本地节点和远程节点之间实现内存访问。

  

RDMA的数据操作分为“单边”和“双边”,双边为send/receive,单边是read/write,本质都是在本地和远程节点之间共享内存。对于双边来说,需要双⽅节点的CPU共同参与,⽽单边则仅仅需要⼀⽅CPU参与即可,对于另⼀⽅的CPU是完全透明的,不会触发中断。根据上述解释,⼤家可以看出“单边”传输才是被⽤来传输⼤量数据的主要⽅法。但是“单边”传输也⾯临这下列挑战:

   1. 由于RDMA在数据传输过程中不需要内核参与,所以内核也⽆法帮助RDMA缓存数据,因此RDMA要求在写⼊数据的时候,数据的⼤⼩不能超过接收⽅准备好的共享内存⼤⼩,否则出错。所以发送⽅和接收⽅在写数据前必须约定好每次写数据的⼤⼩。

  2. 此外,由于RDMA在数据传输过程中不需要内核参与,因此有可能内核会把本地节点要通过RDMA共享给远程节点的内存给交换出去,所以RDMA必须要跟内核申请把共享的内存空间常驻内存,这样保证远程节点通过RDMA安全访问本地节点的共享内存。

   3. 再者,虽然RDMA需要把本地节点跟远程节点共享的内存空间注册到内核,以防内核把共享内存空间交换出去,但是内核并不保证该共享内存的访问安全。即本地节点的程序在更新共享内存数据时,有可能远程节点正在访问该共享内存,导致远程节点读到不⼀致的数据;反之亦然,远程节点在写⼊共享内存时,有可能本地节点的程序也正在读写该共享内存,导致数据冲突或不⼀致。使⽤RDMA编程的开发者必须⾃⾏保证共享内存的数据⼀致性,这也是RDMA编程最复杂的关键点。

总之,RDMA在数据传输过程中绕开了内核,极⼤提升性能的同时,也带来很多复杂度,特别是关于内存管理的问题,都需要开发者⾃⾏解决。

RDMA的unsafe封装


RDMA的编程接⼝主要是C实现的rdma-core,最开始我们觉得⽤Rust的bingen可以很容易⽣成对rdma-core的Rust封装,但实际中却碰到了很多问题。

⾸先,rdma-core有⼤量的接⼝函数是inline⽅式定义,⾄少上百个inline函数接⼝,bindgen在⽣成Rust封装时直接忽略所有的inline函数,导致我们必须⼿动实现。Rust社区有另外⼏个开源项⽬也实现了对rdma-core的Rust封装,但是都没有很好解决inline函数的问题。此外,我们在⾃⾏实现rdma-core的inline函数Rust封装时,保持了原有的函数名和参数名不变。

其次,rdma-core有不少宏定义,bindgen在⽣成Rust封装时也直接忽略所有的宏定义,于是我们也必须⼿动实现⼀些关键的宏定义,特别是要⼿动实现rdma-core⾥⽤宏定义实现的接⼝函数和⼀些关键常量。

再有,rdma-core有很多数据结构的定义⽤到了union,但是bindgen对C的union处理得不好,并不是直接转换成Rust⾥的union。更严重的是rdma-core的数据结构⾥还⽤到匿名union,如下所示:

struct ibv_wc {
    ...
	union {
		__be32		imm_data;
		uint32_t	invalidated_rkey;
	};
    ...
};

由于Rust不⽀持匿名union,针对这些rdma-core的匿名union,bindgen在⽣成的Rust binding⾥会⾃动⽣成union类型的名字,但是bindgen⾃动⽣成的名字对开发者很不友好,诸如ibv_flow_spec__bindgen_ty_1__bindgen_ty_1 这种名字,所以我们都是⼿动重新定义匿名union,如下所示:

#[repr(C)]
pub union imm_data_invalidated_rkey_union_t {
    pub imm_data: __be32,
    pub invalidated_rkey: u32,
}

#[repr(C)]
pub struct ibv_wc {
    ...
    pub imm_data_invalidated_rkey_union: imm_data_invalidated_rkey_union_t,
    ...
}

再次,rdma-core⾥引⽤了很多C的数据结构,诸如pthread_mutex_t和 sockaddr_in 之类,这些数据结构应该使⽤Rust libc⾥定义好的,⽽不是由bindgen再重新定义⼀遍。所以我们需要配置bindgen不重复⽣成libc⾥已经定义好的数据结构的Rust binding。

简单⼀句话总结下,bindgen对⽣成rdma-core的unsafe封装只能起到⼀半作⽤,剩下很多⼯作还需要⼿动完成,⾮常细碎。不过好处是,RDMA接⼝已经稳定,此类⼯作只需要⼀次操作即可,后续⼏乎不会需要⼤量更新。

RDMA的safe封装


 关于RDMA的safe封装,有两个层⾯的问题需要考虑:

  • 如何做到符合Rust的规范和惯例;

  • 如何实现RDMA操作的内存安全。

⾸先,关于RDMA的各种数据结构类型,怎样才能封装成对Rust友好的类型。rdma-core⾥充斥着⼤量的指针,绝⼤多数指针被bindgen定义为 *mut 类型,少部分定义为 *const 类型。在Rust⾥,这些裸指针类型不是 Sync 也不是 Send ,因此不能多线程访问。如果把这些裸指针转化为引⽤,⼜涉及到⽣命周期问题,⽽这些指针指向的数据结构都是rdma-core⽣成的,⼤都需要显式的释放,⽐如 struct ibv_wq 这个数据结构由 ibv_create_wq()函数创建,并由 ibv_destroy_wq() 函数释放:

struct ibv_wq *ibv_create_wq(...);

int ibv_destroy_wq(struct ibv_wq *wq);

但是⽤Rust开发RDMA应⽤的时候,Rust代码并不直接管理 struct ibv_wq 这个数据结构的⽣命周期。进⼀步,在Rust代码中并不会直接修改rdma-core创建的各种数据结构,Rust代码都是通过调⽤rdma-core的接⼝函数来操作各种RDMA的数据结构/指针。所以对Rust代码来说,rdma-core⽣成的各种数据结构的指针,本质是⼀个句柄/handler,这个handler的类型是不是裸指针类型并不重要。于是,为了在Rust代码中便于多线程访问,我们把rdma-core返回的裸针类型都转换成 usize 类型,当需要调⽤rdma-core的接⼝函数时,再从usize转换成相应的裸指针类型。这么做听上去很hack,但背后的原因还是很显⽽易⻅的。进⼀步,对于在rdma-core中需要⼿动释放的资源,可以通过实现Rust的 Drop trait ,在 drop() 函数中调⽤rdma-core相应的接⼝实现资源⾃动释放。

其次,关于RDMA的内存安全问题,这部分⼯作尚未完成。⽬前RDMA的共享内存访问安全问题在学术界也是个热⻔研究课题,并没有完美的解决⽅案。本质上讲,RDMA的共享内存访问安全问题是由于为了实现⾼性能⽹络传输、绕过内核做内存共享带来的,内核在内存管理⽅⾯做了⼤量的⼯作,RDMA的数据传输绕过内核,因此RDMA⽆法利⽤内核的内存管理机制保证内存安全。如果要把内核在内存管理⽅⾯的⼯作都搬到⽤户态来实现RDMA共享内存访问安全,这么做的话⼀⽅⾯复杂度太⾼,另⼀⽅⾯也不⼀定有很好的性能。

在实际使⽤中,⼈们会对RDMA的使⽤⽅式进⾏规约,⽐如不允许远程节点写本地节点的共享内存,只允许远程节点读。但即便是只允许远程读取,也有可能有数据不⼀致的问题。⽐如远程节点读取了共享内存的前半段数据,本地节点开始更新共享内存。假定本地节点更新的数据很少⽽远程节点读取的数据很多,因此本地节点更新的速度⽐远程节点读取的速度快,导致有可能本地节点在远程节点读后半段数据前更新完毕,这样远程节点读取的是不⼀致的数据,前半段数据不包括更新数据但是后半段包括更新数据。远程节点读到的这个不⼀致的数据,既不是先前真实存在的某个版本的数据,也不是全新版本的数据,破坏了数据⼀致性的保证。

针对RDMA内存安全问题,⼀个常⻅的解决⽅案是采⽤⽆锁(Lock-free)数据结构。⽆锁数据结构本质上就是解决并发访问下保证内存安全问题,当多个线程并发修改时,⽆锁数据结构保证结果的⼀致性。针对上⾯提到的远程读、本地写的⽅式,可以采⽤Seqlock来实现。即每块RDMA的共享内存空间关联⼀个序列号(sequence number),本地节点每次修改共享内存前就把序列号加⼀,远程节点在读取开始和结束后检查序列号是否有变化,没有变化说明读取过程中共享内存没有被修改,序列号有变化说明读取过程中共享内存被修改,读到了有可能不⼀致的数据,则远程节点重新读取共享内存。

如果要放宽对RDMA的使⽤规约,即远程节点和本地节点都可以读写共享内存的场景,那么就需要采⽤更加复杂的算法或⽆锁数据结构,诸如Copy-on-Write和Read-Copy-Update等。内核中⼤量使⽤Copy-on-WriteRead-Copy-Update这两种技术来实现⾼效内存管理。这⽅⾯的⼯作有不少技术难度。 

后续工作


下⼀步在完成对RDMA的safe封装之后,我们规划⽤Rust实现对RDMA接⼝函数的异步调⽤。因为RDMA都是IO操作,⾮常适合异步⽅式来实现。

对RDMA接⼝函数的异步处理,最主要的⼯作是关于RDMA的完成队列的消息处理。RDMA采⽤了多个⼯作队列,包括接收队列(RQ),发送队列(SQ)以及完成队列(CQ),这些队列⼀般是RDMA的硬件来实现。其中发送队列和接收队列的功能很好理解,如字⾯意思,分别是存放待发送和待接收的消息,消息是指向内存中的⼀块区域,在发送时该内存区域包含要发送的数据,在接收时该内存区域⽤于存放接收数据。在发送和接收完成后,RDMA会在完成队列⾥放⼊完成消息,⽤于指示相应的发送消息或接收消息是否成功。⽤户态RDMA程序可以定期不定期查询完成队列⾥的完成消息,也可以通过中断的⽅式在CPU收到中断后由内核通知应⽤程序处理。

异步IO本质上都是利⽤Linux的epoll机制,由内核来通知⽤户态程序某个IO已经就绪。对RDMA操作的异步处理,⽅法也⼀样。RDMA是通过创建设备⽂件来实现⽤户态RDMA程序跟内核⾥的RDMA模块交互。在安装RDMA设备和驱动后,RDMA会创建⼀个或多个字符设备⽂件, /dev/infiniband/uverbsN ,N从0开始,有⼏个RDMA设备就有⼏个 uverbsN 设备⽂件。如果只有⼀个那就是 /dev/infiniband/uverbs0 。⽤户态RDMA程序要实现针对RDMA完成队列的异步消息处理,就是采⽤Linux提供的epoll机制,对RDMA的 uverbsN 设备⽂件进⾏异步查询,在完成队列有新消息时通知⽤户态RDMA程序来处理消息。

关于RDMA的封装,这块⼯作我们还没有完成,我们打算把RDMA的safe封装以及对RDMA的共享内存管理都实现,这样才能⽅便地使⽤Rust进⾏RDMA编程,同时我们欢迎有感兴趣的朋友⼀起参与。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

达坦科技DatenLord

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值